Packs a working app into Docker: images, compose for test, dev and prod, a deploy document. Use for «докер», «docker-compose», «собери образ», «инструкция по развёртыванию». The server upload is the user's step; not CI.
Resources
3Install
npx skillscat add idontlikedrinkpivo/web-dev-pipeline-codex-plugin/deploy-topology Install via the SkillsCat registry.
Codex runtime. Before using this skill, read
../executor-catalog/references/codex-runtime.md. It defines tool mappings, named-agent dispatch and stage-chat orchestration; use it for inherited Claude Code terms below.
Deploy Topology
Three environments, three compose files, one image per container. The shape that
makes this safe is always the same: what talks to what changes per
environment; what the code does never does. The application does not
know it is in test, dev, or prod beyond reading APP_ENV and refusing to
start when an environment's required config is missing or contradictory.
Scope
USE when:
- The app has real routes/handlers and its service endpoints
(/health/live,/health/ready,/metrics) — post-work, or
post-scaffold if the user only wants topology, not business code - The user asks to containerize the app, add
docker-compose, or define
test/dev/prod running modes - An existing single dev-only compose (from
repo-scaffold) needs to grow
into the three-environment shape
NOT for:
- Choosing the stack, the framework, or the database — architecture already
decided that - Kubernetes manifests, secrets-manager wiring, pushing to a registry,
deploying anywhere — images leave as a tar file withDEPLOY.mdbeside
it (Step 8); what happens to them next is the operator's flow - The CI workflow that runs these checks on every push/PR —
ci-pipeline,
once this skill's test topology and compose-contract tests exist - Writing business logic, entities, or new endpoints
- A repo with no runnable app yet — run
repo-scaffoldfirst
Inputs
| Input | Required | Source |
|---|---|---|
A working app with /health/live, /health/ready, /metrics, JSON logs on stdout and a graceful SIGTERM |
yes | repo-scaffold + work |
| The target cluster: ARM, or a rule against shells in images | when known | the user or their platform's requirements; Step 2 asks the shell question once |
| External dependencies this app calls | yes | architecture's adapters (a database, an object store, third-party APIs) |
| Which dependencies must be mockable | yes | ask if not obvious: anything the app calls over the network in a test run needs a mock/fake, or the test suite becomes an integration suite that needs live credentials |
| Containers: backend, client (SPA or SSR), workers | yes | architecture foundation §1 (containers view) and §5 (folders) |
| Upload size and request-time NFRs | for the SPA image | the SRS NFRs through foundation §1 «Решения по NFR» — they set nginx's limits (references/images.md) |
Existing compose/Dockerfile/.env.example |
when present | read and extend in place — do not fork a parallel set of files |
If the three service paths are missing, that is a repo-scaffold gap
(upgrade-code for an older project): say so and stop rather than
inventing them here — the images, probes and DEPLOY.md all point at them.
A /health that is an operation of the API contract (an operationId, an
FR) stays as it is: the service paths sit beside it, and removing a
contract operation is a plan's decision, never this stage's.
The three environments
| test | dev | prod | |
|---|---|---|---|
| Database | local, in this compose file | local, in this compose file | external, no local service |
| Object storage / queue / cache | local (e.g. SeaweedFS for S3), in this compose file | local, in this compose file | external, no local service |
| Third-party APIs (payment, mail, external HR/LLM/etc.) | mocked — fixture-backed, no network call | real — real credentials, real network calls | real |
| Port publishing | loopback or none | loopback or none | loopback only, behind a reverse-proxy/TLS terminator the user runs separately |
| Concurrency / worker count | one worker process per container, as in prod (a stack that still runs several keeps prod's count here, or its concurrency bugs show up only in prod) | same as test | one per container; the operators scale by replicas |
| Secrets | fixture/dummy values, safe to commit as defaults | real values, never committed | real values, never committed |
The asymmetry is the whole point: test and dev share infrastructure
shape (local DB/storage) and differ only in whether external APIs are real;
dev and prod share "real APIs" and differ only in whether the
DB/storage is local. A compose file that collapses this into one file with
profiles usually ends up with a flag nobody remembers to set correctly for
prod. Three files that only need to be read top-to-bottom, never diffed
against each other to know what is active, are worth the duplication.
Workflow
Before Step 1, create documentation/plans/<version>/progress-deploy.md perpipeline → references/progress-files.md — one row per Step below, 1 to 9
with 7a and 7b, ⏳ ждёт — and post its progress line
(python3 "<folder of this SKILL.md>/../pipeline/scripts/progress_link.py" documentation/plans/<version>/progress-deploy.md,
pasted as printed). Set each row 🔄 в работе → ✅ готово (or ⏭ пропущено: …) as
you go, and post the progress line after each step; the
image build in Step 8 runs for minutes, and its row says so
while it runs. The line under the table carries the compose-contract tests
and the release folder.
Step 1. Inventory before writing anything
List every external dependency the architecture's adapters name: the
database, any object storage / cache / queue, every third-party API. For
each one, decide:
- Local-in-compose for test+dev, external for prod — stateful
infrastructure the team's own stack manages (Postgres, Redis, an
S3-compatible store via SeaweedFS or similar — any S3-compatible image
works; verify it pulls). - Mocked for test, real for dev+prod — anything outside this team's
infrastructure that costs money, has side effects, or needs credentials
this repo should not hold in test (payment gateway, third-party HR/LLM
API, outbound email).
A dependency that is neither — always real, in every environment, no local
substitute possible — is rare and worth asking about explicitly rather than
guessing.
Step 2. Images
One image per container, the same image in all three environments.
Read references/images.md first: it holds the backend image, the SPA
image on nginx-unprivileged with the config copied from assets/nginx/,
and what each takes from the environment. Match it; do not add labels or
ports it does not have. No dev-only tooling baked in, no --reload/hot-reload flag in CMD (that belongs to a local
non-Docker dev loop, not the container). The process reads its worker/
concurrency count from the environment (1 by default) rather than a
hardcoded flag, so changing it later is a compose edit, not an image rebuild.
Four project-level facts shape every Dockerfile (references/images.md):
- A numeric non-root
USERends each final stage (1000 Node, 10001
Python, 101 nginx-unprivileged, 65532 shell-less): Kubernetes'runAsNonRootcan verify only a number. - Shell or not — asked once per project. Reuse the card's «Образ» row,
the existing Dockerfiles' base or the progress file's Step 2 row; none —
ask the question in Shell or no shell and write the answer into that row. - One platform:
linux/amd64, orlinux/arm64when the user says the
cluster is ARM (Architecture). - Base references checked against the registry (Checking a tag), never
remembered. The compose healthcheck asks/health/readythrough the
runtime (node -e,python -c), neverwget/curl.
CMD / ENTRYPOINT in exec form (["python", "-m", "app"]), never shell
form — a shell-form process does not receive SIGTERM and is killed after
the stop timeout. The app drains in-flight requests on SIGTERM withinSHUTDOWN_TIMEOUT_SEC and exits 0, and it logs JSON lines to
stdout/stderr, never to a file inside the container. Pin the base image to
an exact tag (a digest where the vendor publishes only moving tags, or the
project already pins digests); latest or a bare major makes two builds
of the same commit differ. Use a multi-stage build when the toolchain is
not needed at runtime.
Write or extend .dockerignore in the same step: every .env.*.local,.env, .git, local virtualenv / node_modules, test caches and the
compose files. Without it a COPY . . bakes .env.prod.local's real
secrets into the image; .env.example may stay — it holds no secrets.
Step 3. The mock/live gate lives in the app, not in compose
Whatever selects mock vs. real behavior for a third-party API must be a
config value the application itself validates — typically "this directory
of fixtures is set" or "this flag is on" — and the application refuses to
honor it unless APP_ENV=test. Compose merely sets the value per file;
the enforcement that a mock cannot leak into dev or prod belongs to the
same fail-fast config layer as Step 5, not to compose alone. Two ways to
leak a mock into prod: someone copies a compose file and forgets to blank
the mock directory, or someone sets it directly in .env.prod.local. Only
an application-level check catches both.
Step 4. Three compose files
If repo-scaffold left a single docker-compose.yml, firstgit mv docker-compose.yml docker-compose.dev.yml and extend it in place
(history follows the file), then update the compose: line indocumentation/project-map/project-map.md to name all three files. No baredocker-compose.yml remains — docker compose up without -f should
fail rather than guess an environment.
Read references/compose-shape.md for the concrete shape and the
invariants each file must hold. In outline:
docker-compose.test.yml— local DB + local object storage (+ their
one-shot init/seed containers) + a one-shotmigrateservice +backend
with the mock gate on (+frontend, the frontend image, when the
architecture has a client). Everything the test suite needs to run inside
Docker with zero external network access. Copyable — nocontainer_name, host ports from variables with defaults, volumes
without a fixedname:, the application images named
(references/compose-shape.mdsays why each matters) — withscripts/e2e-stands.sh up N/downbeside it (downremoves the
stands' containers, volumes and networks, never the images), so a long browser run
takes several identical stands (ui-test-cases→references/parallel-stands.md).docker-compose.dev.yml— same local infrastructure shape as test, mock
gate off, real third-party credentials expected from.env.dev.local.docker-compose.prod.yml— no database service, no object-storage
service, not even commented out "in case" — that reads as an
invitation next quarter; prod's database is external, not a toggle.backend,migrate(andfrontend) only, pointed at externally managed instances
through.env.prod.local. The entry port —frontend's when there is a
client, elsebackend's — publishes on loopback only
("8080:8080"binds all interfaces and ships a plaintext port to the
network); a reverse-proxy/TLS terminator in front is assumed and
documented, never built into this compose file.
App containers (backend, migrate, frontend) in the test and prod
files run hardened — read_only, tmpfs: [/tmp], cap_drop: [ALL],no-new-privileges, no user: — as a restricted Kubernetes namespace will
run them, so the operators' preflight finds nothing new. In test, migrate
and backend connect as an ordinary schema owner and a rows-only account,
never the image's superuser (docker/db-init/roles.sql) — the rights
«Для DBA» asks of their DBA (references/compose-shape.md).
migrate is a one-shot service in every file: it runs the project's
migrate command over the SQL files in documentation/db/migrations/
(carried in the backend image at that same path) to completion, backend depends on it withcondition: service_completed_successfully, and the application process
itself never runs a migration on startup. This is what keeps "which
migration is live" a deployment-time fact instead of a race between however
many app replicas start first, or a schema change under a live
connection's cached type information.
Step 5. Fail-fast config validation
The application's own config/settings layer — not compose, not a shell
script — enforces the rules that must never depend on someone remembering
to set a flag correctly. Read "The fail-fast rules" inreferences/env-and-config-contract.md while writing this validation; it
gives each rule's failure message and why it crashes rather than defaults.
- An unset
APP_ENV(or equivalent) refuses to start. - The mock gate (Step 3) refuses to activate outside
APP_ENV=test. - A prod-only guarantee that depends on network topology (secure cookies,
HSTS, TLS-only) refuses to start if the paired flag is not also set — one
flag alone is not proof the terminator is actually there, but a
contradiction (secure cookies on, TLS flag off) is proof something is
wrong. - Every secret and external credential prod needs has no default in
code or indocker-compose.prod.yml— a missing one is a startup crash
with the variable's name, not a silent fallback. - If the architecture named a resource budget that scales with worker count
(a connection pool, a rate limit), validate the arithmetic at startup
against the configured limit instead of discovering the overrun at
runtime under load.
Step 6. Env files, not secrets in compose
Before writing any env file, sort every variable into one of the four
categories in references/env-and-config-contract.md — the category
decides whether it sits inline in compose or in an env file, and the same
file fixes the DATABASE_URL schema option for every environment.
.env.exampleis documentation, never read by compose directly —
say so in a comment at its top. It lists every variable with a one-line
comment — its markers ([required],[secret],[infra]—references/env-and-config-contract.md), then what test/dev/prod expect.- Compose reads
.env.<environment>.localviaenv_file,required: true
for dev and prod (the stack must not silently start with defaults),required: falsefor test (safe fixture defaults may live inline in the
compose file's ownenvironment:block instead). - Only test fixture values (a dummy API key, a well-known local password)
may sit inline in compose YAML. Anything that would matter if leaked
lives only in a gitignored.env.<environment>.local. - Non-secret, environment-fixed values (log level, worker count, the mock
gate, security flags) belong in each compose file's ownenvironment:
block, not the env file — that way the value that decides "am I in a
safe-to-mock environment" is not something a stray.env.*.localedit
can quietly override. - Provide bootstrap targets (Makefile,
package.jsonscripts, or the
stack's task runner) that runscripts/env_files.py . build— every
environment's file from the one template, the per-environment values in
its{test: …, dev: …, prod: …}maps (references/env-and-config-contract.md),
a filled secret kept — plusup-<environment>targets that bring the stack up and
remove the exited one-shot containers so they do not linger.
Step 7. Compose-contract tests
Encode the invariants that must never silently regress as tests in the
project's own test suite, not just as prose here. One test per invariant —references/compose-shape.md → The compose-contract tests holds each one's exact keys; read it before
writing them:
- prod ports on loopback; no local database or storage in prod;
migrateunpublished and awaited bybackend;- the test file copyable;
- Dockerfiles: no hot reload, exec form, pinned
FROM, numeric non-rootUSER; - app services hardened, two database accounts in test;
- the client's nginx: no placeholder left, only
/api/proxied, the healthcheck per the shell answer; - the build script and the release folder;
deploy.mdagrees with the prod compose file and.env.example("The sync test" inreferences/deploy-doc.md);.dockerignorehides.env.*.local;backend's healthcheck and stop grace period;.env.examplein sync with the config layer;- the mock gate refused outside
APP_ENV=test.
These are cheap and exist so a future edit that reopens a closed hole (an
all-interfaces port publish, a mock flag with a default) fails CI instead
of shipping — nobody notices infra regressions locally, everybody in prod.
Step 7a. The project's verify skill
Write .agents/skills/verify/ — how to launch the test stand, what
«ready» means, a read-only doctor, how to drive it per role, where evidence
goes, how to clean up, and the stand's traps — perreferences/verify-skill.md, then prove it by running it end to end once.
Every later stage that runs the app reads it instead of rediscovering the
stand. An existing one is refreshed, not rewritten: doctor, one drive per
feature this iteration changed, the map extended, drift fixed
(references/verify-skill.md → Keeping it honest).
Step 7b. The stand's proof: service contract and resources
Needs the test stand (up-test), not the verify skill: after Step 7a on
the stand its proof brought up, before the cleanup — or alone when the
verify skill is absent or postponed (references/deploy-doc.md → The
stand's proof).
- The service contract in the container. Stop
backend, then runrepo-scaffold's check against that container:python3 "<folder of this SKILL.md>/../repo-scaffold/scripts/service_check.py" --port <APP_PORT> --expect-ready --shutdown-timeout <SHUTDOWN_TIMEOUT_SEC> -- docker start -a <backend container>
(docker start -apasses SIGTERM on and exits with the container's
code;docker compose runadds lines that are not JSON). It proves
probes, metrics, JSON logs and the SIGTERM exit under the read-only root
and the image'sUSER; then startbackendagain. - Resources. Idle, typical at a fixed request rate, peak under the
fullest load, each app container's time to ready, on an image of this
machine's architecture (or marked as emulated) — DEPLOY.md's «Ресурсы»
with the date and how, and a startup probe when ready takes over 10 s.
No Docker here: say so; the section reads «Не замерено».
Step 8. The release for hand-over
The operators get one folder per release and nothing else — every file
the plugin produces for them: the image archive, DEPLOY.md, the
variables file and the API contract openapi.yaml. They do not getdocker-compose.prod.yml — they run the images their own way, so they
need the facts it holds, written down. From upgrade-docs (MODE as-is,
a project with its own containers and no deploy document): only item 2,
from the project's own Dockerfiles and compose files as they are
(upgrade-docs/references/as-is.md).
- Check the security audit. Read the first line of
documentation/plans/<version>/security-audit.md. An open P0
(релиз заблокирован) stops the stage before any release work: name the findings and ask —
fix first (a fix plan), or build anyway, which goes into the report's
«Принятый риск» with the date. No report meanssecurity-auditdid not run:
ask before any release work (pipeline→references/question-format.md) — «Аудит безопасности в этой
итерации не проводился. A. Провести сейчас (рекомендую) B. Собирать без него — пропуск запишется в итог» — and
on B write that into the summary's «Безопасность» row. A line and going on let a release go out without the
audit, which the owner's question then found a P1 in (ai-initiatives 2.2.0). - Write
documentation/deploy/deploy.mdperreferences/deploy-doc.md:
«Карточка сервиса» first, then every section of its template — probes
with their timings, the measured resources, dependencies with the
variable, port and direction of each, «Для DBA», logs, metrics,
shutdown, pod security, tracing, the first start (ask when the
architecture and SRS do not say how the first admin or seed data
appears) — naming only the files the release folder holds. Beside it,documentation/deploy/prod.env.example, the variables file the
operators fill — built byscripts/env_files.py . buildfrom.env.example, never by hand. Facts come from the prod compose file, the Dockerfiles,.env.example, the schema and the measurement; Step 7's sync test holds
them together. - Write
scripts/build-images.shand abuild-imagestarget perreferences/images.md→ Hand-over: one tag<version>-<YYYYMMDD-HHMM>
for every image,docker buildx build --platform "$PLATFORM" --loadper
image, onedocker saveof all of them intorelease/<version>/<project>-<version>.tar(e.g.release/1.4.0/room-booking-1.4.0.tar), and beside it every other file
the operators get:DEPLOY.md;<project>.env.example, the prod
variables (references/deploy-doc.md→ The variables file — left out
only when the user says the operators keep variables in their own
store);openapi.yaml, one file (a split contract bundled); andOPERATIONS.mdwhen the operations document exists (operations-doc,
the next stage). The script ends by checking that every fileDEPLOY.md
names is in the folder, and fails naming the one that is not.release/is in.gitignoreand.dockerignore. Images leave as the
tar only — no push: the operators load them into their own registry. - Run it once and report the folder, the tar size, the image tags and the platform;
if Docker or buildx is not available here, say so instead of claiming the
build. Add the «Сборка образов» section to the README. - Scan the images with
trivyover the tar (security-audit→references/scanners.md→ Docker images, with its proxy, cache and
fallback rules), HIGH and CRITICAL only. A CRITICAL with a fixed
version in the base image stops the hand-over with one question —references/images.md→ A vulnerable base, the same insideupgrade-code; the rest go into the closing report and under «Образы»
insecurity-audit.mdwhen it exists. The build itself does not run
the scan, so an operator's rebuild does not need it.
release/, not dist/: clean scripts and bundlers empty dist/ before a
build, and the hand-over archive would go with it. A folder per version
keeps the previous release for a rollback; a rebuild replaces its own.
Then run python3 "<folder of this SKILL.md>/scripts/deploy_check.py" . and fix every line before Step 9's commit.
Step 9. Commit
Commit what this stage wrote on the iteration branch before the close: the
version tag and the push at the end of the iteration carry only what is
committed, and the next stage (operations-doc) and CI build on these
files. deploy.md is a document under documentation/, so first wait for
the site rebuild it started —python3 "<folder of this SKILL.md>/../docs-site/scripts/rebuild.py" . --wait —
and stage documentation/_web-docs with it (docs-site → Built at the first
document). One commit, build(deploy): контейнеры и комплект <version>: the
Dockerfiles and .dockerignore, the three compose files,docker/db-init/roles.sql, .env.example, the
scripts and task-runner targets, the compose-contract tests, the README
section, documentation/deploy/ and the project map's compose: line —
staged by name, never git add .. Nothing under release/ ordocumentation/plans/, and no .env.*.local. The verify skill already has
its own commit (Step 7a); an architecture fix has its own (Guardrails). Then
close: the report — the release folder, the tar size, the image tags, the
scan — and the next stage, per pipeline → "Asking before a transition".
Guardrails
- A value that departs from the architecture. A port, a variable, a
service name, an image or a command that turns out different from whatarchitecture.mdsays (the port is taken, the user picks another name)
is settled first — by the user, or here when the choice is this stage's
own — and then the architecture is fixed and committed at once, with no
version change (pipeline→references/decision-changes.md). A config
that silently departs from the document sends the next plan building
against the old one. - A one-time risky step left out of
deploy.md. If a cutover (new
external DB, new external storage, a secret rotation) has an
order-sensitive sequence where doing it wrong is destructive or leaks
data, write that sequence intodeploy.mdas its own section rather than
trusting it to be improvised correctly once, live.
Before you finish
deploy_check.py passed at the end of Step 8 — it checks the compose, image and release items
below that code can decide (hardening, healthcheck, no stateful service in prod, numeric USER, .dockerignore, the
release folder and what DEPLOY.md names); each line it prints is fixed first.
- Three compose files, each readable on its own — "The three environments".
- One Dockerfile per container, matching
references/images.md: a numeric non-rootUSER, the shell answer from
the card (asked at most once per project), every base reference checked against its registry, plus.dockerignore
— Step 2. - App containers in the test and prod files hardened (read-only root,
/tmptmpfs, no capabilities, no new
privileges),backend's healthcheck on/health/readythrough the runtime, and the test stand's two database
accounts — Step 4. - The service check passed against the
backendcontainer, and «Ресурсы» holds the stand's numbers with the date
and how, or «Не замерено» — Step 7b. documentation/deploy/deploy.mdopens with «Карточка сервиса» and covers every section ofreferences/deploy-doc.md— probes, resources, dependencies, «Для DBA», logs, metrics, shutdown, pod security,
tracing — Step 8.scripts/build-images.shran for the card's platform:release/<version>/holds the tar with every image under
the<version>-<date>tag,DEPLOY.md,<project>.env.exampleandopenapi.yaml(one file) — Step 8.- Mock gate enforced in application config — Steps 3 and 5.
- Prod file has no local stateful service;
up-testruns the suite with zero outbound network calls — Step 4. - Bootstrap and
up-<environment>targets for all three — Step 6. - A passing compose-contract test for every Step 7 invariant — Step 7.
.agents/skills/verify/written and proven end to end — Step 7a.docker-compose.test.ymlcopyable;DEPLOY.mdnames only files the release folder holds — Steps 4, 7, 8.- Everything this stage wrote is committed on the iteration branch, after the site rebuild — Step 9.
References
references/images.md— the images with and without a shell, the shell
question, tag checks, healthchecks, platform, tag and release folder (Steps 2, 8).references/deploy-doc.md— whatdeploy.mdholds, where each fact comes
from, the stand's proof, and its sync test (Steps 7, 7b and 8).assets/nginx/— the SPA image's nginx config and templates, copied as
they are with the<<…>>values filled (Step 2).references/compose-shape.md— the concrete file-by-file compose
skeleton, the hardening of app containers, the two database accounts,
and the exact invariants each service must hold (Step 4), with the
compose-contract tests key by key (Step 7).references/verify-skill.md— the project's verify skill: its sections,
the doctor's checks, the proof run, how stages use and refresh it (Step 7a).references/env-and-config-contract.md— the env-var categories, the
variables every backend has (LOG_LEVEL,SHUTDOWN_TIMEOUT_SEC), the
fail-fast validation shape, and the.env.example-sync test pattern
(Steps 5–7).repo-scaffold(skill) — the empty-skeleton stage this one follows;
read its single dev-only compose (if any) before writing three.ci-pipeline(skill) — the consumer ofdocker-compose.test.yml: it
wires that exact fixture shape into a workflow that runs on every push/PR.pipeline(skill) — where this stage sits. At the close, ask about the
stage its table names after this one, per "Asking before a transition".
Read it at the end instead of naming a next step from memory.