trails/.github/workflows/cd-apps.yml
Ullrich Schäfer 7e28e72c29 cd-apps: reload Caddy after deploy so Caddyfile changes take effect
cd-apps already scp's `infrastructure/Caddyfile` to the server
alongside docker-compose.yml — but the running Caddy container
doesn't auto-pick-up config changes. cd-infra is the workflow that
calls `caddy reload`, and it only triggers on `infrastructure/`
paths. So any Caddyfile edit shipped through cd-apps (e.g. the
`lb_try_duration` block in PR #329, deployed as part of an apps
push) sits on disk unapplied until the next cd-infra run.

This was real today: PR #329 added `lb_try_duration 30s` to silence
deploy-time 502s. PR #331 (a vite.config edit, apps path) merged
right after and triggered cd-apps, which copied the new Caddyfile
but didn't reload Caddy. The very next planner restart in that
deploy promptly produced three 502s — the exact thing #329 was
supposed to prevent. We applied the reload manually via SSH after
the fact.

This commit makes that reload happen on every cd-apps deploy. The
operation is idempotent (Caddy validates first, swaps live, no
downtime) so doing it even when Caddyfile is unchanged costs
nothing. `|| true` keeps the deploy from failing if Caddy is
unhealthy at deploy time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:09:43 +02:00

147 lines
5.7 KiB
YAML

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
environment: production
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 Sentry auth token
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
grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_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_RELEASE=${{ github.sha }}
secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token
deploy:
name: Deploy Apps
needs: [build-images]
runs-on: ubuntu-latest
environment: production
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 > infrastructure/app.env
echo "SENTRY_RELEASE=${{ github.sha }}" >> infrastructure/app.env
echo "DOMAIN=trails.cool" >> infrastructure/app.env
# Flagship marker — see cd-infra.yml for what this gates.
echo "IS_FLAGSHIP=true" >> infrastructure/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: "infrastructure/app.env"
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
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
# --remove-orphans cleans up containers whose service was deleted
# from the compose file, matching cd-infra's behaviour.
docker compose --env-file app.env up -d --remove-orphans journal planner
# Reload Caddy with the Caddyfile we just scp'd. cd-apps
# ships infrastructure/Caddyfile alongside docker-compose.yml
# (see scp step above), but containers don't auto-pick-up
# config changes. cd-infra reloads Caddy as part of its
# deploy; cd-apps did NOT, which meant any Caddyfile change
# touching only `apps/`/`packages/` paths sat on disk
# unapplied until the next cd-infra run. The reload is
# idempotent (Caddy validates first, swaps live, no
# downtime) so doing it on every cd-apps deploy is safe
# even when Caddyfile is unchanged. `|| true` keeps the
# deploy from failing if Caddy itself is unhealthy.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
# Clean up
docker image prune -af
docker compose ps
# Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives
# in secrets.infra.env (decrypted by cd-infra.yml into the
# merged /opt/trails-cool/.env on the server). cd-apps's
# own app.env intentionally does NOT carry it — apps don't
# need it at runtime. So we read from the merged `.env`
# that cd-infra populated. If cd-infra has never run on
# this host, .env may not exist; the `2>/dev/null` and
# the `if -n` guard make the annotation a silent no-op
# rather than a deploy failure in that case.
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env 2>/dev/null | cut -d= -f2-)
if [ -n "$GRAFANA_TOKEN" ]; then
docker compose exec -T grafana curl -sf -X POST \
-H "Authorization: Bearer $GRAFANA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text":"Deploy ${{ github.sha }}","tags":["deploy","apps"]}' \
http://localhost:3000/api/annotations || true
fi