feat: package the application for Docker, with deployment docs
Multi-stage build on node:22-alpine, standalone output, non-root user, healthcheck on /api/health, and migrations applied by the entrypoint before the first request. A failed migration stops the container rather than serving an inconsistent database. Getting the Prisma CLI into the runtime image took three attempts and the reasoning is recorded in the Dockerfile. Copying it out of the build stage leaves its transitive dependencies behind; patching them in one at a time is a losing game. It now gets its own stage and its own tree, with the schema and prisma.config.ts beside it, and the entrypoint runs from there so every import resolves locally. The version is read from our own package.json so it cannot drift from the generated client. Two things had to change to build without a database, which a build container rightly does not have. prisma.config.ts no longer reads the URL through prisma's env() helper, which throws on a missing variable even for `generate`. And lib/db.ts creates the client on first use rather than on import: Next imports every route module while collecting page data, so a module that threw on import failed the build with an error naming whichever route was analysed first, which says nothing useful. The failure now lands on the first query, where it belongs. Verified by running the image against a real database: migrations applied, cron scheduled in Europe/Zurich, a device paired, and the panel image served as a genuine 1-bit 800x480 BMP — so satori, resvg and the vendored fonts all work on musl. The image hash came out identical to the one produced on the glibc host, which is the reproducibility the vendored fonts were for. The production overlay publishes through an existing Traefik, drops the host port, mounts the filesystem read-only, and adds a nightly dump kept for a fortnight. README and DEPLOY are in French and cover what actually bites: the panel receives nothing and only updates when it wakes; the issuer must match to the character; the captive portal URL takes no trailing slash; a rollback across a migration needs the dump, because Prisma does not undo one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
# Production overlay: the application is published by an existing Traefik
|
||||
# rather than on a host port.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build --wait
|
||||
#
|
||||
# Traefik must already be running and own the external network named by
|
||||
# TRAEFIK_NETWORK. Nothing here holds a secret: every value comes from .env,
|
||||
# which is never committed.
|
||||
#
|
||||
# Using Nginx Proxy Manager instead? Delete the labels and the `edge` network,
|
||||
# keep the published port from docker-compose.yml bound to 127.0.0.1, and point
|
||||
# a proxy host at it. The forwarded headers matter either way: the application
|
||||
# builds the image URL handed to the panel from them, so X-Forwarded-Proto and
|
||||
# X-Forwarded-Host must both reach it or the device will be sent to the wrong
|
||||
# scheme.
|
||||
|
||||
services:
|
||||
db:
|
||||
# The database is reached only over the compose network.
|
||||
ports: !override []
|
||||
|
||||
app:
|
||||
ports: !override []
|
||||
networks:
|
||||
- default
|
||||
- edge
|
||||
# The database is the only thing worth persisting; the application writes
|
||||
# nothing to its own filesystem.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.docker.network: ${TRAEFIK_NETWORK:-web}
|
||||
traefik.http.routers.horaires.rule: Host(`${APP_DOMAIN:?Set APP_DOMAIN in .env}`)
|
||||
traefik.http.routers.horaires.entrypoints: ${TRAEFIK_ENTRYPOINT:-websecure}
|
||||
traefik.http.routers.horaires.tls: "true"
|
||||
traefik.http.routers.horaires.tls.certresolver: ${TRAEFIK_CERTRESOLVER:-myresolver}
|
||||
traefik.http.routers.horaires.middlewares: horaires-hsts
|
||||
traefik.http.services.horaires.loadbalancer.server.port: "3010"
|
||||
# The admin is only ever served over TLS; say so to the browsers.
|
||||
# Note the panel is NOT a browser: if its firmware trips over the
|
||||
# certificate chain, see DEPLOY.md before reaching for DEVICE_ALLOW_HTTP.
|
||||
traefik.http.middlewares.horaires-hsts.headers.stsSeconds: "31536000"
|
||||
traefik.http.middlewares.horaires-hsts.headers.stsIncludeSubdomains: "true"
|
||||
|
||||
backup:
|
||||
# A nightly dump kept for two weeks. Small, boring, and the only thing
|
||||
# standing between a bad migration and retyping a year of opening hours.
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-horaires}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-horaires}
|
||||
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
|
||||
volumes:
|
||||
- ./backups:/backups
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
while true; do
|
||||
stamp="$$(date +%Y%m%d-%H%M%S)"
|
||||
if pg_dump -h db -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" \
|
||||
| gzip > "/backups/horaires-$$stamp.sql.gz"; then
|
||||
echo "[backup] /backups/horaires-$$stamp.sql.gz"
|
||||
else
|
||||
echo "[backup] échec du dump $$stamp" >&2
|
||||
rm -f "/backups/horaires-$$stamp.sql.gz"
|
||||
fi
|
||||
find /backups -name 'horaires-*.sql.gz' -mtime "+$$BACKUP_KEEP_DAYS" -delete
|
||||
sleep 86400
|
||||
done
|
||||
|
||||
networks:
|
||||
edge:
|
||||
external: true
|
||||
name: ${TRAEFIK_NETWORK:-web}
|
||||
Reference in New Issue
Block a user