From 310b63b0cbefee2daa4190e9774bf399811cfef3 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 2 Sep 2026 16:19:27 +0100 Subject: [PATCH] build(cms): wire Payload into the Docker image and both stacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploads go to a named volume at /app/media. The directory is created in the image before the mount and covered by the existing chown, because Docker seeds a fresh named volume from the image path — a missing or root-owned directory there fails every upload with EACCES at runtime, long after the build passed. PAYLOAD_SECRET uses the same :? form as AIRFLOW_ADMIN_PASSWORD: refuse to start rather than boot with an empty secret and accept forged sessions. Staging's must differ from production's, which the header comment now says explicitly. Portainer prefixes volume names per stack, so payload_media isolates itself. prodMigrations is not wired yet — generating the initial migration needs a reachable Postgres. Follows in its own commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM --- docker-compose.portainer.staging.yml | 16 ++++++++++++++++ docker-compose.portainer.yml | 16 ++++++++++++++++ nextjs-app/Dockerfile | 7 +++++++ 3 files changed, 39 insertions(+) diff --git a/docker-compose.portainer.staging.yml b/docker-compose.portainer.staging.yml index c08ae48..99cc608 100644 --- a/docker-compose.portainer.staging.yml +++ b/docker-compose.portainer.staging.yml @@ -18,6 +18,10 @@ # TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend) # UNLEASH_URL — http://:4242/api (empty = all flags off) # UNLEASH_API_TOKEN — Unleash *client* token, environment: development +# PAYLOAD_SECRET — Payload CMS encryption secret. REQUIRED: long and +# random, and DIFFERENT from production's. Sharing +# it would let a staging session authenticate +# against production. # AIRFLOW_ADMIN_USER — Airflow admin username (default: admin) # AIRFLOW_ADMIN_PASSWORD — Airflow admin password. REQUIRED: the api-server # refuses to start without it, rather than falling @@ -89,9 +93,20 @@ services: - FASTAPI_URL=http://backend:80/api - TYPESENSE_URL=http://typesense:8108 - TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme} + # Payload CMS runs inside this container, in the `payload` schema of the + # staging database. Staging has its own stack, its own Postgres and its + # own admin account — never production's. + - DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME} + - PAYLOAD_SECRET=${PAYLOAD_SECRET:?set PAYLOAD_SECRET in the staging Portainer stack environment} + volumes: + # Portainer prefixes volume names with the stack name, so this is + # automatically isolated from production's media. + - payload_media:/app/media depends_on: backend: condition: service_healthy + sc_database: + condition: service_healthy networks: backend: {} macvlan: @@ -242,3 +257,4 @@ volumes: typesense_data: airflow_logs: unleash_cache: + payload_media: diff --git a/docker-compose.portainer.yml b/docker-compose.portainer.yml index 00e085e..fd2be50 100644 --- a/docker-compose.portainer.yml +++ b/docker-compose.portainer.yml @@ -9,6 +9,9 @@ # TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend) # UNLEASH_URL — http://:4242/api (empty = all flags off) # UNLEASH_API_TOKEN — Unleash *client* token, environment: production +# PAYLOAD_SECRET — Payload CMS encryption secret. REQUIRED: long and +# random. Changing it invalidates every admin +# session. Staging MUST use a different value. # AIRFLOW_ADMIN_USER — Airflow admin username (default: admin) # AIRFLOW_ADMIN_PASSWORD — Airflow admin password. REQUIRED: the api-server # refuses to start without it, rather than falling @@ -78,9 +81,21 @@ services: - FASTAPI_URL=http://backend:80/api - TYPESENSE_URL=http://typesense:8108 - TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme} + # Payload CMS runs inside this container. It reaches Postgres over the + # `backend` network and keeps its tables in the `payload` schema, so no + # pipeline operation on `public` can touch blog content. + - DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME} + # Same :? form as AIRFLOW_ADMIN_PASSWORD: refuse to start rather than + # boot with an empty secret and silently accept forged sessions. + - PAYLOAD_SECRET=${PAYLOAD_SECRET:?set PAYLOAD_SECRET in the Portainer stack environment} + volumes: + # Blog images. Not reproducible from the pipeline — must be backed up. + - payload_media:/app/media depends_on: backend: condition: service_healthy + sc_database: + condition: service_healthy networks: backend: {} macvlan: @@ -231,3 +246,4 @@ volumes: typesense_data: airflow_logs: unleash_cache: + payload_media: diff --git a/nextjs-app/Dockerfile b/nextjs-app/Dockerfile index 2f4665f..af446c9 100644 --- a/nextjs-app/Dockerfile +++ b/nextjs-app/Dockerfile @@ -53,6 +53,13 @@ COPY --from=builder /app/.next/static ./.next/static # a miss here is a silent 500 on /opengraph-image, not a build failure. COPY --from=builder /app/assets ./assets +# Payload writes uploads here, and the compose file mounts a named volume over +# it. The directory must exist and be owned by the runtime user BEFORE the +# mount: Docker seeds a fresh named volume from the image path, so a missing or +# root-owned directory here makes every upload fail with EACCES at runtime, +# long after the build passed. The chown below covers it. +RUN mkdir -p /app/media + # Set correct permissions RUN chown -R nextjs:nodejs /app