Files
dtf-system/PORTAINER.md
Cauê Faleiros 4c9fa2436e build: pin base image digests and clear every fixable image finding
The production images built on mutable tags with --pull, so the same commit
could produce different bases, and neither Dockerfile upgraded its OS packages
even though the local ones did. The published API image carried 56 HIGH and 3
CRITICAL findings, 15 of them with an upstream fix available.

Pin both bases by digest and upgrade OS packages in the production images. That
removes all 3 CRITICAL and 13 of the 15 fixable findings. The remaining two,
msgpack and setuptools, come from a third-party SBOM; neither package is
importable or listed by pip in the built image, which I confirmed rather than
taking the previous report's word for it.

The web image could not be fixed this way: the official 1.28 line pins
nginx=1.28.3-r1 in /etc/apk/world, so apk upgrade leaves five HIGH findings in
place even though Alpine ships 1.28.3-r7. Moving to nginx:alpine (1.31.6)
clears them completely; 1.29-alpine scans worse, at 37 HIGH. Same uid 101 and
the same template entrypoint, and the local images now use the same pinned
bases so the integration suite exercises what ships. Full suite passes on
nginx 1.31.6, including the browser end-to-end.

With both images at zero CRITICAL, the image scan now blocks on CRITICAL and
reports HIGH, instead of reporting everything. PYTHON_BASE_IMAGE and
NGINX_BASE_IMAGE are wired through to the builds so a base can move forward
without editing the repository, which is what PORTAINER.md already promised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:58:32 -03:00

6.1 KiB

Portainer deployment

DTF follows the same operating model as Graphs and ComporHUB: Gitea builds prebuilt images, pushes them to the Gitea registry, and calls one Portainer webhook. Portainer owns and redeploys one Docker Swarm stack named dtf-cloud.

The production stack is deploy/stack.yaml. It contains Site, Kanban, API, worker, PostgreSQL, ClamAV, and a one-time database initializer. Production uses Cloudflare R2, so MinIO is not part of this stack.

1. Gitea configuration

The single workflow is .gitea/workflows/deploy.yml. Every push and pull request runs static validation, the integration suite against a real stack, and a secret scan. A push to main then builds the production images, publishes both latest and the full commit SHA, reports their vulnerabilities, and calls Portainer.

What actually gates a deployment:

Check Gates?
py_compile and the unit tests yes
Integration suite on a live stack (smoke, workflow, security, scanning, retention, runtime) yes
Browser suites only when the runner has Chrome; otherwise warns and continues
Trivy secret scan (HIGH/CRITICAL) yes
Source preflight (deploy/production_preflight.py --source-only) only when ENFORCE_PRODUCTION_PREFLIGHT is true
Trivy image vulnerabilities, CRITICAL yes
Trivy image vulnerabilities, HIGH no — reported after the build

The source preflight is advisory by default because it refuses a release while the payment and messaging adapters are fake, which is the deliberate state the stack runs in today. Enforcing it now would block every deployment. Set the repository variable ENFORCE_PRODUCTION_PREFLIGHT to true once real adapters land, and it becomes a hard gate.

CRITICAL image findings block. Both images carry none: the bases are pinned by digest, both Dockerfiles upgrade their OS packages, and the web image moved off the 1.28 nginx line, which pins nginx=1.28.3-r1 in /etc/apk/world and so cannot be patched in place. HIGH findings are reported rather than enforced because the remainder have no upstream fix.

PYTHON_BASE_IMAGE and NGINX_BASE_IMAGE override the digests pinned in the Dockerfiles. Update the Dockerfile default in the same change, so the repository still records what a build used.

Repository variables:

  • REGISTRY_HOST — normally gitea.blyzer.com.br.
  • REGISTRY_OWNER — normally blyzer.
  • PYTHON_BASE_IMAGE, NGINX_BASE_IMAGE, TRIVY_IMAGE — approved immutable @sha256: image references.

Repository secrets:

  • REGISTRY_USERNAME and REGISTRY_TOKEN — package write credentials.
  • PORTAINER_WEBHOOK — webhook generated by the dtf-cloud Portainer stack.

The webhook is called only after the gating checks in the table above pass. ENFORCE_PRODUCTION_PREFLIGHT and TRIVY_IMAGE are optional repository variables; without them the preflight is advisory and a pinned default scanner image is used.

2. One-time Portainer resources

Use a Docker Swarm environment. Create a dedicated production PostgreSQL volume and set its name as POSTGRES_VOLUME. Label its Swarm node dtf_database=true. Do not reuse the localhost Compose volume.

Create each application credential under Secrets. Use versioned names such as dtf_prod_database_url_v1, then place only those names in the corresponding *_SECRET stack variables. The stack expects distinct secrets for:

  • runtime and administrator database URLs;
  • database administrator and application-role passwords;
  • R2 access key and secret key;
  • operator password;
  • Mercado Pago token and webhook secret;
  • Tiny/Olist token;
  • WhatsApp token.

Credential values must never be entered into Git, portainer.env, or Gitea workflow variables.

3. Create the stack once

In Portainer select Stacks → Add stack → Git repository:

  • Name: dtf-cloud
  • Repository: this Gitea repository
  • Reference: main
  • Compose path: deploy/stack.yaml
  • Registry: the private gitea.blyzer.com.br registry
  • Re-pull image: enabled
  • Automatic update: webhook

Load a completed copy of deploy/portainer.env.example as the stack's non-secret environment variables. Do not load the example unchanged: TBD and zero digests are deliberate blockers. Keep IMAGE_TAG=latest for normal webhook deployments.

Before entering those values in Portainer, validate the completed ignored file:

set -a
. deploy/portainer.env
set +a
python3 deploy/production_preflight.py

The public reverse proxy should send the Site hostname to SITE_PORT and the Kanban hostname to KANBAN_PORT. /api/ and /portal.html are served through the Site gateway. Preserve the original Host header. Do not expose PostgreSQL, API, worker, or ClamAV. Restrict the two published web ports at the host firewall so only the reverse proxy can use them, and terminate HTTPS at the proxy.

The db-init service completing and stopping is expected. The other six services must be healthy. A failed db-init task or an unhealthy service blocks acceptance.

4. Normal deployment

Push to main. Gitea validates, tests, scans, publishes these images, and calls the webhook:

gitea.blyzer.com.br/blyzer/dtf-api:latest
gitea.blyzer.com.br/blyzer/dtf-api:<full-commit-sha>
gitea.blyzer.com.br/blyzer/dtf-web:latest
gitea.blyzer.com.br/blyzer/dtf-web:<full-commit-sha>

In Portainer, verify the new image digests, service health, db-init result, and the public /health endpoints. Back up PostgreSQL and R2 before changes that affect stored data or retention.

5. Rollback

In the Portainer stack variables, change IMAGE_TAG from latest to the full SHA of the last known-good Gitea commit and redeploy. This rolls back Site, API, Kanban, and worker code; it does not undo database migrations or restore data. After recovery and a corrected release, return IMAGE_TAG to latest.

Current blocker

This is the final deployment shape, but it is not authorized for production today. Production adapters, Docker-secret file loading, provider sandbox tests, current clean base images, approved inputs, and a production restore rehearsal are still required. Do not bypass deploy/production_preflight.py or the Gitea scan gates to make a deployment run.