deploy-mvp
Run the first production deployment of this project onto the Portainer + Infisical + GitHub Container Registry + GitHub Actions stack. Use when the user asks to "deploy the MVP", "deploy to production
SKILL.md.claude/skills/deploy-mvp/SKILL.md
GitHubDeploy MVP
Take a project from "code on GitHub" to "running in production, redeploying on
every push to main" on this five-part stack:
| Concern | System |
|---|---|
| Data | PostgreSQL (existing server, one role + database per project) |
| Secrets | Infisical (one project per service, injected at container start) |
| Images | GitHub Container Registry (ghcr.io) |
| Orchestration | Portainer (standalone Docker Compose stack, deployed from the Git repo) |
| CI/CD | GitHub Actions (.github/workflows/build_*.yml) |
How secrets actually flow
Get this wrong and nothing else matters. Portainer never holds the app's
(tier-1) secrets. The stack env carries the five bootstrap values the
container needs to authenticate to Infisical and, for the backend stack only,
the Directus admin block (ADMIN_SECRET, ADMIN_PASSWORD, ADMIN_DB_*) that
compose substitutes into the directus-admin service. The app container pulls
everything else from Infisical at boot.
GitHub Actions ──build──▶ ghcr.io/<org>/<project>-api-prod:sha-abc1234
│
└──deploy──▶ Portainer ──git clone──▶ backend/docker-compose.prod.yml
│ │
│ injects 5 bootstrap vars │ compose substitution
│ (+ Directus block) ▼
└──────────────────────▶ container starts
│
docker/start-prod runs:
infisical login --method=universal-auth
infisical run --projectId --env -- /commands <api|worker>
│
▼
Infisical ──~45 app secrets──▶ app process
Read references/env-vars.md before touching any of the three tiers. The
single most common failure is putting an application secret into the Portainer
stack env, where the app will never see it.
Safety contract
This skill provisions real infrastructure. Hold to these rules:
- Never print a secret value. Print names, lengths, and fingerprints
(
sha256 | head -c 8) — never the value. This includes generated passwords: write them straight into Infisical and report only that they were set. - Confirm before every mutation in a system the user did not explicitly ask you to change. Creating a database, creating an Infisical project, and creating a Portainer stack each require an explicit go-ahead.
- Never
DROPanything without the user typing the object name back. - Idempotency first. Every script here is safe to re-run. If a step already happened, detect it and report "already present" rather than erroring or duplicating.
- Stop at the first failure. A half-provisioned deploy is worse than none. Report exactly which phase failed and what state the earlier phases left.
- Never commit credentials.
.env.deployis gitignored; verify before writing to it.
Locate the bundled files
Resolve the absolute directory containing this loaded SKILL.md. Use
<skill-dir>/scripts/... and <skill-dir>/references/... regardless of
whether this skill was loaded from .claude or .agents. Do not assume they live under the repository's top-level scripts/.
The Python scripts use only the standard library — no pip install, no curl.
Run python3 <skill-dir>/scripts/<name>.py --help (or bash <skill-dir>/scripts/github_setup.sh --help) to see options.
infisical_seed.py and portainer_stack.py use subcommands: their global
options (--env-file, --endpoint-id, --insecure) go before the
subcommand, or argparse rejects them.
Operator machine prerequisites: python3, bash, openssl, psql on
PATH (phase 1 shells out to it), gh authenticated with admin on the repository
(phases 3–5), git, and docker with Buildx only for a manual image push
(references/ghcr.md § Manual first push). docker network create runs on
the Docker host, not necessarily your machine.
Inputs the operator must supply
Collect every input before starting; ask in one batch rather than one at a
time. The authoritative list, with a comment per key, is
<skill-dir>/assets/env.deploy.template:
cp "<skill-dir>/assets/env.deploy.template" .env.deploy
git check-ignore -v .env.deploy # must print a matching .gitignore rule
The user fills .env.deploy at the repo root; read values from there — never
inline them in a shell command, where they land in history and logs. Three
local files take part, all matched by .env.* in .gitignore:
| File | Written by | Holds |
|---|---|---|
.env.deploy |
operator | inputs from the template |
.env.deploy.generated |
phases 1–2 | generated DB credentials and secrets |
.env.deploy.ci |
phase 3 (merge) | what github_setup.sh and the stack env read |
Phase 0 — Preflight
Never provision blind. Verify every system answers before mutating any of them.
python3 "<skill-dir>/scripts/preflight.py" --env-file .env.deploy
It checks, and reports a table of pass/fail:
- Postgres reachable, credentials valid, server version, whether the target role/database already exist.
- Infisical reachable, machine identity authenticates, which projects it can see.
- Portainer reachable, token valid, which environment (endpoint) IDs exist, whether a stack with the target name already exists.
- GitHub:
gh auth status, repo exists, Actions enabled, whether theProductionenvironment exists. - Repo:
docker-compose.prod.ymlpresent for each service, workflows present.
Do not proceed past a failure. Fix it or ask the user.
Record the Portainer endpointId from this output as PORTAINER_ENDPOINT_ID;
phase 4 needs it.
Phase 1 — Provision the database
One role and one database per project, plus a separate database for Directus if the backend stack includes it (it does by default in this repo).
python3 "<skill-dir>/scripts/provision_db.py" \
--env-file .env.deploy \
--name "$PROJECT_SLUG" \
--with-directus \
--dry-run
Review the emitted SQL with the user, then re-run without --dry-run.
The script generates a strong DSN-safe password, creates the role and database
idempotently, and applies the PostgreSQL 15+ schema grants that the naive
GRANT ALL PRIVILEGES ON DATABASE does not cover — without them migrations
fail with permission denied for schema public. See
references/postgres.md for the full rationale and the manual SQL if you
prefer to run it by hand.
It writes the generated credentials to .env.deploy.generated (gitignored) for
phase 2 to consume, and prints only the key names.
Verify before moving on:
python3 "<skill-dir>/scripts/provision_db.py" --env-file .env.deploy --name "$PROJECT_SLUG" --verify
Phase 2 — Seed Infisical
One Infisical project per service (<project>-backend, <project>-frontend)
so a compromised frontend identity cannot read backend secrets.
In the commands below, $PROJECT_SLUG, $BACKEND_PROJECT_ID,
$FRONTEND_PROJECT_ID, $PORTAINER_ENDPOINT_ID and the *_DEPLOYMENT_SERVICE
names stand for the non-secret values in .env.deploy; substitute them or
export just those. plan/apply require an explicit --project-id — there is
no fallback, so a frontend seed can never land in the backend project.
If
BACKEND_PROJECT_ID/FRONTEND_PROJECT_IDare blank, create the projects (go-ahead required), record the ids in.env.deploy, and grant the machine identity access to both:python3 "<skill-dir>/scripts/infisical_seed.py" --env-file .env.deploy \ create-project --name "${PROJECT_SLUG}-backend"Plan the backend from the repo's own
.env.example, so nothing the app reads is missed:python3 "<skill-dir>/scripts/infisical_seed.py" --env-file .env.deploy plan \ --template backend/.env.example \ --overrides .env.deploy.generated \ --project-id "$BACKEND_PROJECT_ID" \ --environment prodplanprints key, source (override / operator / derived / template / generated / MISSING) and status against Infisical. Never apply a plan containingMISSINGvalues without walking the user through each one; those are the settings only a human can supply (OAuth client IDs, SMTP credentials, S3 keys,REDIS_HOST/REDIS_PASSWORDandRABBITMQ_HOST/RABBITMQ_USER/RABBITMQ_PASSWORD— the production compose files ship no Valkey or RabbitMQ, and the backend refuses to start in production withoutREDIS_PASSWORDand a non-defaultRABBITMQ_PASSWORD). Put the answers in.env.deployunder the template's application block and re-plan.Apply once the plan is clean — same arguments, subcommand
apply:python3 "<skill-dir>/scripts/infisical_seed.py" --env-file .env.deploy apply \ --template backend/.env.example --overrides .env.deploy.generated \ --project-id "$BACKEND_PROJECT_ID" --environment prodGenerated values (including
ADMIN_API_KEY) are persisted to.env.deploy.generatedso a re-run does not rotate them.The frontend's
BACKEND_API_KEYmust equal the backend'sADMIN_API_KEY. The script would otherwise generate an independent value, so copy it as an override first (nothing is printed), then plan and apply the frontend:grep -q '^BACKEND_API_KEY=.' .env.deploy.generated || \ grep -E '^ADMIN_API_KEY=.' .env.deploy.generated \ | sed 's/^ADMIN_API_KEY=/BACKEND_API_KEY=/' >> .env.deploy.generated python3 "<skill-dir>/scripts/infisical_seed.py" --env-file .env.deploy plan \ --template frontend/.env.example --overrides .env.deploy.generated \ --project-id "$FRONTEND_PROJECT_ID" --environment prod # then the same command with `apply`The Directus
ADMIN_SECRET/ADMIN_PASSWORDlive in the backend stack env, not Infisical, and no script generates them. Write them without printing:for k in ADMIN_SECRET ADMIN_PASSWORD; do grep -q "^$k=." .env.deploy.generated || \ printf '%s=%s\n' "$k" "$(openssl rand -hex 32)" >> .env.deploy.generated doneEnforce the cross-service invariants listed in
references/env-vars.md(ADMIN_API_KEY==BACKEND_API_KEY, CORS origins, redirect URIs).checkreads both project ids from the env file:python3 "<skill-dir>/scripts/infisical_seed.py" --env-file .env.deploy check --environment prod
API details, auth flow, and the CLI equivalents are in
references/infisical.md.
Phase 3 — Configure the GitHub environments
Do this before the first image. .github/workflows/build_backend.yml and
build_frontend.yml have no workflow_dispatch: they run on
workflow_run after the Code Quality workflow completes successfully for a
push to main or dev. They name the image from
vars.BACKEND_REPOSITORY_URI/vars.FRONTEND_REPOSITORY_URI and read the
stack name, compose path and bootstrap values from the same environment, so a
push before this phase builds nothing useful. The environment is selected from
the triggering branch, so configure both Production and Development:
environment: ${{ github.event.workflow_run.head_branch == 'main' && 'Production' || 'Development' }}
github_setup.sh reads one env file, and the first line for a key wins
even when empty. Merge the generated values in front of the operator inputs:
(umask 077; cat .env.deploy.generated .env.deploy > .env.deploy.ci)
Run for each environment you deploy to (go-ahead required):
bash "<skill-dir>/scripts/github_setup.sh" --env-file .env.deploy.ci --environment Production --dry-run
bash "<skill-dir>/scripts/github_setup.sh" --env-file .env.deploy.ci --environment Production
bash "<skill-dir>/scripts/github_setup.sh" --env-file .env.deploy.ci --environment Development --dry-run
bash "<skill-dir>/scripts/github_setup.sh" --env-file .env.deploy.ci --environment Development
It creates the environment if absent (gh secret set --env 404s otherwise),
then sets 16 variables and 8 secrets — the exact list is in
references/env-vars.md § Tier 3. Values are piped via stdin, never passed as
arguments where they would appear in the process table. Secret values are
never printed, only their length.
Then audit:
bash "<skill-dir>/scripts/github_setup.sh" --env-file .env.deploy.ci --environment Production --audit
The audit extracts every ${{ vars.X }} and ${{ secrets.X }} from the deploy
workflows and reports any that is not configured — plus anything configured
that no workflow reads.
Why the audit matters: an unset
vars.*expands to an empty string in Actions, not an error. The deploy step then targets a stack named"", matches nothing, and either creates a stray stack or reports success having changed nothing.
Two things need a judgement call with the user rather than silent dead
configuration: the CF_ACCESS_* pair (stored, but not passed to the Portainer
step unless you add a headers: input — references/env-vars.md § Two names
that need a decision), and the deploy step's missing endpoint: input (the
action then uses the first Portainer endpoint — wrong on multi-environment
Portainer; references/portainer.md § The community GitHub Action).
Note: In this template repository the deploy steps are guarded by
if: ${{ github.repository != 'Llamitai/wise' }}, so they no-op here and run only in projects generated from the template. If you are testing in the template itself, that guard is why nothing deploys.
Phase 4 — Publish the first image and create the stacks
Prerequisite: backend/docker-compose.prod.yml attaches to an external
network named shared-network. It must already exist on the Docker host:
docker network create shared-network # run on the Docker host, once
frontend/docker-compose.prod.yml joins no shared network; see
references/env-vars.md § Frontend project for what that means for
BACKEND_API_HOST.
Each *_REPOSITORY_URI must equal the image: in the matching
docker-compose.prod.yml (without the tag), or CI pushes an image the stack
never pulls. Stacks are created from the Git repository, not an uploaded
compose file, so redeploys pull the current compose from the branch.
Path A — CI (default; proves the pipeline). Get an explicit go-ahead first:
the next push to main runs Code Quality, then both Build & Deploy
workflows build, push to GHCR and — in generated projects — create or
update the Portainer stacks named *_DEPLOYMENT_SERVICE with the env from
Phase 3. Push (an empty commit is enough, with the user's approval) and watch:
gh run list --workflow "Build & Deploy / Backend" --limit 1
gh run watch <run-id>
Path B — review the stack before CI touches it. Push a first image by hand
(references/ghcr.md § Manual first push), then build the stack env files from
.env.deploy.ci without printing them:
(
umask 077
get() { grep -E "^$1=.+" .env.deploy.ci | head -n1 | cut -d= -f2-; }
boot="INFISICAL_API_URL INFISICAL_SECRET_ENV INFISICAL_MACHINE_CLIENT_ID INFISICAL_MACHINE_CLIENT_SECRET"
admin="ADMIN_SECRET ADMIN_EMAIL ADMIN_PASSWORD ADMIN_DB_HOST ADMIN_DB_DATABASE ADMIN_DB_USER ADMIN_DB_PASSWORD ADMIN_PUBLIC_URL"
{ echo "PROJECT_ID=$(get BACKEND_PROJECT_ID)"; for k in $boot $admin; do echo "$k=$(get "$k")"; done; } > .env.deploy.stack.backend
{ echo "PROJECT_ID=$(get FRONTEND_PROJECT_ID)"; for k in $boot; do echo "$k=$(get "$k")"; done; } > .env.deploy.stack.frontend
)
Then create each stack under the same name CI will target, dry-run first:
python3 "<skill-dir>/scripts/portainer_stack.py" \
--env-file .env.deploy --endpoint-id "$PORTAINER_ENDPOINT_ID" \
create \
--name "$BACKEND_DEPLOYMENT_SERVICE" \
--repo "https://github.com/$GITHUB_REPOSITORY" \
--ref refs/heads/main \
--compose backend/docker-compose.prod.yml \
--stack-env .env.deploy.stack.backend \
--dry-run # add --private-repo for a private repository
Review the dry-run payload (secrets redacted), then apply without --dry-run.
Repeat with frontend/docker-compose.prod.yml and .env.deploy.stack.frontend.
Stack env files hold only tier-2 bootstrap values plus the backend's Directus
block (references/env-vars.md § Tier 2); anything from
backend/.env.example there is the classic mistake — move it to Infisical.
Later CI deploys replace the stack env with their env_data, so GitHub
environments stay the durable source.
After either path, confirm:
The package exists at
ghcr.io/<owner>/<project>-api-prodand is linked to the repository (via theorg.opencontainers.image.sourcelabel).The Portainer host can pull it. GHCR packages are private by default; either make the package public or add registry credentials on the Docker host.
references/ghcr.md§ Pulling a private image covers both. This is the most common silent failure: the stack deploys, then the container never starts because the host getsdenied.The containers are healthy:
python3 "<skill-dir>/scripts/portainer_stack.py" \ --env-file .env.deploy --endpoint-id "$PORTAINER_ENDPOINT_ID" \ status --name "$BACKEND_DEPLOYMENT_SERVICE"
If a container is restarting, get its logs through the same script
(... logs --name "$BACKEND_DEPLOYMENT_SERVICE" --service api --tail 200)
before changing anything. The usual causes are in § Gotchas.
Phase 5 — Verify the loop closes
The deploy is not done until an ordinary push deploys itself.
With the user's explicit go-ahead (as in Path A), push a trivial change to
main.Code Qualityhas no path filter, so every push tomainruns it and, on success, bothBuild & Deployworkflows.gh run watch—Code Quality, then build, push, and the Portainer deploy step of each service must all pass.Confirm the running image tag advanced:
python3 "<skill-dir>/scripts/portainer_stack.py" \ --env-file .env.deploy --endpoint-id "$PORTAINER_ENDPOINT_ID" \ status --name "$BACKEND_DEPLOYMENT_SERVICE"The reported image tag must equal the new commit's
sha-<short>.Hit the public URLs:
/api/py/health(liveness) and/api/py/health/ready(database and Redis), the frontend root, and one authenticated round trip through the BFF (proves theADMIN_API_KEY/BACKEND_API_KEYpair matches).
Only after step 4 report the deployment as complete.
Rollback
Read references/rollback.md before rolling back. Rolling back the image does not roll back secrets or migrations; say so explicitly when the bad deploy changed either.
Gotchas
Symptoms, their most likely cause and where to look.
| Symptom | Most likely cause | Where to look |
|---|---|---|
| Container restart-loops immediately | Infisical login failed — wrong PROJECT_ID, INFISICAL_SECRET_ENV, or machine identity lacks project access |
references/infisical.md § Troubleshooting |
denied / unauthorized pulling image |
GHCR package private, host not authenticated | references/ghcr.md § Pulling a private image |
permission denied for schema public |
PostgreSQL 15+ grants missing | references/postgres.md § The grant you actually need |
| Stack creates but no containers | shared-network missing on the host |
Phase 4 prerequisite |
| Deploy step succeeds, nothing changes | Empty vars.*DEPLOYMENT_SERVICE — targeted a stack name that does not exist |
Phase 3 audit |
Build & Deploy never starts |
Code Quality failed, or the run was not a push to main/dev (no workflow_dispatch) |
Phase 3 |
Backend exits: Missing required production secret(s): REDIS_PASSWORD |
Valkey not seeded; prod compose ships no Valkey | references/env-vars.md § Backend project |
| API or worker exits at startup with an AMQP connection/authentication error | RabbitMQ unreachable from shared-network or RABBITMQ_* wrong; prod compose ships no broker |
references/env-vars.md § Backend project |
| CI deployed to the wrong Portainer host | Deploy step has no endpoint: input — first endpoint wins |
references/portainer.md § The community GitHub Action |
| Portainer call returns HTML, or 401/403 from CI only | Behind Cloudflare Access without service-token headers | references/ghcr.md § Cloudflare Access |
| Redeploy wiped the stack's variables | env omitted from /git/redeploy — the API applies zero values |
references/portainer.md § The env-wipe trap |
| App boots but every BFF call 401s | ADMIN_API_KEY != BACKEND_API_KEY |
references/env-vars.md § Cross-service invariants |
| Google login redirect mismatch | GOOGLE_REDIRECT_URI not registered in Google Cloud |
references/env-vars.md |
| Infisical CLI errors on an API path | CLI version pinned in backend/Dockerfile (INFISICAL_CLI_VERSION) is mismatched with the self-hosted server |
references/infisical.md § Troubleshooting |
Reference index
| File | Contents |
|---|---|
references/env-vars.md |
The three variable tiers, full catalog, cross-service invariants |
references/postgres.md |
Provisioning SQL, PG15+ grants, idempotency, rollback |
references/infisical.md |
Machine identity auth, secret CRUD API, CLI, runtime injection |
references/portainer.md |
API auth, endpoints, git-stack create/redeploy, webhooks |
references/ghcr.md |
Build/push from Actions, SHA pinning, provenance/SBOM, manual first push, package visibility, private pulls, gh config |
references/rollback.md |
Rollback workflow, known-good tag and digest lookup, compose-ref and env gotchas |
assets/env.deploy.template |
Operator input template |