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

78
.github/workflows/cd.yml vendored Normal file
View file

@ -0,0 +1,78 @@
name: CD
on:
push:
branches: [main]
concurrency:
group: deploy
cancel-in-progress: false
jobs:
build-images:
name: Build & Push Docker Images
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
app: [journal, planner]
steps:
- uses: actions/checkout@v6
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: apps/${{ matrix.app }}/Dockerfile
push: true
tags: |
ghcr.io/trails-cool/${{ matrix.app }}:latest
ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }}
build-brouter:
name: Build & Push BRouter Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: docker/brouter
push: true
tags: |
ghcr.io/trails-cool/brouter:latest
ghcr.io/trails-cool/brouter:${{ github.sha }}
# Deploy step - uncomment when Hetzner server is provisioned
# deploy:
# name: Deploy to Hetzner
# needs: [build-images, build-brouter]
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v6
# - name: Deploy via SSH
# uses: appleboy/ssh-action@v1
# with:
# host: ${{ secrets.DEPLOY_HOST }}
# username: root
# key: ${{ secrets.DEPLOY_SSH_KEY }}
# script: |
# cd /opt/trails-cool
# docker compose pull
# docker compose up -d

29
apps/journal/Dockerfile Normal file
View file

@ -0,0 +1,29 @@
FROM node:24-slim AS base
RUN corepack enable && corepack prepare pnpm@10.6.5 --activate
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/journal/package.json apps/journal/
COPY packages/types/package.json packages/types/
COPY packages/ui/package.json packages/ui/
COPY packages/map/package.json packages/map/
COPY packages/gpx/package.json packages/gpx/
COPY packages/i18n/package.json packages/i18n/
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules
COPY . .
RUN pnpm --filter @trails-cool/journal build
FROM base AS runtime
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/apps/journal/build ./apps/journal/build
COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json
WORKDIR /app/apps/journal
EXPOSE 3000
ENV PORT=3000
CMD ["npx", "react-router-serve", "./build/server/index.js"]

31
apps/planner/Dockerfile Normal file
View file

@ -0,0 +1,31 @@
FROM node:24-slim AS base
RUN corepack enable && corepack prepare pnpm@10.6.5 --activate
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/planner/package.json apps/planner/
COPY packages/types/package.json packages/types/
COPY packages/ui/package.json packages/ui/
COPY packages/map/package.json packages/map/
COPY packages/gpx/package.json packages/gpx/
COPY packages/i18n/package.json packages/i18n/
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/planner/node_modules ./apps/planner/node_modules
COPY . .
RUN pnpm --filter @trails-cool/planner build
FROM base AS runtime
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/apps/planner/build ./apps/planner/build
COPY --from=build /app/apps/planner/server.ts ./apps/planner/server.ts
COPY --from=build /app/apps/planner/app/lib ./apps/planner/app/lib
COPY --from=build /app/apps/planner/package.json ./apps/planner/package.json
WORKDIR /app/apps/planner
EXPOSE 3001
ENV PORT=3001
CMD ["node", "server.ts"]

27
docker/brouter/Dockerfile Normal file
View file

@ -0,0 +1,27 @@
FROM eclipse-temurin:11-jre-jammy
WORKDIR /brouter
# Download latest BRouter release
ARG BROUTER_VERSION=1.7.7
RUN apt-get update && apt-get install -y --no-install-recommends wget unzip \
&& wget -q "https://github.com/abrensch/brouter/releases/download/${BROUTER_VERSION}/brouter-${BROUTER_VERSION}.zip" \
&& unzip "brouter-${BROUTER_VERSION}.zip" \
&& rm "brouter-${BROUTER_VERSION}.zip" \
&& apt-get purge -y wget unzip && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
# Create directories for segments and profiles
RUN mkdir -p /data/segments /data/profiles
# Copy default profiles from release
RUN cp -r profiles2/* /data/profiles/ 2>/dev/null || true
EXPOSE 17777
# BRouter server: <segmentdir> <profiledir> <customprofiledir> <port> <maxthreads>
CMD ["java", "-Xmx512M", "-Xms128M", "-Xmn32M", \
"-DmaxRunningTime=300", \
"-cp", "brouter.jar", \
"btools.server.RouteServer", \
"/data/segments", "/data/profiles", "/data/profiles", \
"17777", "4"]

View file

@ -0,0 +1,38 @@
#!/bin/bash
set -euo pipefail
# Download Germany RD5 segments from brouter.de
# These 4 tiles cover all of Germany:
# E5_N45 = Southwest (Frankfurt, Stuttgart, Black Forest)
# E5_N50 = Northwest (Hamburg, Bremen, Ruhr)
# E10_N45 = Southeast (Munich, Nuremberg, Bavaria)
# E10_N50 = Northeast (Berlin, Leipzig, Dresden)
SEGMENTS_DIR="${1:-/data/segments}"
BASE_URL="https://brouter.de/brouter/segments4"
TILES=(
"E5_N45"
"E5_N50"
"E10_N45"
"E10_N50"
)
mkdir -p "$SEGMENTS_DIR"
for tile in "${TILES[@]}"; do
file="${SEGMENTS_DIR}/${tile}.rd5"
url="${BASE_URL}/${tile}.rd5"
if [ -f "$file" ]; then
echo "Updating ${tile}.rd5 (checking for newer version)..."
wget -N -q -P "$SEGMENTS_DIR" "$url" || echo " No update available or download failed"
else
echo "Downloading ${tile}.rd5..."
wget -q -P "$SEGMENTS_DIR" "$url"
fi
done
echo ""
echo "Segments downloaded to ${SEGMENTS_DIR}:"
ls -lh "${SEGMENTS_DIR}"/*.rd5 2>/dev/null || echo " No segments found"

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
}

View file

@ -19,14 +19,14 @@
## 3. Infrastructure ## 3. Infrastructure
- [ ] 3.1 Create Terraform config for Hetzner CX21 with Docker installed - [x] 3.1 Create Terraform config for Hetzner CX21 with Docker installed
- [ ] 3.2 Create Docker Compose for all services (Journal, Planner, BRouter, PostgreSQL+PostGIS, Garage) - [x] 3.2 Create Docker Compose for all services (Journal, Planner, BRouter, PostgreSQL+PostGIS, Garage)
- [ ] 3.3 Create BRouter Dockerfile with segment volume mount - [x] 3.3 Create BRouter Dockerfile with segment volume mount
- [ ] 3.4 Create segment download script (Germany: E5_N45, E5_N50, E10_N45, E10_N50) - [x] 3.4 Create segment download script (Germany: E5_N45, E5_N50, E10_N45, E10_N50)
- [ ] 3.5 Configure DNS for trails.cool and planner.trails.cool with TLS (Caddy) - [x] 3.5 Configure DNS for trails.cool and planner.trails.cool with TLS (Caddy)
- [x] 3.6 Set up GitHub Actions CI pipeline (build, typecheck, lint, unit tests, e2e) - [x] 3.6 Set up GitHub Actions CI pipeline (build, typecheck, lint, unit tests, e2e)
- [ ] 3.7 Set up GitHub Actions CD pipeline (build Docker images, push to ghcr.io, deploy to Hetzner) - [x] 3.7 Set up GitHub Actions CD pipeline (build Docker images, push to ghcr.io, deploy to Hetzner)
- [ ] 3.8 Set up PostgreSQL backup cron (daily pg_dump to Hetzner Storage Box) - [x] 3.8 Set up PostgreSQL backup cron (daily pg_dump to Hetzner Storage Box)
## 4. Planner — Session Management ## 4. Planner — Session Management