Idontlikedrinkpivo

deploy-topology

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.

Idontlikedrinkpivo 0 Updated 3h ago

Resources

3
GitHub

Install

npx skillscat add idontlikedrinkpivo/web-dev-pipeline-codex-plugin/deploy-topology

Install via the SkillsCat registry.

SKILL.md

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 with DEPLOY.md beside
    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-scaffold first

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 per
pipeline → 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 USER ends each final stage (1000 Node, 10001
    Python, 101 nginx-unprivileged, 65532 shell-less): Kubernetes'
    runAsNonRoot can 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, or linux/arm64 when the user says the
    cluster is ARM (Architecture).
  • Base references checked against the registry (Checking a tag), never
    remembered. The compose healthcheck asks /health/ready through the
    runtime (node -e, python -c), never wget/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 within
SHUTDOWN_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, first
git mv docker-compose.yml docker-compose.dev.yml and extend it in place
(history follows the file), then update the compose: line in
documentation/project-map/project-map.md to name all three files. No bare
docker-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-shot migrate service + 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 — no
    container_name, host ports from variables with defaults, volumes
    without a fixed name:, the application images named
    (references/compose-shape.md says why each matters) — with
    scripts/e2e-stands.sh up N / down beside it (down removes 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 (and frontend) only, pointed at externally managed instances
    through .env.prod.local. The entry port — frontend's when there is a
    client, else backend'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 with
condition: 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" in
references/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 in docker-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.example is 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>.local via env_file, required: true
    for dev and prod (the stack must not silently start with defaults),
    required: false for test (safe fixture defaults may live inline in the
    compose file's own environment: 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 own environment:
    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.*.local edit
    can quietly override.
  • Provide bootstrap targets (Makefile, package.json scripts, or the
    stack's task runner) that run scripts/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 — plus up-<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;
  • migrate unpublished and awaited by backend;
  • the test file copyable;
  • Dockerfiles: no hot reload, exec form, pinned FROM, numeric non-root USER;
  • 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.md agrees with the prod compose file and .env.example ("The sync test" in
    references/deploy-doc.md);
  • .dockerignore hides .env.*.local;
  • backend's healthcheck and stop grace period;
  • .env.example in 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 — per
references/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).

  1. The service contract in the container. Stop backend, then run
    repo-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 -a passes SIGTERM on and exits with the container's
    code; docker compose run adds lines that are not JSON). It proves
    probes, metrics, JSON logs and the SIGTERM exit under the read-only root
    and the image's USER; then start backend again.
  2. 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 get
docker-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).

  1. 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 means security-audit did 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).
  2. Write documentation/deploy/deploy.md per references/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 by scripts/env_files.py . build from .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.
  3. Write scripts/build-images.sh and a build-images target per
    references/images.md → Hand-over: one tag <version>-<YYYYMMDD-HHMM>
    for every image, docker buildx build --platform "$PLATFORM" --load per
    image, one docker save of all of them into
    release/<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); and
    OPERATIONS.md when the operations document exists (operations-doc,
    the next stage). The script ends by checking that every file DEPLOY.md
    names is in the folder, and fails naming the one that is not.
    release/ is in .gitignore and .dockerignore. Images leave as the
    tar only — no push: the operators load them into their own registry.
  4. 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.
  5. Scan the images with trivy over 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 inside
    upgrade-code; the rest go into the closing report and under «Образы»
    in security-audit.md when 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/ or
documentation/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 what
    architecture.md says (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 into deploy.md as 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-root USER, 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, /tmp tmpfs, no capabilities, no new
    privileges), backend's healthcheck on /health/ready through the runtime, and the test stand's two database
    accounts — Step 4.
  • The service check passed against the backend container, and «Ресурсы» holds the stand's numbers with the date
    and how, or «Не замерено» — Step 7b.
  • documentation/deploy/deploy.md opens with «Карточка сервиса» and covers every section of
    references/deploy-doc.md — probes, resources, dependencies, «Для DBA», logs, metrics, shutdown, pod security,
    tracing — Step 8.
  • scripts/build-images.sh ran for the card's platform: release/<version>/ holds the tar with every image under
    the <version>-<date> tag, DEPLOY.md, <project>.env.example and openapi.yaml (one file) — Step 8.
  • Mock gate enforced in application config — Steps 3 and 5.
  • Prod file has no local stateful service; up-test runs 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.yml copyable; DEPLOY.md names 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 — what deploy.md holds, 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 of docker-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.