Set up Docker deployment with Caddy reverse proxy #14
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?
Task 14 — Plan reference:
docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.mdAs a developer/admin, I need the app packaged as a single Docker container that persists the SQLite DB and uploads across restarts, with a Caddy reverse-proxy for TLS on the root server.
Acceptance criteria
Dockerfileuses multi-stage build:node:22-alpinebuilder →node:22-alpinerunnersharpnative bindings work in Alpine (vips-devinstalled)CMD ["node", "build"]docker-compose.ymlmounts named volumes fordb/anduploads/DATABASE_PATH=/app/db/erbstuecke.dbUPLOAD_DIR=/app/uploadsADMIN_MARCEL_PASSWORD_HASH,ADMIN_RENATE_PASSWORD_HASH,ADMIN_BERIT_PASSWORD_HASH.dockerignoreexcludesnode_modules,.svelte-kit,build,*.db,uploads/docker compose build && docker compose upruns and Gate Screen appears athttp://localhost:3000Bcrypt hash generation (run locally before deployment)
Caddy config on host (not in repo)
Files to create
Dockerfiledocker-compose.yml.dockerignoreDepends on: all previous tasks | Size: S | Spec: system-design §2
👤 Tobias Wendt — DevOps & Platform Engineer
Observations
docker-compose.ymlwithoutexposevsportsdistinction — the compose snippet in the system design doesn't clarify this. Usingports: ["3000:3000"]would expose port 3000 directly to the internet, bypassing Caddy and all its security headers.SESSION_SECRETin the description of environment variables required but omit it from the explicit env-var bullet list. If the implementer follows that list literally,SESSION_SECRETwill be missing from the compose file.sharprequiresvips-devto be installed in Alpine — correct, but the multi-stage Dockerfile must installvips-devin the builder stage only. The runner stage needsvips(the runtime shared library, not the dev headers) orlibvipsdepending on Alpine version. Missing this distinction produces a working builder but a broken runtime image.CMD ["node", "build"]but do not mentionUSER node. Running as root inside a container is a known risk — the Tobias persona's standards requireUSER nodein the runner stage..env.exampleis listed in "Files to create." Without it, the first deployer will stare at the compose file and guess at variable names.Recommendations
expose: ["3000"](notports) to the docker-compose.yml. Caddy on the host connects tolocalhost:3000. The port must never be reachable from the internet.SESSION_SECRETto the explicit environment variable list in the acceptance criteria — it is required by the app and must come from.env.sharp/vipson Alpine correctly:/healthendpoint in SvelteKit returning 200 OK..env.exampleto "Files to create" — it documents every required variable and is the only onboarding artifact a new deployer needs.restart: unless-stoppedexplicitly to the acceptance criteria (it is in the system design spec but not checked in the issue ACs).Open Decisions (omit if none)
/healthbe implemented as part of this Docker task (Task 14) or tracked as a separate issue? If it is not implemented, the health check will always fail and the container will never be marked healthy. Options: (A) implement/healthin this task, (B) disable the health check until a later task adds it. Cost of A: small extra scope. Cost of B: Caddy routes to an uninitialized container on first deploy.👤 Felix Brandt — Fullstack Developer
Observations
CMD ["node", "build"]— this is correct for@sveltejs/adapter-nodewhich outputs abuild/index.jsentry point. However, the runner stage must copy more than justbuild/: adapter-node also requires thepackage.jsonand the productionnode_modulesto be present at the same level asbuild/.sharpis a native module compiled against the Node ABI and the platform'slibvips. Rebuilding it in the runner stage vianpm ci --omit=devis necessary because the binaries compiled in the builder stage are not guaranteed to be compatible if the runner's Alpine patch version differs. The current approach of copyingpackage.jsonand runningnpm ci --omit=devin the runner is correct — but ifsharpis not independencies(only indevDependenciesby mistake),--omit=devwill exclude it and uploads will break at runtime..dockerignorelist in the ACs (node_modules, .svelte-kit, build, *.db, uploads/) is correct. However it should also excludesrc/,static/, anddocs/from the runner stage context — these are large and only needed by the builder. Alternatively, use a proper.dockerignorethat excludes everything not needed for the Docker build context.http://localhost:3000directly (bypassing any Caddy config) to isolate the container as the unit under test.DATABASE_PATHdirectory being created on first start. If the named volumedbis empty and the app tries to open/app/db/erbstuecke.db,better-sqlite3will fail if/app/db/does not exist. Thelib/db.tsimplementation must create the directory before opening the database, or the Dockerfile mustRUN mkdir -p /app/db /app/uploads.Recommendations
sharpis listed underdependencies(notdevDependencies) inpackage.json. It is a runtime dependency —npm ci --omit=devin the runner stage must include it.lib/db.ts:.dockerignoreshould be more aggressive — excludesrc/,static/,docs/,*.md,tests/from the build context. Onlypackage*.jsonis needed beforenpm ci; the full source is needed only fornpm run build. Use a layered approach in the Dockerfile (copy package files first, install, then copy source) to maximize Docker layer cache reuse on rebuilds.DATABASE_PATHdirectory creation indb.test.ts— confirm thatgetDb()with a path under a non-existent directory either creates the directory or throws a useful error, not a crypticSQLITE_CANTOPEN.Open Decisions (omit if none)
sharpdependency classification — The plan showsnpm install sharp(runtime), but if a previous task accidentally placed it indevDependencies, this task will produce a runtime crash only visible inside Docker (not in dev). Should the acceptance criteria include an explicit check thatsharpis independencies? (Raised by: Felix Brandt)👤 Nora Steiner — Application Security Engineer
Observations
.envmust be gitignored or that the repo must never contain a committed.env. Without an explicit.gitignoreentry and a.env.example, a deployer will likely create.envin the project root and accidentally commit it.SESSION_SECRETin the compose file as a required variable, but the issue's acceptance criteria bullet list omits it. If the app fails-closed on missingSESSION_SECRET(as the persona standards require), a deployment without it will crash. If it does not fail-closed, it is a security defect..dockerignorecorrectly excludes*.dbanduploads/— this prevents accidental baking of a local database or uploaded photos into the image. Good. But it should also exclude.envexplicitly, since.envis not in the project root.gitignoreby default in many SvelteKit scaffolds.ADMIN_MARCEL_PASSWORD_HASH,ADMIN_RENATE_PASSWORD_HASH, andADMIN_BERIT_PASSWORD_HASHby name. These being in environment variables is correct. The risk is that the bcrypt hash generation command (node -e "require('bcryptjs').hash('PASSWORD', 12).then(h => console.log(h))") suggests the operator substitutes their actual password in a shell command — which writes the plaintext password to shell history. The issue should recommendread -s PASSWORD && node -e ...to avoid history exposure.erbstuecke.example.comas a placeholder — appropriate, but the comment says "not in repo." The security headers block (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy) is absent from the Caddyfile shown. Without these headers, the deployed app has a weaker security posture despite TLS.USER nodein the Dockerfile) means a container escape or path traversal exploit runs as root on the host if the Docker daemon is not properly isolated. The current acceptance criteria do not requireUSER node.Recommendations
.envto.dockerignoreexplicitly:.envto.gitignoreif not already present. This is a separate file from.dockerignorebut equally critical.SESSION_SECRETexplicitly in the acceptance criteria env-var list, and require thatlib/db.tsorhooks.server.tsfails to start if it is missing:USER nodeto the acceptance criteria checklist for the Dockerfile runner stage.👤 Sara Holt — QA Engineer & Test Strategist
Observations
docker compose down && docker compose up --buildcycle. This is the most common mistake in Docker deployments — it is easy to verify manually and essential for the app's durability guarantee.docker compose build && docker compose upAC only verifies that the container starts, not that it is healthy. Without a health check endpoint, there is no programmatic way to confirm the app is actually serving requests before running the manual flow test..dockerignoreexclusions are not currently verifiable by the acceptance criteria — there is no AC that confirms a*.dbfile in the project root is not baked into the built image.Recommendations
.dockerignore:/healthendpoint is implemented, add:DATABASE_PATHset, confirm it exits with a non-zero code and a readable error message rather than crashing silently or serving garbage.Open Decisions (omit if none)
smoke-test.sh) would pay off quickly. Options: (A) keep it manual with the checklist above, (B) create ascripts/smoke-test.shthat runs the curl checks automatically. (Raised by: Sara Holt)👤 Markus Keller — Application Architect
Observations
adapter-noderequires, and (3) environment variables must be consumed exactly aslib/db.ts,lib/auth.ts, andlib/photos.tsexpect them.DATABASE_PATH=/app/db/erbstuecke.dbandUPLOAD_DIR=/app/uploads. These must match exactly whatlib/db.tsreads (process.env.DATABASE_PATH) and whatlib/photos.tsreads (process.env.UPLOAD_DIR). If either lib uses a hardcoded fallback path, the container will silently write to the wrong location and data will be lost on rebuild.SESSION_SECRET. The auth system requires it. Ifhooks.server.tsor the session layer silently falls back to a hardcoded secret, sessions will be insecure in production. If it fails-closed (as it should), the container will crash on first start without a clear error unless the error is logged.GET /uploads/[...path]/+server.tsstreams files fromUPLOAD_DIR. In Docker, this path resolves inside the container's volume mount. The path bounds check (path.resolve(UPLOAD_DIR, params.path).startsWith(path.resolve(UPLOAD_DIR) + path.sep)) must use the env var value, not a hardcoded string, or it will reject all upload requests when the env var differs from the default.db:,uploads:) survivedocker compose downanddocker compose up --build— this is the correct approach. However, the volume name prefix is determined by the Compose project name (defaults to the directory name). If the deployment directory is renamed, Docker creates new volumes and the old data appears lost. This is a common operational surprise.erbstuecke-wannsee/(outside the spec repo). The Dockerfile, docker-compose.yml, and .dockerignore must be created in that project root, not in the spec repo (wannsee-kram/).Recommendations
lib/db.tsreadsprocess.env.DATABASE_PATHwithout a fallback (or fails-closed if missing), and thatlib/photos.tsreadsprocess.env.UPLOAD_DIRwithout a fallback. Both shouldprocess.exit(1)with a clear message if their required env var is absent.SESSION_SECRETto the required env vars in the compose file and to the acceptance criteria list.docker-compose.ymlnoting that the volume names are project-name-prefixed, and that renaming the deployment directory requires migrating the volumes:+server.tsusesUPLOAD_DIRfrom the environment, not a hardcoded string. This is an architecture correctness issue, not just a security one — wrong path = 404 for all photos in production.docker compose exec app envshows all required variables with non-empty values before running the manual flow test.👤 Leonie Voss — UX Design Lead
Observations
/healthendpoint is added to support the Docker health check, it must not be a publicly navigable page that returns HTML — it should return a plain 200 with a minimal body (ok). A health route that accidentally returns the Gate Screen HTML could confuse a monitoring tool or a curious user who lands on/health.@fontsource/loraand@fontsource/interfonts served from the Node process. Fonts are bundled into the SvelteKit build output — no CDN needed. Confirm that the built image includes the font files (they are innode_modules/@fontsource/and referenced inapp.css, so they should be bundled by Vite at build time). If they are excluded or missing, the Gate Screen will fall back to Georgia/system-ui, which may not be noticed during the smoke test.Recommendations
/healthroute is implemented, implement it as a minimal JSON or text endpoint, not a full SvelteKit page:curl http://localhost:3000/succeeds, open the Gate Screen in a browser on the deployment machine and confirm Lora serif renders (the code input box and the "Erbstücke Wannsee" heading use Lora — if fonts are missing the difference is immediately visible).👤 Elicit — Requirements Engineer
Observations
SESSION_SECRET. This creates an incomplete specification — a developer implementing exactly to the AC list will produce a compose file that omits a required secret. The gap should be closed in the issue, not discovered at runtime..envfile on the server must never be committed. For a solo operator this may be obvious, but it is a gap in the operational requirements.erbstuecke.example.comas a placeholder and noted as "not in repo." This means there is no deliverable artifact for the Caddy config — it is documentation-only. This is fine for this project's scope, but it should be explicitly stated that the Caddyfile is the operator's responsibility and is not tracked in git.Recommendations
SESSION_SECRETto the acceptance criteria env-var list. The requirement is already in the system design spec §2 — it is simply missing from this issue's AC..envand.env.exampleto the operational notes. Specifically:.envis created on the server from.env.example, filled with real values, and never committed. This is an NFR-OPS requirement that is currently implicit.🗳️ Decision Queue — Action Required
3 decisions need your input before implementation starts.
Health Endpoint
/healthbe implemented as part of this Docker task (Task 14) or tracked as a separate issue? Without it, the health check in docker-compose.yml will always fail and the container will never reach "healthy" status, meaning Caddy routes traffic to a potentially uninitialized Node process. Options: (A) implement/healthas a minimal+server.tsroute within this task — small scope, one file; (B) skip the health check in docker-compose.yml for now and add it in a follow-up task. (Raised by: Tobias Wendt)Dependency Classification
sharpindependenciesvsdevDependencies—sharpis a native runtime module that must survivenpm ci --omit=devin the Docker runner stage. If it was accidentally placed indevDependenciesduring scaffolding, the production image will crash on any photo upload — a failure mode that does not appear innpm run dev. Should the acceptance criteria include an explicit check (jq '.dependencies.sharp' package.json) to verify correct placement before the Docker build is considered passing? Options: (A) add this as an explicit AC checkpoint; (B) rely on the full-flow smoke test to catch it (it will, but only after a longer debug cycle). (Raised by: Felix Brandt)Smoke Test Automation
scripts/smoke-test.shthat runs the curl checks and prints pass/fail — roughly 20 lines, reusable on every future deploy. (Raised by: Sara Holt)