chore(deploy): harden Docker/compose from the deployment audit

- Dockerfile: npm ci (reproducible, lock-pinned, matches CI) instead of npm
  install; add a HEALTHCHECK against the /api/health liveness route.
- package.json: pin engines node>=22.18.0 — the server require()s .mts/.cts and
  relies on Node's default TS type-stripping (unflagged only on >=22.18), which
  was previously undocumented on a floating base image.
- .dockerignore: exclude .env / .env.* so a stray env file can't be baked into
  the image by COPY . .
- docker-compose: set TRUST_PROXY=true (behind the reverse proxy, so Secure
  cookies + client IP for rate-limit/audit are correct); flip CSRF_HTTP_ONLY to
  true (SPA reads the token from an endpoint, so no JS cookie access needed);
  remove the active default INIT_ADMIN_PASS; document TOKEN_ENCRYPTION_KEY (set
  it to move at-rest secrets off the DB-resident key); drop obsolete version key.

Not included: TOKEN_ENCRYPTION_KEY itself — that's a secret to generate and
inject into the deployment env (the app self-migrates to the env-key scheme on
next boot); documented in compose + .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 18:54:50 -05:00
parent a03216ee55
commit f3f46a73f0
4 changed files with 33 additions and 9 deletions

View File

@ -6,3 +6,7 @@ data/
*.log
.git
.gitignore
# Never bake secrets into the image (COPY . . would otherwise include a stray .env)
.env
.env.*
!.env.example

View File

@ -5,9 +5,10 @@ WORKDIR /app
# native build deps (better-sqlite3 etc)
RUN apk add --no-cache python3 make g++
# install ALL deps (vite needs dev deps)
# install ALL deps (vite needs dev deps) — npm ci for a reproducible, lock-pinned
# build that matches CI (which also uses npm ci).
COPY package*.json ./
RUN npm install
RUN npm ci
# copy full project
COPY . .
@ -44,5 +45,10 @@ ENV NODE_ENV=production \
EXPOSE 3000
VOLUME ["/data"]
# Liveness probe against the unauthenticated /api/health route (uses node, which
# is always present — no curl/wget needed).
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]

View File

@ -1,5 +1,3 @@
version: "3.8"
services:
bill-tracker:
image: dream.scheller.ltd/null/billtracker:latest
@ -9,11 +7,24 @@ services:
- "3030:3000"
environment:
INIT_ADMIN_USER: admin
INIT_ADMIN_PASS: changeme123
# CSRF Cookie httpOnly setting (default: true)
# Set CSRF_HTTP_ONLY=false to allow JavaScript access for SPA CSRF patterns
CSRF_HTTP_ONLY: "false"
# First-run admin bootstrap: set INIT_ADMIN_USER/INIT_ADMIN_PASS here ONLY
# for the very first start (with a strong password), then remove them. Do
# not ship a real deployment with a default password.
# Set the at-rest secrets key OUTSIDE the database (SimpleFIN token, OIDC
# secret, SMTP password). Without it the app stores an auto-key in the DB
# next to the ciphertext — anyone with DB/backup read access can decrypt.
# Generate: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# TOKEN_ENCRYPTION_KEY: "replace-with-a-long-random-string"
# Behind a reverse proxy (Portainer/Traefik/nginx): trust it so req.secure
# (Secure cookies) and req.ip (rate limits, audit + login history) are correct.
TRUST_PROXY: "true"
# CSRF cookie httpOnly (default: true). The SPA reads the token from
# GET /api/auth/csrf-token into memory, so it never needs JS cookie access —
# keep true to keep the token off the XSS-accessible surface.
CSRF_HTTP_ONLY: "true"
# CSRF Cookie sameSite setting (default: strict)
# Set CSRF_SAME_SITE=lax for SPA cross-site scenarios
CSRF_SAME_SITE: "strict"

View File

@ -3,6 +3,9 @@
"version": "0.40.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"engines": {
"node": ">=22.18.0"
},
"scripts": {
"dev:api": "node --watch server.js",
"dev:ui": "vite",