Add infrastructure: Terraform, Docker Compose, BRouter, CD pipeline (#2)

Terraform:
- Hetzner CX22 server with Docker, firewall (80/443/22)
- SSH key and output for server IP

Docker Compose:
- Caddy reverse proxy (auto HTTPS)
- Journal and Planner app containers
- BRouter routing engine container
- PostgreSQL + PostGIS
- Garage S3 storage

BRouter:
- Dockerfile based on eclipse-temurin:11-jre
- Segment download script for Germany (4 tiles, ~750MB)

CD Pipeline:
- Build & push Docker images to ghcr.io on main push
- Deploy step commented out (enable when server provisioned)

Scripts:
- PostgreSQL daily backup with 14-day retention
- Dockerfiles for both apps (multi-stage builds)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-22 13:32:01 +01:00 committed by GitHub
parent 464ba0f64d
commit 7908a518b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 429 additions and 7 deletions

View file

@ -0,0 +1,8 @@
# trails.cool infrastructure environment variables
# Copy to .env and fill in values
DOMAIN=trails.cool
POSTGRES_PASSWORD=change-me
JWT_SECRET=change-me
S3_ACCESS_KEY=
S3_SECRET_KEY=

7
infrastructure/Caddyfile Normal file
View file

@ -0,0 +1,7 @@
{$DOMAIN:trails.cool} {
reverse_proxy journal:3000
}
planner.{$DOMAIN:trails.cool} {
reverse_proxy planner:3001
}

View file

@ -0,0 +1,84 @@
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- journal
- planner
journal:
image: ghcr.io/trails-cool/journal:latest
restart: unless-stopped
environment:
DOMAIN: ${DOMAIN:-trails.cool}
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/trails
S3_ENDPOINT: http://garage:3900
S3_BUCKET: trails-media
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
PORT: 3000
depends_on:
postgres:
condition: service_healthy
planner:
image: ghcr.io/trails-cool/planner:latest
restart: unless-stopped
environment:
BROUTER_URL: http://brouter:17777
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/trails
PORT: 3001
depends_on:
postgres:
condition: service_healthy
brouter:
condition: service_started
brouter:
image: ghcr.io/trails-cool/brouter:latest
restart: unless-stopped
volumes:
- brouter_segments:/data/segments
- brouter_profiles:/data/profiles
# Segments can be pulled from:
# - https://brouter.de/brouter/segments4/ (official, weekly updates)
# - A trails.cool CDN mirror (later)
postgres:
image: postgis/postgis:16-3.4
restart: unless-stopped
environment:
POSTGRES_USER: trails
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-trails}
POSTGRES_DB: trails
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U trails"]
interval: 5s
timeout: 5s
retries: 5
garage:
image: dxflrs/garage:v1.0
restart: unless-stopped
volumes:
- garage_data:/var/lib/garage
- ./garage.toml:/etc/garage.toml:ro
volumes:
caddy_data:
caddy_config:
pgdata:
brouter_segments:
brouter_profiles:
garage_data:

View file

@ -0,0 +1,26 @@
#!/bin/bash
set -euo pipefail
# Daily PostgreSQL backup
# Run via cron: 0 3 * * * /opt/trails-cool/scripts/backup-postgres.sh
BACKUP_DIR="${BACKUP_DIR:-/backups}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/trails_${TIMESTAMP}.sql.gz"
mkdir -p "$BACKUP_DIR"
echo "[$(date)] Starting PostgreSQL backup..."
docker compose -f /opt/trails-cool/docker-compose.yml exec -T postgres \
pg_dump -U trails trails | gzip > "$BACKUP_FILE"
echo "[$(date)] Backup saved to ${BACKUP_FILE} ($(du -h "$BACKUP_FILE" | cut -f1))"
# Remove backups older than retention period
echo "[$(date)] Removing backups older than ${RETENTION_DAYS} days..."
find "$BACKUP_DIR" -name "trails_*.sql.gz" -mtime "+${RETENTION_DAYS}" -delete
echo "[$(date)] Backup complete. Current backups:"
ls -lh "$BACKUP_DIR"/trails_*.sql.gz 2>/dev/null || echo " No backups found"

5
infrastructure/terraform/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.terraform/
*.tfstate
*.tfstate.backup
*.tfvars
.terraform.lock.hcl

View file

@ -0,0 +1,79 @@
terraform {
required_version = ">= 1.0"
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.45"
}
}
}
provider "hcloud" {
token = var.hcloud_token
}
resource "hcloud_ssh_key" "deploy" {
name = "trails-cool-deploy"
public_key = var.ssh_public_key
}
resource "hcloud_server" "trails" {
name = "trails-cool"
server_type = "cx22"
image = "ubuntu-24.04"
location = "fsn1"
ssh_keys = [hcloud_ssh_key.deploy.id]
labels = {
project = "trails-cool"
}
user_data = <<-EOF
#!/bin/bash
set -euo pipefail
# Install Docker
curl -fsSL https://get.docker.com | sh
# Install Docker Compose plugin
apt-get install -y docker-compose-plugin
# Create app directory
mkdir -p /opt/trails-cool
EOF
}
resource "hcloud_firewall" "trails" {
name = "trails-cool"
rule {
direction = "in"
protocol = "tcp"
port = "22"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "80"
source_ips = ["0.0.0.0/0", "::/0"]
}
rule {
direction = "in"
protocol = "tcp"
port = "443"
source_ips = ["0.0.0.0/0", "::/0"]
}
}
resource "hcloud_firewall_attachment" "trails" {
firewall_id = hcloud_firewall.trails.id
server_ids = [hcloud_server.trails.id]
}
output "server_ip" {
value = hcloud_server.trails.ipv4_address
}

View file

@ -0,0 +1,10 @@
variable "hcloud_token" {
description = "Hetzner Cloud API token"
type = string
sensitive = true
}
variable "ssh_public_key" {
description = "SSH public key for server access"
type = string
}