dorfteich/deploy/stages.md
Claude Fable 5 2f7ba65eef
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m11s
CI / Build container images (pull_request) Successful in 1m24s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Auth e2e pack (pull_request) Successful in 8m49s
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m24s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled
#218: mirror procedure into an internal registry
Airgapped sites pull from their own registry (ADR 0024). The image list
is GENERATED (deploy/scripts/list-images.sh resolves the compose file
incl. the caddy profile) so a mirror can never silently miss a service;
third-party images gain a configurable ${REGISTRY_PREFIX:-} in the
compose file (digest pins unchanged - Docker verifies the same sha256
regardless of which registry serves it), own images keep IMAGE_PREFIX;
no image reference is ever edited per site.

Step-by-step procedure in deploy/stages.md 5b: generate list, copy
digest-preservingly (docker buildx imagetools create; plain
pull/tag/push as the documented fallback - the digest comparison closes
the loop either way), verify the digest in the mirror against the pin,
point the deployment via REGISTRY_PREFIX/IMAGE_PREFIX.

Executed once end-to-end and recorded as assessor-facing evidence
(docs/vs-nfd/95-mirror-protokoll.md): all four third-party images
mirrored digest-identically into a local registry:2, plus
dorfteich-api:v0.12.0 (sha256:576f1646... identical on both sides; the
imagetools stall against the Gitea registry is recorded with its
workaround). Operations manual's airgap section now lists the mirror
part as available.

Refs #218.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 14:33:04 +02:00

10 KiB

Stage provisioning on the dedicated host ONE (one.101010.cloud)

Test and Int run as Compose stacks on the operator's dedicated Hetzner server ONE (one.101010.cloud, 168.119.32.247) — the same host that runs the Gitea instance and the CI runner; DNS for *.dorfteich.cloud already points there (deployment.md §Stages). Steps marked [root] need host root access and are executed by the repo owner; everything else can be done by CI or a deploy user.

Overview

Stage Directory Domain Ports (localhost)
Test /srv/DOCKER/dorfteich-test/ test.dorfteich.cloud web 8100, api 8101, collab 8102
Int /srv/DOCKER/dorfteich-int/ int.dorfteich.cloud web 8110, api 8111, collab 8112

1. Stage directories [root]

for stage in test int; do
  mkdir -p /srv/DOCKER/dorfteich-$stage
  mkdir -p /home/RAID/DOCKER/dorfteich-$stage   # bulk data, if RAID exists on this host
done

Copy deploy/compose/docker-compose.yml and .env.example.env into each stage directory. Set per stage in .env (mode 600):

  • POSTGRES_PASSWORD: unique random value per stage
  • COLLAB_TOKEN_SECRET: long random value per stage (openssl rand -base64 32); signs/verifies the collaboration tokens (issue #34). The api and collab services read the same value from this one variable.
  • COMPOSE_PROJECT_NAME: dorfteich-test / dorfteich-int
  • WEB_PORT/API_PORT/COLLAB_PORT: 8100/8101/8102 (test), 8110/8111/8112 (int). COLLAB_PORT must be set per stage — both stacks share this host, so the compose default (8102) would make the Int collab container collide with Test's; Int needs COLLAB_PORT=8112.
  • IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich
  • TAG: managed by the CD pipeline (<git-sha> on test, int on int)
  • APP_BASE_URL: https://test.dorfteich.cloud / https://int.dorfteich.cloud — e-mail links and the CSRF origin check depend on it
  • SMTP_HOST/SMTP_PORT/SMTP_SECURE/SMTP_USER/SMTP_PASS/SMTP_FROM: real relay credentials (both non-prod stages share one mailbox); without them, signup/reset mails queue up and fail

Fixture accounts on the stages are created with the regular seed, but with stage-specific passwords (never the public dev password). Always pass both override variables when re-seeding a stage — the seed re-hashes credentials on every run, so omitting them silently resets the stage accounts to the public dev password:

FIXTURE_ADMIN_PASSWORD=FIXTURE_USER_PASSWORD=\
  DATABASE_URL=postgresql://dorfteich:…@localhost:<tunnel-port>/dorfteich \
  pnpm --filter @dorfteich/api db:seed

(The stage db is not published; tunnel to the db container, e.g. ssh -L 15432:<db-container-ip>:5432 root@one.101010.cloud.)

2. Reverse proxy vhosts [root]

Both vhosts terminate TLS and route by path; WebSocket upgrade on /collab is required from milestone M3 on, configure it now. Caddy example:

test.dorfteich.cloud {
    handle /api/* {
        reverse_proxy 127.0.0.1:8101
    }
    handle /collab* {
        reverse_proxy 127.0.0.1:8102   # Hocuspocus collab (issue #33); Caddy passes the WebSocket upgrade through automatically
    }
    handle {
        reverse_proxy 127.0.0.1:8100
    }
}

(nginx equivalent: proxy_pass per location; for /collab add proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";.)

Int: same block with int.dorfteich.cloud and ports 8110/8111/8112.

3. Gitea act_runner [root]

The CI/CD workflows (.gitea/workflows/) need one act_runner on the host with Docker access and the ubuntu-latest label:

# 1. Download act_runner (https://gitea.com/gitea/act_runner/releases)
# 2. Registration token: Gitea → Site/Repo Settings → Actions → Runners
#    (or: docker exec -u git gitea_app gitea actions generate-runner-token)
act_runner register \
  --instance https://gitea.101010.cloud \
  --token <REGISTRATION_TOKEN> \
  --name one-dorfteich \
  --labels ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
# 3. Run as a systemd service (act_runner daemon), user in the docker group.

4. Deploy user and SSH keys

The CD workflow (issue #8) deploys via SSH: ssh deploy@one.101010.cloud 'cd /srv/DOCKER/dorfteich-test && docker compose pull && docker compose up -d'.

  • [root] Create a deploy user (or reuse an existing deployment user), member of the docker group, owning the stage directories.
  • Generate one ed25519 keypair per stage; public keys into deploy's authorized_keys (optionally with a command= restriction to the compose command), private keys become the repository secrets DEPLOY_SSH_KEY_TEST / DEPLOY_SSH_KEY_INT.

5. Registry access

The pipeline pushes images to the Gitea container registry (gitea.101010.cloud/stwaidele/dorfteich-{web,api,collab}):

  • Repository secret REGISTRY_TOKEN: a Gitea access token with write:package scope (owner stwaidele or a CI account).
  • On the host, docker login gitea.101010.cloud for the deploy user with a read:package token, so compose pull works.

5a. Third-party image digests (issue #203, ADR 0024)

Every third-party image in deploy/compose/docker-compose.yml is pinned as name:tag@sha256:… — the tag stays for readability, the digest decides what runs, so the deployed artefact is exactly the reviewed one. An early CI step fails on any third-party image: reference without a digest. Our own images are pinned per release by the deploy pipeline (TAG in the stage .env); compose.dev.yml is a local convenience and deliberately not digest-pinned.

Updating a digest (e.g. to take a rebased base image or a new tag):

  1. Resolve the new digest — this prints the manifest-list digest every platform pulls:

    docker buildx imagetools inspect <name:tag>   # → Digest: sha256:…
    
  2. Update the reference in deploy/compose/docker-compose.yml to <name:tag>@sha256:… and let CI confirm.

  3. Roll out by hand: CD does NOT sync stage composes — apply the same change to /srv/DOCKER/dorfteich-{test,int,prod}/docker-compose.yml on ONE. The next compose pull && up -d (any CD run for test/int, the next release deploy for prod) recreates the containers from the pinned digest.

  4. Verify after rollout: docker inspect --format '{{.Image}}' <container> must print the pinned digest (or check RepoDigests on the image).

5b. Mirroring into an internal registry (issue #218, ADR 0024)

Airgapped sites pull every image from their own registry. The procedure is written for the customer's operations team — nothing in it needs this project's infrastructure.

  1. Generate the image list (never hand-maintain it — a manual list silently misses a service one day). On a machine with this repository and Docker:

    TAG=v0.12.0 IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich \
      deploy/scripts/list-images.sh
    

    This resolves deploy/compose/docker-compose.yml (including the optional caddy profile) and prints every reference: our four images at the release tag plus the digest-pinned third-party images.

  2. Copy each image digest-preservingly. The copy tool is docker buildx imagetools create — it moves manifests registry-to-registry without re-encoding, so the digest in the mirror is byte-identical to the pinned one:

    docker buildx imagetools create \
      --tag registry.example.gov/mirror/postgres:17.5-alpine \
      postgres:17.5-alpine@sha256:6567bca8d7bc8c82c5922425a0baee57be8402df92bae5eacad5f01ae9544daa
    

    (One command per listed image; our own images copy the same way from the release registry. Fallback when imagetools create stalls against a registry — observed once with the Gitea registry: plain docker pull + docker tag + docker push is equally valid, because step 3's digest comparison closes the loop either way.)

  3. Verify the digest after the copy — the copy is only evidence once checked:

    docker buildx imagetools inspect registry.example.gov/mirror/postgres:17.5-alpine
    # → Digest: must equal the pinned sha256 from the compose file
    
  4. Point the deployment at the mirror — configuration only, no image reference is ever edited per site:

    • third-party images: REGISTRY_PREFIX=registry.example.gov/mirror/ in the stage .env (must end with /),
    • own images: IMAGE_PREFIX=registry.example.gov/mirror/dorfteich.

    The digest pins still apply — Docker verifies the pulled content against the same sha256 regardless of which registry serves it.

Executed end-to-end on 2026-07-31 against a local registry:2 (all four third-party images plus dorfteich-api:v0.12.0; every mirrored digest identical to the pin) — protocol: docs/vs-nfd/95-mirror-protokoll.md.

6. Verification checklist

  • https://test.dorfteich.cloud/healthzok
  • https://test.dorfteich.cloud/api/v1/readyz{"status":"ok",…}
  • https://test.dorfteich.cloud/collab/healthz{"status":"ok","service":"collab",…}
  • same for int
  • runner shows online under Gitea → Settings → Actions → Runners
  • a test workflow run executes on the runner
  • .env files are mode 600, owned by deploy

Provisioning log

  • 2026-07-05: Test/Int stage directories, .env files, Caddy vhosts (TLS live), deploy user, act_runner (v0.6.1, systemd) and registry login provisioned on the shared 4-GB VPS (188.245.116.44); Gitea Actions enabled instance-wide (app.ini on BASEL, backup kept). First pipeline run = this commit.
  • 2026-07-11: Everything moved to the dedicated host ONE (one.101010.cloud, 168.119.32.247) after Gitea itself relocated there: stage volumes (db-data, uploads) and .env files copied 1:1, compose files refreshed from the repo (now includes the plugins volume from #71), Caddy vhosts recreated (prod block prepared but commented out), act_runner one-dorfteich registered, old VPS runner and stacks stopped (kept as rollback reserve). DEPLOY_HOST in cd.yml and the DEPLOY_HOST_KEY secret updated accordingly.
  • 2026-07-11 (later): act_runner capacity raised from 1 to 4 in /home/deploy/act_runner/config.yaml — ONE has the headroom, so the independent CI jobs now run in parallel (the CD chain stays sequential via needs:).