diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..cefcdd3 --- /dev/null +++ b/.github/workflows/cd.yml @@ -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 diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile new file mode 100644 index 0000000..b36fc39 --- /dev/null +++ b/apps/journal/Dockerfile @@ -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"] diff --git a/apps/planner/Dockerfile b/apps/planner/Dockerfile new file mode 100644 index 0000000..6ee2002 --- /dev/null +++ b/apps/planner/Dockerfile @@ -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"] diff --git a/docker/brouter/Dockerfile b/docker/brouter/Dockerfile new file mode 100644 index 0000000..9f9bd5e --- /dev/null +++ b/docker/brouter/Dockerfile @@ -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: +CMD ["java", "-Xmx512M", "-Xms128M", "-Xmn32M", \ + "-DmaxRunningTime=300", \ + "-cp", "brouter.jar", \ + "btools.server.RouteServer", \ + "/data/segments", "/data/profiles", "/data/profiles", \ + "17777", "4"] diff --git a/docker/brouter/download-segments.sh b/docker/brouter/download-segments.sh new file mode 100755 index 0000000..ac2a18a --- /dev/null +++ b/docker/brouter/download-segments.sh @@ -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" diff --git a/infrastructure/.env.example b/infrastructure/.env.example new file mode 100644 index 0000000..b94d0ef --- /dev/null +++ b/infrastructure/.env.example @@ -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= diff --git a/infrastructure/Caddyfile b/infrastructure/Caddyfile new file mode 100644 index 0000000..9958540 --- /dev/null +++ b/infrastructure/Caddyfile @@ -0,0 +1,7 @@ +{$DOMAIN:trails.cool} { + reverse_proxy journal:3000 +} + +planner.{$DOMAIN:trails.cool} { + reverse_proxy planner:3001 +} diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml new file mode 100644 index 0000000..b6228af --- /dev/null +++ b/infrastructure/docker-compose.yml @@ -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: diff --git a/infrastructure/scripts/backup-postgres.sh b/infrastructure/scripts/backup-postgres.sh new file mode 100755 index 0000000..2d38030 --- /dev/null +++ b/infrastructure/scripts/backup-postgres.sh @@ -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" diff --git a/infrastructure/terraform/.gitignore b/infrastructure/terraform/.gitignore new file mode 100644 index 0000000..ecac1df --- /dev/null +++ b/infrastructure/terraform/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +*.tfstate +*.tfstate.backup +*.tfvars +.terraform.lock.hcl diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf new file mode 100644 index 0000000..6d1688f --- /dev/null +++ b/infrastructure/terraform/main.tf @@ -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 +} diff --git a/infrastructure/terraform/variables.tf b/infrastructure/terraform/variables.tf new file mode 100644 index 0000000..05ff561 --- /dev/null +++ b/infrastructure/terraform/variables.tf @@ -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 +} diff --git a/openspec/changes/phase-1-mvp/tasks.md b/openspec/changes/phase-1-mvp/tasks.md index 14bc042..0a83128 100644 --- a/openspec/changes/phase-1-mvp/tasks.md +++ b/openspec/changes/phase-1-mvp/tasks.md @@ -19,14 +19,14 @@ ## 3. Infrastructure -- [ ] 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) -- [ ] 3.3 Create BRouter Dockerfile with segment volume mount -- [ ] 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.1 Create Terraform config for Hetzner CX21 with Docker installed +- [x] 3.2 Create Docker Compose for all services (Journal, Planner, BRouter, PostgreSQL+PostGIS, Garage) +- [x] 3.3 Create BRouter Dockerfile with segment volume mount +- [x] 3.4 Create segment download script (Germany: E5_N45, E5_N50, E10_N45, E10_N50) +- [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) -- [ ] 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.7 Set up GitHub Actions CD pipeline (build Docker images, push to ghcr.io, deploy to Hetzner) +- [x] 3.8 Set up PostgreSQL backup cron (daily pg_dump to Hetzner Storage Box) ## 4. Planner — Session Management