SOPS+age secrets, split CD workflows, GitHub OAuth for Grafana

Secrets:
- Add .sops.yaml with age encryption config
- Add encrypted secrets.app.env (app secrets) and secrets.infra.env (Grafana OAuth)
- CD decrypts at deploy time with AGE_SECRET_KEY — all other secrets
  move out of GitHub Actions into version-controlled encrypted files

Split CD:
- cd-apps.yml: triggered by apps/packages changes, builds Docker images, deploys apps
- cd-infra.yml: triggered by infrastructure/ changes, copies configs, restarts services
- Remove monolithic cd.yml

Grafana auth:
- GitHub OAuth (trails-cool org), disable login form
- Remove Caddy basic_auth block and all GRAFANA_* env vars/secrets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-27 16:56:01 +01:00
parent 206f6ac941
commit 57094323d2
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
15 changed files with 541 additions and 138 deletions

109
.github/workflows/cd-apps.yml vendored Normal file
View file

@ -0,0 +1,109 @@
name: CD Apps
on:
push:
branches: [main]
paths:
- "apps/**"
- "packages/**"
- "pnpm-lock.yaml"
workflow_dispatch: {}
concurrency:
group: deploy-apps
cancel-in-progress: true
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@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Decrypt app secrets
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env
source /tmp/secrets.env
- uses: docker/build-push-action@v7
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-args: |
SENTRY_AUTH_TOKEN=${{ env.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ github.sha }}
deploy:
name: Deploy Apps
needs: [build-images]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Decrypt secrets
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/app.env
echo "SENTRY_RELEASE=${{ github.sha }}" >> /tmp/app.env
echo "DOMAIN=trails.cool" >> /tmp/app.env
- name: Copy files to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile"
target: /opt/trails-cool
strip_components: 1
- name: Copy secrets to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "/tmp/app.env"
target: /opt/trails-cool
strip_components: 2
- 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
# Login to ghcr.io
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN app.env | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
# Pull and deploy app containers
docker compose --env-file app.env pull journal planner
docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force
docker compose --env-file app.env up -d journal planner
# Clean up
docker image prune -af
docker compose ps

66
.github/workflows/cd-brouter.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: CD BRouter
on:
push:
branches: [main]
paths:
- "docker/brouter/**"
workflow_dispatch: {}
concurrency:
group: deploy-brouter
cancel-in-progress: true
jobs:
build:
name: Build & Push BRouter Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: docker/brouter
push: true
tags: |
ghcr.io/trails-cool/brouter:latest
ghcr.io/trails-cool/brouter:${{ github.sha }}
deploy:
name: Deploy BRouter
needs: [build]
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
# Download BRouter segments if missing
if [ ! -d /opt/trails-cool/segments ] || [ -z "$(ls /opt/trails-cool/segments/*.rd5 2>/dev/null)" ]; then
mkdir -p /opt/trails-cool/segments
echo "Downloading Germany BRouter segments..."
for tile in E5_N45 E5_N50 E10_N45 E10_N50; do
[ -f "/opt/trails-cool/segments/${tile}.rd5" ] || \
wget -q "https://brouter.de/brouter/segments4/${tile}.rd5" -O "/opt/trails-cool/segments/${tile}.rd5"
done
fi
docker pull ghcr.io/trails-cool/brouter:latest
docker compose up -d brouter
docker image prune -af

81
.github/workflows/cd-infra.yml vendored Normal file
View file

@ -0,0 +1,81 @@
name: CD Infra
on:
push:
branches: [main]
paths:
- "infrastructure/**"
workflow_dispatch:
inputs:
restart_all:
description: "Restart all containers (not just infra)"
type: boolean
default: false
concurrency:
group: deploy-infra
cancel-in-progress: true
jobs:
deploy:
name: Deploy Infrastructure
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Decrypt secrets
run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
chmod +x sops-v3.9.4.linux.amd64
# Merge app + infra secrets into one .env for docker-compose
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/all.env
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.infra.env >> /tmp/all.env
echo "DOMAIN=trails.cool" >> /tmp/all.env
- name: Copy configs to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards"
target: /opt/trails-cool
strip_components: 1
- name: Copy secrets to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "/tmp/all.env"
target: /opt/trails-cool
strip_components: 2
- 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
# Merge env files: app.env (from cd-apps) + all.env (from cd-infra)
# all.env has both app + infra secrets
cp all.env .env
# Login to ghcr.io (for pulling monitoring images)
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN .env | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin 2>/dev/null || true
# Restart services
if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then
docker compose --env-file .env up -d --remove-orphans
else
# Restart infra services (except Caddy — just reload its config)
docker compose --env-file .env up -d postgres prometheus loki grafana
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
fi
docker compose ps

View file

@ -1,131 +0,0 @@
name: CD
# Triggers:
# - push: merges via merge queue or manual gh pr merge
# - workflow_dispatch: manual trigger via gh workflow run cd.yml
on:
push:
branches: [main]
workflow_dispatch: {}
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@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
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-args: |
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ 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@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: docker/brouter
push: true
tags: |
ghcr.io/trails-cool/brouter:latest
ghcr.io/trails-cool/brouter:${{ github.sha }}
deploy:
name: Deploy to Hetzner
needs: [build-images, build-brouter]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Copy files to server
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: root
key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards"
target: /opt/trails-cool
strip_components: 1
- 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
# Login to ghcr.io to pull private images
echo "${{ secrets.DEPLOY_GHCR_TOKEN }}" | docker login ghcr.io -u stigi --password-stdin
# Download BRouter segments if missing
if [ ! -d /opt/trails-cool/segments ] || [ -z "$(ls /opt/trails-cool/segments/*.rd5 2>/dev/null)" ]; then
mkdir -p /opt/trails-cool/segments
echo "Downloading Germany BRouter segments..."
for tile in E5_N45 E5_N50 E10_N45 E10_N50; do
[ -f "/opt/trails-cool/segments/${tile}.rd5" ] || \
wget -q "https://brouter.de/brouter/segments4/${tile}.rd5" -O "/opt/trails-cool/segments/${tile}.rd5"
done
fi
# Pull latest images
export SENTRY_RELEASE="${{ github.sha }}"
export SMTP_URL="${{ secrets.SMTP_URL }}"
export SMTP_FROM="${{ secrets.SMTP_FROM }}"
export GRAFANA_USER="${{ secrets.GRAFANA_USER }}"
export GRAFANA_PASSWORD="${{ secrets.GRAFANA_PASSWORD }}"
export GRAFANA_PASSWORD_HASH='${{ secrets.GRAFANA_PASSWORD_HASH }}'
docker compose pull
# Push database schema (starts postgres, waits for healthy)
docker compose run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force
# Start all services
docker compose up -d --remove-orphans
# Clean up old images to prevent disk full
docker image prune -af
# Sync Grafana admin credentials (env vars only work on first boot)
sleep 10
docker compose exec -T grafana grafana cli admin reset-admin-password "$GRAFANA_PASSWORD" || true
# Verify services are running
docker compose ps