devops: production deployment — Caddy, staging env, and Gitea Actions CI/CD #497
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Set up the full production deployment pipeline for the Familienarchiv app on the root server. Covers two environments:
archiv.raddatz.cloud— production, deployed on git tag push (v*)staging.raddatz.cloud— staging, deployed nightly frommainThe runner already uses Docker-out-of-Docker (DooD) via the mounted socket, so CI builds go directly to the host daemon — no registry needed.
Spring Boot production profile
No
application-prod.yamlis needed. The baseapplication.yamlis already production-ready:open-in-view: false✓show-sql: false✓The
devprofile only enables Swagger and SQL logging. In production we simply don't activate it (SPRING_PROFILES_ACTIVEis not set todev).Codebase changes
1.
frontend/Dockerfile— add production stageCurrently dev-only (runs
npm run dev). Needs aproductiontarget using the Node adapter output:The dev
docker-compose.ymlis unaffected — its bind mount overrides the COPY and the CMD is already overridden.2.
docker-compose.prod.yml— new fileKey differences from the dev compose:
./data/bind mounts)target: productionbuild stage127.0.0.1only (Caddy handles external traffic)SPRING_PROFILES_ACTIVEnot set todev3.
.gitea/workflows/nightly.ymlTriggered at 02:00 every night and on
workflow_dispatch. Deploys to staging (archiv-stagingproject, ports 8081/3001).4.
.gitea/workflows/release.ymlTriggered on
v*tag push. Deploys to production (archiv-productionproject, ports 8080/3000).Gitea secrets to configure
Repo → Settings → Secrets and Variables → Actions:
PROD_POSTGRES_PASSWORDPROD_MINIO_PASSWORDPROD_OCR_TRAINING_TOKENSTAGING_POSTGRES_PASSWORDSTAGING_MINIO_PASSWORDSTAGING_OCR_TRAINING_TOKENMAIL_HOSTMAIL_PORT587MAIL_USERNAMEMAIL_PASSWORDServer one-time setup
Caddy (
/etc/caddy/Caddyfile)DNS records
Firewall
Ports 80 and 443 must be open. Port 222 for Git SSH.
Environment isolation
Docker project name (
-p) namespaces all resources automatically:archiv-productionarchiv-stagingarchiv-production_postgres-dataarchiv-staging_postgres-data8080808130003001Acceptance criteria
frontend/Dockerfilehas aproductionstage; dev compose still works unchangeddocker-compose.prod.ymlexists and starts all services with named volumesnightly.ymlworkflow deploys to staging on schedule; manually triggerablerelease.ymlworkflow deploys to production onv*tag pusharchiv.raddatz.cloudandstaging.raddatz.cloudcorrectly with TLSdocker compose up(dev) still works locally without changesEffort
M — 1 day. Most time is server provisioning and first-deploy smoke testing.
🏛️ Markus Keller — Application Architect
Observations
Standalone vs overlay — diverges from existing docs.
docs/infrastructure/production-compose.mddocuments an overlay pattern:docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d, where MinIO is disabled viaprofiles: ["dev"]in favour of Hetzner Object Storage. This issue proposes a self-containeddocker-compose.prod.ymlthat re-introduces MinIO as a production service. The two approaches are mutually exclusive and the docs need to win or be updated — not silently bypassed.MinIO in production vs Hetzner OBS — an architectural decision, not an implementation detail. The existing architecture docs made an explicit choice: Hetzner OBS for production (S3-compatible, no MinIO to operate, no data on the VPS itself). The issue reverses this without naming the reason. Both approaches are valid, but the choice has lasting consequences for backup strategy, storage costs, and operational complexity.
Architecture diagram update required. Per the doc-update table in CLAUDE.md: "New Docker service or infrastructure component →
docs/architecture/c4/l2-containers.puml+docs/DEPLOYMENT.md." If Caddy runs as a host service (not a Docker container), it should still appear in the C4 L2 diagram as an infrastructure component. The currentl2-containers.pumlreferences it only as an implicit boundary, not as a named container. This PR must update that diagram before merge.DooD (Docker-out-of-Docker) is the right call here. Building directly on the host daemon avoids a registry entirely on a single-VPS setup. The project name namespacing (
archiv-staging/archiv-production) cleanly isolates volumes, networks, and containers between environments."No
application-prod.yamlis needed" — verified correct. Checkedapplication.yaml:springdoc.api-docs.enabled: false,springdoc.swagger-ui.enabled: false,open-in-view: false,show-sql: false. Swagger and SQL logging are already off at baseline. The dev profile re-enables them. Simply not settingSPRING_PROFILES_ACTIVE=devis sufficient.docs/DEPLOYMENT.mdreferences the overlay approach in its "Dev vs production differences" table (Spring profile: prod). This will be stale after the issue is implemented.Recommendations
docs/infrastructure/production-compose.mdand the DEPLOYMENT.md table to reflect it. If the decision is Hetzner OBS, keep the overlay pattern and add theprofiles: ["dev"]gate. Don't leave the docs contradicting the code.docs/architecture/c4/l2-containers.pumlto add Caddy as an infrastructure component and show the two port paths (:3000→ frontend,:8080→ backend).docs/DEPLOYMENT.md"Dev vs production differences" table to match whichever compose strategy is chosen.Open Decisions
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
Frontend Dockerfile production stage is clean. Multi-stage build is correct:
buildstage runsnpm run build,productionstage copies the output and runsnpm ci --omit=dev. Thenode buildCMD matches the SvelteKit Node adapter's default output directory.BuildKit not explicitly enabled in CI workflows. The backend
DockerfileusesRUN --mount=type=cache,target=/root/.m2— a BuildKit-only feature. Bothnightly.ymlandrelease.ymlrundocker compose buildwithout settingDOCKER_BUILDKIT=1. On most modern Docker installations (23+), BuildKit is the default, but on the self-hosted NAS runner (currently running Docker 24.x per the existingci.yml), it may not be set. Without BuildKit, the--mount=type=cachedirective is silently ignored — builds still succeed but Maven re-downloads all dependencies on every run, adding several minutes.node:20-alpineis unpinned in the production stage. The issue adds aproductionstage usingFROM node:20-alpine. The existing dev stage also usesnode:20-alpine. In production, unpinned base images meandocker compose buildon different dates can pull different Node patch versions. This is a reproducibility risk.Runtime dependencies are correctly scoped.
npm ci --omit=devin the production stage correctly excludes dev tooling. No concern here — the SvelteKit Node adapter output (build/) is self-contained and its runtime deps are independencies, notdevDependencies.Backend Dockerfile already has a good multi-stage build (builder → JRE). Nothing new is proposed for the backend Dockerfile. The existing image is production-ready.
.env.stagingand.env.productionwritten as heredocs in CI. This works but leaves secret-containing files in the workspace on disk. If the runner reuses the workspace directory (which Gitea's self-hosted runners do by default), these files persist across workflow runs. They should be cleaned up after use.Recommendations
DOCKER_BUILDKIT=1to both workflow files as a top-levelenv:to guarantee BuildKit is active and the Maven cache mount works:node:20.19.0-alpine3.21, to match what's already tested in CI and prevent silent runtime differences.🔧 Tobias Wendt — DevOps & Platform Engineer
Observations
minio/minio:latestin the prod compose.:latestis not a version — it's a pointer that moves. Two deploys a month apart can run different MinIO versions without any record of what changed. The dev compose also uses:latest, but that's acceptable for local iteration. Production needs a pinned tag. Check the MinIO release page and pin to the current stable, e.g.minio/minio:RELEASE.2025-02-28T09-55-16Z. Add Renovate to automate future bumps.MinIO root credentials used as application S3 credentials. The prod compose sets
S3_ACCESS_KEY: archivandS3_SECRET_KEY: ${MINIO_PASSWORD}— the same account that isMINIO_ROOT_USER: archiv. The root account has full MinIO admin rights: creating and deleting buckets, managing users, resetting passwords. If the backend is compromised, an attacker has full MinIO admin access, not just read/write on the archive bucket. Create a dedicated service account:mc admin user add myminio archiv-app <strong-password>and attach a bucket-scoped policy.No post-deploy smoke test. Both workflows end at
docker compose up -d --remove-orphans. If Flyway finds a migration conflict, the backend container crash-loops silently. There is no step to verify the stack actually came up healthy. Add a health check step:No backup strategy. The prod compose defines named volumes (
postgres-data,minio-data) but the issue has no section on backup. For a family archive containing irreplaceable digitised documents, "named volume without backup = single point of failure." At minimum, a nightlypg_dumpto Hetzner S3 and a MinIOmc mirrorto a second location should be part of this milestone.OCR service memory limit missing from prod compose. The dev compose sets
mem_limit: 12gandmemswap_limit: 12gfor the OCR service (documented with a comment: "Surya OCR loads ~5GB of transformer models"). The prod compose omits this entirely. On a CX32 (8GB RAM), an unconstrained OCR service can consume all available memory and OOM-kill other services including PostgreSQL. Addmem_limit: 6gfor CX32 ormem_limit: 12gfor CX42.OCR service has no healthcheck in prod compose. Dev compose has
start_period: 120sbecause model loading takes 30–50 seconds. Without this in prod, the backend'sdepends_on: ocr-service: condition: service_healthywould need a healthcheck to be useful — but the prod compose omits the healthcheck definition entirely, so the condition silently falls back toservice_started..env.staging/.env.productionpersist on disk. Gitea's self-hosted runner reuses the workspace directory between runs. The heredoc step writes a file containingPOSTGRES_PASSWORD,MINIO_PASSWORD, and SMTP credentials. These are not cleaned up. Either pipe todocker compose --env-file /dev/stdin, or addrm -f .env.*in analways()cleanup step.No observability stack.
docs/infrastructure/production-compose.mdincludes Prometheus, Grafana, Loki, and Alertmanager. This issue deploys a production environment with no metrics, no log aggregation, and no alerting. Operations will be blind. This may be intentional for a first-deploy milestone, but it should be a named gap and a follow-up issue.Standalone vs overlay. The existing docs describe an overlay pattern. The standalone approach doubles the service definitions between dev and prod compose. If a service-level change (e.g. new env var, new volume) is made in
docker-compose.yml, the prod compose won't inherit it and will silently drift. With the overlay approach, common config lives in one place. Recommend the overlay pattern — or if standalone is intentional, extract shared sections into adocker-compose.base.yml.Recommendations
docs/DEPLOYMENT.md.if: always()after deploy.Open Decisions
docker-compose.prod.ymlvs overlay pattern. Standalone is simpler to reason about for a first deploy; overlay avoids drift between dev and prod service definitions. The cost of standalone: any new env var added to a service indocker-compose.ymlmust also be manually added todocker-compose.prod.yml. The existing docs say overlay. (Raised by: Tobias)🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
MEDIUM: Actuator not blocked at Caddy. The proposed Caddyfile uses a catch-all
handle { reverse_proxy localhost:8080 }. This routes all paths — including/actuator/*— to the backend. I checkedapplication.yaml: Spring Boot's management endpoints are not explicitly configured, which means only/actuator/healthis exposed by default in Spring Boot 3+. This is safe today, but it is one misplaced config line away from exposing/actuator/env(which dumps all environment variables includingPOSTGRES_PASSWORDandMINIO_PASSWORD),/actuator/heapdump(full JVM heap with in-memory secrets), and/actuator/beans. The existingdocs/DEPLOYMENT.mdstates: "Management port 8081 (Spring Actuator / Prometheus scrape) is internal only — the Caddy config blocks/actuator/*externally." This PR must implement that block. It is free defense:HIGH: MinIO root credentials as application credentials.
S3_ACCESS_KEY: archiv+S3_SECRET_KEY: ${MINIO_PASSWORD}is the MinIO root account. The root account can: delete all buckets and their contents, create new users, change the root password, and access the MinIO console. A backend RCE or SSRF vulnerability would give an attacker complete control of the object store. Fix: create a MinIO service account withs3:GetObject,s3:PutObject,s3:DeleteObject,s3:ListBucketpermissions onarn:aws:s3:::familienarchiv/*only.LOW: Secrets written to disk in CI. The
Write staging env/Write production envsteps write database passwords, MinIO passwords, SMTP credentials, and the OCR training token to.env.staging/.env.productionfiles on the runner's filesystem. Gitea's self-hosted runner (running on the NAS) reuses the workspace directory between runs, so these files persist. Anyone with shell access to the runner can read them. Mitigation options: (A) pipe env directly via stdin (docker compose ... --env-file /dev/stdin <<< "$VARS"), or (B) add a cleanup step withif: always()that removes the file after deploy.LOW: Shared SMTP credentials across staging and production. Both environments use
MAIL_HOST,MAIL_USERNAME, andMAIL_PASSWORDfrom the same secret set. A staging misconfiguration — or a bug in a new mail flow being tested on staging — would send real emails from the production SMTP account to real addresses. Consider using a separate staging mail account (e.g. Mailpit exposed externally, or a dedicated SMTP credential) so staging email is sandboxed.Missing security headers in Caddyfile. The proposed Caddyfile has no security headers. These are free defense:
HSTS enforces HTTPS permanently.
X-Frame-Options: DENYprevents clickjacking of the archive UI.-Serverhides the Caddy version.No rate limiting on auth endpoints. This is not introduced by this issue, but the first production deployment is the right moment to add it.
/api/auth/loginand/api/auth/forgot-passwordhave no rate limiting at the Caddy layer. Caddy'srate_limitdirective (or the community plugin) can cap these to 5 requests/minute per IP.Recommendations
/actuator/*block to both vhosts in the Caddyfile — this is a blocker before production goes live.docs/DEPLOYMENT.mdwith the setup steps, and rotate the root password to something different from the service account password.rm -f .env.*) inif: always()in both workflow files.Open Decisions
rate_limitplugin adds one install step; Spring can do it via bucket4j but requires code changes. For a first production deploy, Caddy-layer limiting is simpler. Decision: does Marcel want rate limiting now or as a follow-up? (Raised by: Nora)🧪 Sara Holt — QA Engineer
Observations
Acceptance criteria are concrete and testable. All 8 criteria have clear pass/fail conditions. Good.
No automated post-deploy verification in workflows. Both
nightly.ymlandrelease.ymlend withdocker compose up -d --remove-orphans. If the deployment fails silently — Flyway migration conflict, missing env var, backend crash-loop — the workflow exits 0. The acceptance criterion "Caddy routes correctly with TLS" requires someone to manually open a browser. Automated verification should be part of the workflow, not the acceptance criterion.No rollback procedure defined. The acceptance criteria don't include rollback. If
v1.0.0ships with a broken Flyway migration that crash-loops the backend, the recovery path is undefined. At minimum,docs/DEPLOYMENT.mdshould document: "To roll back:TAG=<previous-tag> docker compose -f docker-compose.prod.yml -p archiv-production up -d." Flyway rollbacks are harder and should also be addressed.create-bucketsservice is correctly idempotent (--ignore-existing). It will not fail on re-deploys. No concern here.Staging nightly criterion requires manual verification. "nightly.yml workflow deploys to staging on schedule" can only be verified by waiting overnight. Suggest testing it on first implementation via
workflow_dispatch(which is already in the YAML — good), and noting this in the acceptance criteria.The OCR service in the prod compose has no healthcheck, meaning
backend'sdepends_on: ocr-service: condition: service_healthywill silently downgrade toservice_started. This means the backend may receive its first OCR request before models are loaded (30–120 seconds), producing a 503 from the OCR service. This is the same issue Tobias raised from an ops angle — here it's a reliability concern.No test coverage of the deployment artifacts. The existing CI workflow runs unit and integration tests against source code. It does not verify that
docker compose buildsucceeds, or that the resulting images start and serve correctly. Consider adding a CI job that builds the production images and smoke-tests them:Recommendations
actuator/healthreturnsUPafter deploy. Fail the workflow if it doesn't.docs/DEPLOYMENT.mdbefore the first production deploy.mem_limitfor VPS tier). Otherwise theservice_healthycondition silently falls back.ci.ymlthat builds production images and starts them, so image build failures are caught on every PR rather than at deploy time.🎨 Leonie Voss — UX Design & Accessibility
No UX concerns from this issue — it's pure infrastructure. From a user perspective, this work is invisible and positive: Caddy's automatic TLS provisioning means users will always connect over HTTPS, which protects their session cookies and authentication credentials in transit. The separate staging environment also means new features can be user-tested before they reach the production archive.
One small note: once staging is live, consider whether the staging URL (
staging.raddatz.cloud) should include a visible banner or<meta name="robots" content="noindex">so family members who accidentally land on it aren't confused by staging data or half-finished features. This is a cosmetic concern for after the infrastructure is up.🗳️ Decision Queue — Action Required
3 decisions need your input before implementation starts.
Architecture
MinIO in production vs Hetzner Object Storage.
docs/infrastructure/production-compose.mdalready made this choice: Hetzner OBS for production, MinIO disabled viaprofiles: ["dev"]. This issue reverses that choice by keeping MinIO in the prod compose. Options: (A) MinIO — self-contained, all data on VPS, simpler networking, but ~500MB RAM overhead and you own the backup strategy for object storage. (B) Hetzner OBS — no MinIO to operate, built-in geo-replication, S3-compatible, ~5 EUR/month, data not on VPS. The decision determines whether the prod compose is standalone or an overlay. (Raised by: Markus)Standalone
docker-compose.prod.ymlvs overlay pattern. The standalone approach (proposed in this issue) is easier to read and reason about on first deploy. The overlay pattern (docker compose -f docker-compose.yml -f docker-compose.prod.yml) avoids drift — any new env var or service added to the dev compose is automatically present in prod. Cost of standalone: every future change to a service definition must be applied to both files manually. The existing docs say overlay; this issue says standalone. Pick one and update the docs. (Raised by: Tobias, intersects with Markus's MinIO decision — they are linked)Security
/api/auth/loginand/api/auth/forgot-passwordare unprotected against brute-force in the proposed Caddyfile. Adding Caddy-layer rate limiting requires the communityrate_limitplugin (one install step, ~10 lines of config). Option A: add it now as part of this issue, while the Caddyfile is being written. Option B: create a follow-up security issue and ship rate limiting separately. For a family archive that isn't publicly advertised, risk is low but not zero. (Raised by: Nora)🔧 Tobias Wendt — Ops Discussion Summary
Worked through all 8 open items from my review comment. All resolved.
Resolved decisions
MinIO vs Hetzner OBS — MinIO stays in production. Start with 13GB on-VPS, migrate to Hetzner OBS later. Switch is trivial: update three env vars +
mc mirror. Migration path to be documented indocs/DEPLOYMENT.md.Standalone vs overlay — standalone
docker-compose.prod.ymlis the chosen pattern. The overlay approach was designed around removing MinIO; since MinIO stays, standalone is cleaner. Updatedocs/infrastructure/production-compose.mdto retire the overlay pattern.MinIO root credentials — create a dedicated MinIO service account scoped to the
familienarchivbucket during server bootstrap. Add themc admin user add+ policy steps to the bootstrap checklist indocs/DEPLOYMENT.md. Use a separateMINIO_APP_PASSWORDsecret;MINIO_PASSWORDstays root-only.Post-deploy verification — replace
docker compose up -dwithdocker compose up -d --waitin bothnightly.ymlandrelease.yml. The--waitflag blocks until all healthchecks report healthy, making the workflow fail loudly on a bad deploy. No separate smoke-test step needed.Backup strategy — add Tailscale installation to the server one-time setup section of this issue (it's needed regardless). Backup implementation (nightly
pg_dump+ MinIO volume backup + rsync over Tailscale toheim-nas) goes in a separate follow-up issue. VPS uses Tailscale to reachheim-nas; Hetzner S3 as the eventual destination.OCR memory + healthcheck — copy the healthcheck from the dev compose verbatim (
start_period: 120s,interval: 10s,retries: 12) into the prod compose. Setmem_limit: 12gfor consistency with dev. Not a safety concern on a 64GB host, but keeps environments aligned.Observability — deliberate gap for the initial go-live. Follow-up issue #498 created with full spec: Prometheus + Loki + Grafana + Alertmanager, all internal-only, provisioned via config files.
Env file cleanup — add
rm -f .env.staging/rm -f .env.productionwithif: always()after the deploy step in both workflow files.Overall: the issue is well-scoped and the design is sound. The
--waitflag and MinIO service account are the two things I'd consider blocking before first production deploy. Everything else is hardening.🔒 Nora "NullX" Steiner — Security Discussion Summary
Worked through all open items from my review comment, plus three additional findings from a code audit of
SecurityConfig.java,UserDataInitializer.java, andapplication.yaml.Resolved decisions
Actuator block at Caddy — add
@actuatormatcher withrespond @actuator 404to both vhosts (archiv.raddatz.cloudandstaging.raddatz.cloud). Blocks/actuator/*regardless of what gets added tomanagement.endpoints.web.exposure.includein future.Security headers — add to both vhosts, with one correction (see X-Frame-Options below):
X-Frame-Optionsis intentionally excluded from Caddy — see finding below.MinIO service account policy — least-privilege:
s3:GetObject,s3:PutObject,s3:DeleteObject,s3:ListBucketonarn:aws:s3:::familienarchivandarn:aws:s3:::familienarchiv/*only. Thearn:aws:s3:::prefix works on MinIO — it's part of their S3 compatibility layer, not Amazon-specific. Document the exactmccommands in thedocs/DEPLOYMENT.mdbootstrap checklist.Staging SMTP isolation — staging points to a Mailpit container, not real SMTP. Add a
mailpitservice todocker-compose.prod.ymlwithprofiles: [staging]. The nightly workflow starts it with--profile stagingand setsMAIL_HOST=mailpit,MAIL_PORT=1025— no real SMTP secrets used in staging at all.Rate limiting —
fail2banjail on Caddy access log, watching for 401 responses on/api/auth/login. Thresholds:maxretry=10,findtime=10m,bantime=30m. Generous enough for a 60+ user who mistyped several times; stops bots instantly. Add to server one-time setup section alongside SSH hardening.Additional findings from code audit
🔴 CRITICAL — Default admin password missing from prod compose
UserDataInitializer.java:37: admin account seeded with${app.admin.password:admin123}on first startup. The default isadmin123. NeitherAPP_ADMIN_EMAILnorAPP_ADMIN_PASSWORDappear in the prod composeenvironment:block or the Gitea secrets table — meaning first production deploy creates a full-admin account with a known password.Fix:
PROD_APP_ADMIN_EMAILandPROD_APP_ADMIN_PASSWORDto the Gitea secrets table in this issueSTAGING_APP_ADMIN_EMAILandSTAGING_APP_ADMIN_PASSWORDfor stagingdocker-compose.prod.yml🟡 MEDIUM —
X-Frame-Options: DENYin Caddy conflicts with Spring SecuritySecurityConfig.java:68-70explicitly setsframeOptions.sameOrigin()— intentional, for PDF preview iframes. AddingX-Frame-Options: DENYin Caddy creates two conflicting response headers. Spring Security'sSAMEORIGINis the correct value for this app. Solution: omitX-Frame-Optionsfrom the Caddy header block entirely — Spring Security handles it correctly.🟡 MEDIUM — Missing
server.forward-headers-strategyinapplication.yamlNot present in
application.yaml. Behind Caddy, Spring Boot doesn't know it's serving HTTPS: it generates HTTP redirect URLs and won't set theSecureflag on Spring Session cookies. Add toapplication.yamlas a code change in this PR:This tells Spring Boot to trust
X-Forwarded-Proto: httpsfrom Caddy.✅ CORS — Not a concern. The SvelteKit SSR architecture makes all browser requests same-origin via Caddy. No
@CrossOriginis needed and none exists.✅ E2E profile in production — The
e2eprofile resets the admin password and creates test users on every startup. The prod compose correctly omitsSPRING_PROFILES_ACTIVE: dev,e2e. No action needed.Overall: the infrastructure design is solid. The three findings above — especially the default admin password — are blockers before any real user accesses the production instance.
✅ Implementation complete — PR #499
Branch
feat/issue-497-prod-deployshipped 9 atomic commits implementing every decision from Tobias's and Nora's review summaries.Acceptance criteria
frontend/Dockerfilehas aproductionstage; dev compose still works unchangedfeat(frontend))docker-compose.prod.ymlexists and starts all services with named volumesfeat(infra))nightly.ymlworkflow deploys to staging on schedule; manually triggerablefeat(ci))release.ymlworkflow deploys to production onv*tag pushfeat(ci))feat(infra)) atinfra/caddy/Caddyfiledocker compose up(dev) still works locally without changestarget: development, dev workflow unchangeddocs/DEPLOYMENT.md§3.3 (table actually lists 16 secrets now: the original 10 plus*_MINIO_APP_PASSWORDand*_APP_ADMIN_*per Nora's CRITICAL finding)docs/DEPLOYMENT.md§3.1–3.2Beyond the original ACs (review-driven additions)
server.forward-headers-strategy: nativeinapplication.yaml(Nora MEDIUM) — with a backing integration testarchiv-appservice-account bootstrap increate-buckets(Nora HIGH)profiles: [staging](Nora)/actuator/*404 block + security headers in Caddyfile (Nora)mem_limit: 12g(Tobias)docker compose up -d --wait(Tobias)if: always()env-file cleanup (Tobias / Nora LOW)docs/DEPLOYMENT.md§3.5docs/DEPLOYMENT.md§5npm run buildin production for the first time (/hilfe/transkription302→/login)Deferred — please file as new issues
ci.ymlthat builds +up -d --waits the prod compose on every PRpg_dump+ MinIOmc mirror+ rsync over Tailscale toheim-nasVerification
./mvnw test— 1566 tests, 0 failures (incl. newForwardHeadersConfigurationTest)docker compose configanddocker compose -f docker-compose.prod.yml config(both prod-only and--profile staging) all parse cleanlydocker build --target production frontend/builds; container smoke-tested withcurl /login→ 200caddy validateagainstcaddy:2reports Valid configurationReady for
/review-pr.