chore: remove the unused second stack definition

deploy/stack.yaml arrived in the first commit and was never deployed. Portainer
runs the repository's docker-compose.yml. Keeping both meant two definitions
drifting apart, with the documentation naming the one nobody used, which is how
the credential question came up at all.

The hardening it offered is narrower than it looks: Docker secrets keep values
out of docker inspect and the Portainer console, but local/secrets.py loads them
into the process environment regardless, and anyone able to read docker inspect
can already read the secret files. With a single Portainer user, the benefit that
remains does not outweigh maintaining a divergent copy.

local/secrets.py stays: inert against the deployed file, and it lets a stack
switch to Docker secrets later without touching code. The preflight and its tests
degrade cleanly when no such stack is present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-21 13:48:22 -03:00
parent e95a42dbed
commit 9926d3ab55
5 changed files with 25 additions and 344 deletions

View File

@@ -4,7 +4,7 @@ The DTF application is one Portainer-owned Docker Swarm stack. Gitea builds,
tests, scans, and publishes the two application images, then calls the stack's
Portainer webhook. Start with the short operator guide in `../PORTAINER.md`.
- `stack.yaml` — the single Portainer stack.
- The deployed stack is the repository's `docker-compose.yml`, not a file here.
- `Dockerfile.api` and `Dockerfile.web` — prebuilt registry images.
- `portainer.env.example` — non-secret Portainer variables.
- `production_preflight.py` — fail-closed application/configuration validator.

View File

@@ -1,310 +0,0 @@
version: "3.8"
x-app-environment: &app-environment
APP_ENV: production
DATABASE_URL_FILE: /run/secrets/database_url
S3_ENDPOINT: ${R2_ENDPOINT:?set R2_ENDPOINT}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
S3_BUCKET: ${R2_BUCKET:?set R2_BUCKET}
AWS_ACCESS_KEY_ID_FILE: /run/secrets/r2_access_key_id
AWS_SECRET_ACCESS_KEY_FILE: /run/secrets/r2_secret_access_key
AWS_DEFAULT_REGION: auto
# The Kanban authenticates by email; the runtime reads OPERATOR_EMAIL.
OPERATOR_EMAIL: ${OPERATOR_EMAIL:?set OPERATOR_EMAIL}
OPERATOR_PASSWORD_FILE: /run/secrets/operator_password
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:?set PAYMENT_ADAPTER}
FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:?set FREIGHT_ADAPTER}
TINY_ADAPTER: ${TINY_ADAPTER:?set TINY_ADAPTER}
WHATSAPP_ADAPTER: ${WHATSAPP_ADAPTER:?set WHATSAPP_ADAPTER}
STORAGE_ADAPTER: s3-r2
PAYMENT_TOKEN_FILE: /run/secrets/payment_token
PAYMENT_WEBHOOK_SECRET_FILE: /run/secrets/payment_webhook_secret
TINY_TOKEN_FILE: /run/secrets/tiny_token
WHATSAPP_TOKEN_FILE: /run/secrets/whatsapp_token
PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN}
PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST}
ALLOWED_HOSTS: ${PUBLIC_HOST:?set PUBLIC_HOST},${KANBAN_HOST:?set KANBAN_HOST}
ALLOWED_ORIGINS: ${PUBLIC_ORIGIN:?set PUBLIC_ORIGIN},https://${KANBAN_HOST:?set KANBAN_HOST}
COOKIE_SECURE: "true"
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-5368709120}
UPLOAD_PART_BYTES: ${UPLOAD_PART_BYTES:-8388608}
STORAGE_QUOTA_BYTES: ${STORAGE_QUOTA_BYTES:?set STORAGE_QUOTA_BYTES}
OWNER_UPLOAD_QUOTA_BYTES: ${OWNER_UPLOAD_QUOTA_BYTES:?set OWNER_UPLOAD_QUOTA_BYTES}
MAX_PENDING_UPLOADS: ${MAX_PENDING_UPLOADS:-10}
SCAN_MAX_BYTES: ${SCAN_MAX_BYTES:-134217728}
x-app-secrets: &app-secrets
- database_url
- r2_access_key_id
- r2_secret_access_key
- operator_password
- payment_token
- payment_webhook_secret
- tiny_token
- whatsapp_token
x-rolling: &rolling
update_config:
parallelism: 1
delay: 10s
order: start-first
failure_action: rollback
monitor: 45s
rollback_config:
parallelism: 1
delay: 5s
order: start-first
failure_action: pause
monitor: 45s
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 5
window: 60s
services:
db:
image: ${POSTGRES_IMAGE:?set POSTGRES_IMAGE}
environment:
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER}
POSTGRES_PASSWORD_FILE: /run/secrets/db_admin_password
secrets: [db_admin_password]
volumes:
- postgres-data:/var/lib/postgresql/data
networks: [backend]
healthcheck:
test: [CMD-SHELL, 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"']
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
stop_grace_period: 60s
deploy:
replicas: 1
placement:
constraints: [node.labels.dtf_database == true]
update_config:
parallelism: 1
order: stop-first
failure_action: rollback
monitor: 60s
rollback_config:
parallelism: 1
order: stop-first
failure_action: pause
monitor: 60s
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 5
window: 120s
resources:
limits: {cpus: "2.0", memory: 4G}
reservations: {cpus: "0.5", memory: 1G}
db-init:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
command: python -m local.bootstrap
environment:
APP_ENV: production
DATABASE_ADMIN_URL_FILE: /run/secrets/database_admin_url
APP_DB_USER: ${APP_DB_USER:?set APP_DB_USER}
APP_DB_PASSWORD_FILE: /run/secrets/app_db_password
secrets: [database_admin_url, app_db_password]
networks: [backend]
deploy:
replicas: 1
restart_policy: {condition: none}
placement:
constraints: [node.platform.os == linux]
resources:
limits: {cpus: "0.5", memory: 512M}
scanner:
image: ${CLAMAV_IMAGE:?set CLAMAV_IMAGE}
user: "100:101"
entrypoint: [clamd, --foreground=true, --config-file=/etc/clamav/clamd.conf]
configs:
- source: clamd_config
target: /etc/clamav/clamd.conf
mode: 0444
networks: [backend]
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
tmpfs:
- /tmp:uid=100,gid=101,mode=0750
- /run/clamav:uid=100,gid=101,mode=0750
- /var/log/clamav:uid=100,gid=101,mode=0750
healthcheck:
test: [CMD, clamdscan, --config-file=/etc/clamav/clamd.conf, --ping, "3"]
interval: 15s
timeout: 5s
retries: 20
start_period: 90s
deploy:
replicas: 1
restart_policy: {condition: on-failure, delay: 10s}
resources:
limits: {cpus: "2.0", memory: 3G}
reservations: {cpus: "0.5", memory: 1G}
api:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
environment: *app-environment
secrets: *app-secrets
networks: [backend, egress]
read_only: true
tmpfs: [/tmp]
init: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test:
- CMD-SHELL
- >-
python -c "import os,urllib.request; r=urllib.request.Request('http://localhost:8000/health',headers={'Host':os.environ['PUBLIC_HOST']}); urllib.request.urlopen(r,timeout=3)"
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
stop_grace_period: 30s
deploy:
<<: *rolling
replicas: 2
resources:
limits: {cpus: "1.0", memory: 1G}
reservations: {cpus: "0.25", memory: 256M}
worker:
image: ${API_IMAGE:?set API_IMAGE}:${IMAGE_TAG:-latest}
command: python -m local.worker
environment:
<<: *app-environment
CLAMD_HOST: scanner
secrets: *app-secrets
networks: [backend, egress]
read_only: true
tmpfs: [/tmp]
init: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://localhost:8002/health',timeout=3)"]
interval: 15s
timeout: 5s
retries: 12
start_period: 90s
stop_grace_period: 60s
deploy:
<<: *rolling
replicas: 1
update_config:
parallelism: 1
order: stop-first
failure_action: rollback
monitor: 60s
rollback_config:
parallelism: 1
order: stop-first
failure_action: pause
monitor: 60s
resources:
limits: {cpus: "1.5", memory: 2G}
reservations: {cpus: "0.25", memory: 512M}
site:
image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest}
environment:
WEB_INDEX: index.html
PUBLIC_HOST: ${PUBLIC_HOST:?set PUBLIC_HOST}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
networks: [backend]
ports:
- target: 8080
published: ${SITE_PORT:-8080}
protocol: tcp
mode: ingress
read_only: true
tmpfs:
- /tmp:uid=101,gid=101,mode=0750
- /var/cache/nginx:uid=101,gid=101,mode=0750
- /var/run:uid=101,gid=101,mode=0750
- /etc/nginx/conf.d:uid=101,gid=101,mode=0750
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
deploy:
<<: *rolling
replicas: 2
resources:
limits: {cpus: "0.5", memory: 256M}
reservations: {cpus: "0.1", memory: 64M}
kanban:
image: ${WEB_IMAGE:?set WEB_IMAGE}:${IMAGE_TAG:-latest}
environment:
WEB_INDEX: kanban.html
PUBLIC_HOST: ${KANBAN_HOST:?set KANBAN_HOST}
S3_PUBLIC_ENDPOINT: ${R2_PUBLIC_ENDPOINT:?set R2_PUBLIC_ENDPOINT}
networks: [backend]
ports:
- target: 8080
published: ${KANBAN_PORT:-8081}
protocol: tcp
mode: ingress
read_only: true
tmpfs:
- /tmp:uid=101,gid=101,mode=0750
- /var/cache/nginx:uid=101,gid=101,mode=0750
- /var/run:uid=101,gid=101,mode=0750
- /etc/nginx/conf.d:uid=101,gid=101,mode=0750
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: [CMD-SHELL, 'wget -q --header="Host: $$PUBLIC_HOST" -O /dev/null http://127.0.0.1:8080/health']
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
deploy:
<<: *rolling
replicas: 1
resources:
limits: {cpus: "0.5", memory: 256M}
reservations: {cpus: "0.1", memory: 64M}
configs:
clamd_config:
file: ../local/clamd.conf
secrets:
database_url: {external: true, name: "${DATABASE_URL_SECRET:?set DATABASE_URL_SECRET}"}
database_admin_url: {external: true, name: "${DATABASE_ADMIN_URL_SECRET:?set DATABASE_ADMIN_URL_SECRET}"}
db_admin_password: {external: true, name: "${DB_ADMIN_PASSWORD_SECRET:?set DB_ADMIN_PASSWORD_SECRET}"}
app_db_password: {external: true, name: "${APP_DB_PASSWORD_SECRET:?set APP_DB_PASSWORD_SECRET}"}
r2_access_key_id: {external: true, name: "${R2_ACCESS_KEY_ID_SECRET:?set R2_ACCESS_KEY_ID_SECRET}"}
r2_secret_access_key: {external: true, name: "${R2_SECRET_ACCESS_KEY_SECRET:?set R2_SECRET_ACCESS_KEY_SECRET}"}
operator_password: {external: true, name: "${OPERATOR_PASSWORD_SECRET:?set OPERATOR_PASSWORD_SECRET}"}
payment_token: {external: true, name: "${PAYMENT_TOKEN_SECRET:?set PAYMENT_TOKEN_SECRET}"}
payment_webhook_secret: {external: true, name: "${PAYMENT_WEBHOOK_SECRET:?set PAYMENT_WEBHOOK_SECRET}"}
tiny_token: {external: true, name: "${TINY_TOKEN_SECRET:?set TINY_TOKEN_SECRET}"}
whatsapp_token: {external: true, name: "${WHATSAPP_TOKEN_SECRET:?set WHATSAPP_TOKEN_SECRET}"}
volumes:
postgres-data:
external: true
name: ${POSTGRES_VOLUME:?set POSTGRES_VOLUME}
networks:
backend:
driver: overlay
internal: true
egress:
driver: overlay