skills/deploy-to-codemate-vps/SKILL.md
Onboard a project repository to the Codemate VPS multi-project hosting stack (vps-infra, Hetzner-hosted, shared Traefik + per-project rootless Docker). Use when the user asks to "deploy this project to the vps", "onboard on codemate-vps", "add this repo to the production VPS", "setup GHA deploy to my VPS", or when the user is clearly preparing a project (PHP/Symfony, Node, Python, Go, static) for hosting on codemate.consulting. Produces a production compose.yml, a GitHub Actions deploy workflow, and a clear out-of-repo checklist covering Ansible inventory, DNS (Gandi), GitHub secrets, and VPS .env seeding. Do NOT use for the vps-infra repo itself (which hosts the Ansible roles) — this skill is for the downstream project repos.
npx skillsauth add nicolas-codemate/claudecodeconfig deploy-to-codemate-vpsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Takes a project repo and wires it for deployment onto the single-operator Codemate VPS
(vps-infra — Hetzner Cloud, Ubuntu 24.04, shared rootful Traefik in front of per-project
rootless Docker daemons). Produces the files that live inside the project repo, and lists
the out-of-repo steps (inventory, DNS, GitHub secrets, VPS .env seeding) the operator
must perform.
Do NOT run this inside the vps-infra repo itself — that's where the roles live; the skill
targets downstream projects.
Before writing anything, confirm the runtime shape with the user:
name: that will go into vps-infra's
inventory.yml → projects: list. Must pass ^[a-z][a-z0-9_-]*$.<project>.codemate.consulting is the default; multiple are allowed).Stack detection below), confirm
with the user.pgvector/pgvector:pg17 explicitly.config/secrets/<env>/*.encrypt.public.php; Ansible Vault;
SOPS; dotenvx; doppler)? If so, .env on the VPS holds ONLY the decryption key
(or the bootstrap credentials), not the actual secrets. See references/stack-guides.md
for Symfony-Vault-specific guidance.ssh codemate-prod-01 → sudo -iu <project> (possible because the
admin user has NOPASSWD sudo). This keeps ONE key (hetzner_codemate) on the
workstation and lets the CI keypair live exclusively in GitHub Secrets.
If the operator explicitly wants direct SSH as the project user (VSCode Remote,
scp, sshfs ergonomics), ask for the VPS hostname/IP and emit an ~/.ssh/config
alias snippet. Warn that this keeps the CI private key on the workstation and
couples rotation of CI and personal access.Match the repo root by file presence, in this order (first match wins):
| File(s) | Stack |
| ------------------------------------------ | ------------------------- |
| composer.json + symfony.lock | PHP / Symfony |
| composer.json | PHP / generic |
| pyproject.toml + uv.lock | Python (uv) |
| pyproject.toml + poetry.lock | Python (Poetry) |
| package.json + pnpm-lock.yaml | Node (pnpm) |
| package.json + yarn.lock | Node (yarn) |
| package.json | Node (npm) |
| go.mod | Go |
| Dockerfile only, static assets in dist/| Static (nginx/Caddy) |
If multiple match (e.g. Node monorepo with Python subproject), ask the user which is the deployment target.
Follow these steps in order. Each produces artifacts in the project repo. Do NOT edit
vps-infra from this skill.
Most mature projects already have a Dockerfile. Use the existing one IF it:
prod/runtime target, OR
single-stage that is prod-ready)If no Dockerfile exists, generate one from the stack's conventions. Node, Python and Go templates are best assembled from scratch per-project.
Do NOT re-write an existing working Dockerfile just to match a template preference. Existing prod Dockerfiles are the source of truth.
compose.prod.ymlStart from assets/compose.prod.yml.template and substitute placeholders. The template
embeds the vps-infra conventions (loopback publish on ${APP_PORT}, .env at the
project user's home, log rotation, restart policy).
Adjustments per stack:
80, env APP_ENV=prod, optional
SERVER_NAME=:80, healthcheck php -v3000, CMD from image, healthcheck
wget -qO- http://localhost:3000/health || exit 18000, same healthcheck patternIf a DB is needed, add the DB service:
postgres:17-alpine or pgvector/pgvector:pg17 for vector workloads.
Persist ./data/db:/var/lib/postgresql/data.mysql:8 with ./data/db:/var/lib/mysql.redis:7-alpine with ./data/redis:/data (only persist if the workload
needs it — pure caches can stay ephemeral).Do NOT publish DB ports on the host — only internal compose-network access. The only
host-visible publish is the app on 127.0.0.1:${APP_PORT}:<internal_port>.
.github/workflows/deploy.ymlTwo flavors in assets/workflows/:
deploy-basic.yml — build → push → scp → pull → up -d. For stateless apps, Node apps
without migrations, Python apps with schema-less stores, static sites.deploy-php-symfony.yml — same + a post-up bin/console doctrine:migrations:migrate --no-interaction. Adapt the migration command for other stacks (Alembic, Prisma,
Drizzle, etc.) — the post-up step is the only material difference.Both flavors:
push: tags: ['v*']) plus workflow_dispatch for manual
redeploys. Plain pushes to main MUST NOT deploy — releases are explicit. The operator
ships a release by tagging (git tag v1.2.3 && git push origin v1.2.3).concurrency: group: deploy-${{ github.ref }}, cancel-in-progress: false to serializedocker/build-push-action@v6 with cache-from/to: type=gha for faster rebuildsv1.2.3), pushed alongside :latest. Deploy pins
to that tag for traceability and rollback.workflow_dispatch accepts an image_tag input that, when set, skips the build and
redeploys an existing GHCR tag — that's how rollbacks happen (re-run the workflow with
image_tag=v1.2.2).~/.project-env before every compose invocation — APP_PORT,
DOCKER_HOST, XDG_RUNTIME_DIR are defined thereIf the Dockerfile is NOT at the repo root, set file: in the build action
(e.g. file: .docker/app/Dockerfile, target: prod).
After committing the files, the operator MUST do these steps in this order. Always list
them in the final response. See references/checklist.md for the full form.
Generate the CI SSH keypair (one per project, never reuse):
ssh-keygen -t ed25519 \
-f ~/.ssh/vps-<project>-ci \
-C "<project>-ci@gha" \
-N ""
The private key is uploaded to GitHub Secrets at step 5. Unless the operator opted into a per-project SSH alias at preflight question 9, the private key can be DELETED from the workstation after step 5 — keeping CI access isolated to GitHub's encrypted secret store.
Default admin path (no per-project alias): hop through the admin user.
ssh codemate-prod-01 -t "sudo -iu <project>"
# or a zshrc helper:
# codemate() { ssh codemate-prod-01 -t "sudo -iu $1"; }
This works because the deploy admin user has NOPASSWD sudo and
sudo -iu spawns a login shell that sources the project user's .bashrc
(DOCKER_HOST, APP_PORT are ready immediately).
Only if the operator said yes at preflight #9, emit the alias snippet:
cat >> ~/.ssh/config <<EOF
Host codemate-<project>
HostName <VPS_HOST>
User <project>
IdentityFile ~/.ssh/vps-<project>-ci
IdentitiesOnly yes
PreferredAuthentications publickey
ServerAliveInterval 60
ServerAliveCountMax 3
EOF
The alias becomes functional after step 4 (Ansible apply).
Add to vps-infra/inventory.yml under projects::
projects:
- name: <project>
domains: [<project>.codemate.consulting]
ssh_public_key: "<contents of ~/.ssh/vps-<project>-ci.pub>"
Add DNS records on Gandi (A + AAAA for each domain) pointing to the VPS IP. Never
touch apex codemate.consulting, MX, or SPF.
From vps-infra: make run ARGS="--tags projects".
GitHub repo secrets/vars. Prefer the gh CLI (fewer clicks, idempotent):
gh secret set VPS_SSH_KEY --repo <owner>/<repo> < ~/.ssh/vps-<project>-ci
gh variable set VPS_HOST --repo <owner>/<repo> --body "<project>.codemate.consulting"
gh variable set PROJECT_USER --repo <owner>/<repo> --body "<project>"
Requires gh auth login (one-off, scope repo). Web UI fallback: Settings →
Secrets and variables → Actions.
Seed .env on the VPS (once, operator-owned, never tracked):
ssh <project>@<VPS_IP> 'cat > ~/.env <<EOF
# paste secrets here (DB_PASSWORD, API keys, etc.)
EOF
chmod 600 ~/.env'
Tag a release to trigger the first deploy:
git tag v0.1.0
git push origin v0.1.0
Plain pushes to main will NOT deploy — only tag pushes (or a manual
workflow_dispatch from the Actions tab) build and ship. This decouples
merging from releasing and lets main accumulate commits between releases.
After the first deploy, verify:
curl -I https://<project>.codemate.consulting/ returns an HTTP status (no connection
error)<project>-httpsssh <project>@<VPS> → source ~/.project-env && docker compose ps shows all
services healthyreferences/vps-infra-conventions.md — load when generating compose.prod.yml or the
deploy workflow; documents APP_PORT, DOCKER_HOST, .project-env, Traefik routing, log
driver expectations, volume paths.references/stack-guides.md — load when confirming stack detection or when the user
asks for stack-specific details (PHP/Symfony, Node, Python, Go, static).references/checklist.md — load before writing the final summary to the user, to
ensure no step is missed.assets/compose.prod.yml.template — base compose template with placeholders
(__PROJECT_NAME__, __IMAGE__, __INTERNAL_PORT__, DB block guards).assets/workflows/deploy-basic.yml — generic deploy workflow.assets/workflows/deploy-php-symfony.yml — deploy workflow with Doctrine migrations.Copy the chosen workflow file into .github/workflows/deploy.yml in the project repo
(leaving any existing ci.yml or other workflows untouched), and substitute placeholders.
Never overwrite existing workflows without asking.
host.docker.internal:${APP_PORT}. Project containers must serve HTTP only and must
NOT try to provision their own certificates. This is especially important for
Caddy-based images (FrankenPHP, plain Caddy): always set
CADDY_GLOBAL_OPTIONS: auto_https off in the compose env, otherwise Caddy provisions
a local CA on boot and crashes on the read-only /data/caddy directory under the
prod www-data user. Same logic applies to nginx/traefik-as-a-sidecar/etc. — never
duplicate TLS at the container layer.127.0.0.1:${APP_PORT} route through.unattended-upgrades on the VPS blacklists docker-ce*; the operator upgrades Docker
manually. Do not assume the latest Docker features are available — stick to compose
v3.x syntax that has been stable for years.hetzner_codemate (the admin
key) for CI, ever.authorized_keys file for the project user is managed with exclusive: true by
Ansible — the CI key is the only thing authorized to SSH as that user. Rotation =
edit inventory.yml, re-run Ansible.tools
--- name: deep-review description: Performs deep code review via an isolated fresh agent (triple perspective, anti-bias). Use when the user asks for an in-depth review of current branch changes, or when invoked by /resolve step 08. Do NOT use for reviewing PRs from GitHub (use review-pr skill instead) or for a quick correctness scan with effort levels (use bundled /code-review instead). argument-hint: [--ticket <id>] [--base <branch>] [--fix] [--severity <level>] allowed-tools: Read, Glob, Grep,
tools
Resolve git rebase conflicts methodically. Classifies each conflict (imports/namespace cleanup vs real logic clash), analyzes the commit introducing the change against the current ticket context, auto-fixes only trivial cases with a per-file summary, and asks the user when ambiguous. Verifies static analysis tools pass at the end and optionally runs functional tests. Use after `git rebase` triggers conflicts, or when the user asks to "resolve conflicts", "fix rebase", "j'ai des conflits", "aide-moi sur ce rebase".
development
Synchronize the markdown test plan in docs/qa/ with the current state of the codebase. Use after adding or modifying features to keep the plan up to date, or to bootstrap a test plan for the first time. Do NOT use to execute tests (use /qa-run instead) and do NOT use to design product specs (use /express-need instead).
tools
Execute the markdown test plan in docs/qa/ via Playwright MCP and create a ticket on each failing scenario. Use after /qa-sync, before a release, or to validate a feature end-to-end. Do NOT use to design or update scenarios (use /qa-sync instead) and do NOT use for visual regression (use visual-verify agent instead).