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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user