Ship-to-VPS Skill
Take a repo that satisfies references/shippability-contract.md and put it on the user's VPS, behind Caddy, with auto-deploy on push to main. Every step is idempotent — re-running the skill on an already-shipped repo verifies and reports drift, doesn't destroy.
When to use
- After
forge has scaffolded an app
- When the user says: "ship this", "deploy to my VPS", "wire up CI/CD", "set up auto-deploy"
- When an existing app needs to be migrated from manual
docker compose up deploys to a CI flow
Hard rules
- Never break the live container. Every change is staged; the live container is only swapped after migrations succeed AND a new image is pulled. The previous image stays available as
:bootstrap (or as the previous SHA tag) for one-command rollback.
- Never write a secret to a file outside
/opt/<slug>/.env on the VPS. Not to GH Secrets (except SSH/Infisical bootstrap), not to the repo, not to shell history.
- Never push to a GHCR package without the OCI source label. Manual unlinked pushes break the deploy job's
GITHUB_TOKEN auth path.
- Never assume the user's VPS has port X free. Probe before binding.
- Never co-tenant another app's Docker network or volume. Each app gets
/opt/<slug>/ + its own Docker network + its own postgres volume (if needed).
- Never commit
.env*. Already gitignored across user repos — verify, don't re-add.
- If the repo fails
references/shippability-contract.md, do NOT proceed. Either fix the violations (with user approval) or hand back to forge to re-scaffold.
Inputs the skill needs
Defaults come from ~/.forge/ship-to-vps-config.json if it exists.
Run forge configure ship-to-vps to set persistent defaults (VPS host, SSH key, GHCR namespace, Infisical domain).
If the config file is absent or a value is unset, ask the user once and remember in project memory.
If unset, ask the user once and remember in a project memory:
| Input |
Example |
Where it lands |
<slug> |
perfume-emporio |
repo name, Infisical project slug, /opt/<slug>/, container name, Caddy site filename |
<slug_underscored> |
derived: perfume_emporio |
Postgres user/db name (Postgres rejects hyphens in identifiers) |
<domain> |
example.com |
Cloudflare A-record, Caddy site :443 host, NEXT_PUBLIC_SITE_URL |
<vps_host> |
<your.vps.ip> |
GH Secret VPS_HOST, A-record value |
<vps_user> |
root |
GH Secret VPS_USER, SSH user |
<ssh_key_path> |
~/.ssh/<your-deploy-key> |
source for GH Secret VPS_SSH_KEY |
<ssh_key_relpath> |
.ssh/<your-deploy-key> |
embedded in bin/{logs,rollback} as $HOME/<relpath> |
<ghcr_namespace> |
teckedd-code2save |
image path ghcr.io/<namespace>/<slug> |
<framework> |
nextjs |
Dockerfile template selection (Dockerfile.nextjs-prisma7) |
<framework_display> |
Next.js 14 |
AGENTS.md stack line |
<language> |
TypeScript |
AGENTS.md stack line |
<orm> |
Prisma 7 |
AGENTS.md stack line |
<has_db> |
true |
docker-compose includes postgres + migrations step |
<host_port> |
auto-picked from 13000–13999 |
web container port binding |
<db_host_port> |
auto-picked from 15000–15999 |
postgres port binding (127.0.0.1 only) |
<caddy_priority> |
40-60 per existing sites |
Caddy site filename ordering |
<infisical_project_id> |
derived from .infisical.json |
AGENTS.md "Secrets UI" link |
<cloudflare_zone_id> |
derived from <domain> |
Cloudflare API target |
<public_build_args> |
rendered from Infisical NEXT_PUBLIC_* keys |
Dockerfile ARG/ENV block |
<public_build_args_yaml> |
same data as above, YAML form |
deploy.yml build-args: lines |
<public_build_placeholders_yaml> |
placeholders for CI build |
ci.yml env: block |
Derivation rules
slug_underscored: slug with - → _
ssh_key_relpath: ssh_key_path with leading ~/ stripped
host_port / db_host_port: Step 1 probes ssh ... 'ss -tlnp' and picks the lowest free port in range
caddy_priority: list existing /etc/caddy/sites/*.caddy files, pick a 2-digit number not yet used (default 50)
infisical_project_id: read from .infisical.json workspaceId field
cloudflare_zone_id: extracted from domain via curl ... /zones?name=<root_domain>
public_build_args*: query Infisical prod, filter keys matching ^NEXT_PUBLIC_, render three forms:
- Dockerfile:
ARG NEXT_PUBLIC_X\nENV NEXT_PUBLIC_X=${NEXT_PUBLIC_X} per key
- deploy.yml:
NEXT_PUBLIC_X=${{ vars.NEXT_PUBLIC_X }} per key (indented under build-args: |)
- ci.yml:
NEXT_PUBLIC_X: "ci-placeholder" per key (or the real value if non-sensitive)
Workflow
Step 0 — Preflight & contract check
- Verify SSH to VPS works:
ssh -i <ssh_key> <user>@<host> 'hostname; docker ps'
- Verify
gh auth status shows write:packages + repo + workflow scopes
- Verify Infisical project exists for this repo:
cat .infisical.json | jq .workspaceId
- Run the shippability contract checker:
~/.claude/skills/ship-to-vps/check-shippability.sh <repo-path>. Exits 0 if shippable, prints PASS/FAIL per item. If any item fails, present the list and offer:
- a) Fill the gaps via this skill (Step 3 will render templates for missing infra; app-level gaps like missing
package.json scripts go back to forge)
- b) Hand back to
forge to re-scaffold the whole repo properly
- Verify
<domain> is on a Cloudflare-managed zone the user has API access to (if user opted into Cloudflare integration).
Step 1 — VPS slot provisioning
- Probe a free host port in 13000–13999:
ssh ... 'ss -tlnp | grep -oE ":1[3-4][0-9]{3}"' | sort -u
- Create
/opt/<slug>/ directory tree
- Drop
templates/vps/docker-compose.yml rendered with this project's variables
- Render initial
/opt/<slug>/.env from Infisical prod (Phase 5 of infisical-flow.md)
- Drop
templates/vps/site.caddy into /etc/caddy/sites/ and reload Caddy
- If
<has_db>: bring up only the postgres service first, wait for healthcheck
- Do not start the web service yet — there's no image in GHCR to pull yet (handled in Step 4)
Step 2 — Cloudflare DNS
- List existing A-records for the zone:
curl -H "Authorization: Bearer $CF_TOKEN" https://api.cloudflare.com/client/v4/zones/<zone_id>/dns_records?type=A
- If
<domain> doesn't exist: create A-record pointing to <vps_host> with proxied=false (Caddy handles TLS termination)
- If it exists pointing elsewhere: present diff, ask before overwriting
- Verify:
dig +short <domain> returns <vps_host> (may need ~60s)
Step 3 — Repo: drop shippable artifacts
For each artifact, check if it exists. If yes, diff against template and ask before overwriting. If no, write fresh.
Dockerfile — templates/Dockerfile.<framework>-<orm> (e.g. Dockerfile.nextjs-prisma7)
.eslintrc.json (if Next.js, and missing)
.dockerignore (if missing)
public/.gitkeep (if Next.js + public/ empty)
.github/workflows/{ci,deploy,infisical-sync}.yml
.github/ISSUE_TEMPLATE/{feature,bug,chore,config}.yml
.github/pull_request_template.md
AGENTS.md
CONTRIBUTING.md (3-liner pointing at AGENTS.md)
bin/{logs,rollback} (chmod +x)
All artifacts are templated with <slug>, <domain>, <ghcr_namespace>, <vps_host>, <host_port>.
Step 4 — GHCR bootstrap
- Locate an existing live image, in this priority:
ssh ... 'docker ps --format "{{.Image}}" --filter "name=<slug>-web"' → if it returns a tag, that's the live image. SSH there and docker tag + docker push from VPS. Most reliable.
- Local:
docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "^(<slug>-web|<slug>):latest$" → if matches, push from laptop.
- Neither exists: do a one-shot local
docker build . of the repo, tag as :bootstrap + :latest, push.
- Login to GHCR for the push: pipe
gh auth token (must have write:packages) through docker login ghcr.io -u <gh_user> --password-stdin. Never put the token on a command line.
- Tag and push: both
:bootstrap (immutable rollback target) and :latest. Verify with gh api users/<namespace>/packages/container/<slug>/versions --jq '.[0]'.
- Verify image works:
docker run --rm <bootstrap-ref> node -e "console.log('ok')" (or framework-equivalent boot check).
- Walk user through linking the package to the repo — this is a UI-only step:
- Print:
Open https://github.com/users/<namespace>/packages/container/<slug>/settings → "Manage Actions access" → Add Repository: <slug> with Write role
- Wait for user confirmation before proceeding. Test the link worked by checking the package's
repository field via gh api.
- Clean up GHCR docker creds from VPS if Step 1's push happened from VPS:
ssh ... 'docker logout ghcr.io'. The CI workflow re-auths fresh each run with ephemeral token.
Step 5 — GitHub Secrets + Variables seeding
- GitHub Secrets (
gh secret set with stdin to keep secrets out of shell history):
VPS_SSH_KEY ← cat <ssh_key_path> piped via stdin (NOT --body, which lands on the command line)
VPS_HOST ← <vps_host> (low-sensitivity, --body ok)
VPS_USER ← <vps_user>
INFISICAL_CLIENT_ID ← INFISICAL_UNIVERSAL_AUTH_CLIENT_ID from ~/.infisical/projects/<slug>.env
INFISICAL_CLIENT_SECRET ← INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET from same file
- Discover NEXT_PUBLIC_ keys* by querying Infisical prod env (these are public values safe to log):
sec <slug> prod -- sh -c 'env | grep ^NEXT_PUBLIC_ | sort'
- GitHub Variables — for each
NEXT_PUBLIC_* key from step 2:gh variable set "$KEY" --repo <owner>/<repo> --body "$VAL"
- Generate
<public_build_args>, <public_build_args_yaml>, <public_build_placeholders_yaml> from the same discovered keys (see "Derivation rules" in Inputs section). These get baked into Dockerfile / deploy.yml / ci.yml when Step 3 re-renders them, OR if Step 3 already ran, edit them now and amend.
If new NEXT_PUBLIC_* keys are added to Infisical later, the infisical-sync.yml workflow auto-re-mirrors them to GitHub Variables. But the Dockerfile + deploy.yml + ci.yml will still need a manual ARG/build-arg line added — same as ~/.claude/skills/ship-to-vps/references/infisical-flow.md Phase 2 drift note.
Step 6 — First commit + PR
- Branch
chore/ship-to-vps-bootstrap
- Stage only the artifacts from Step 3
- Commit with conventional message
- Open PR — body explains everything, links to the shippability contract and Infisical flow
- Wait for CI to pass
Step 7 — Merge + first deploy
- Merge with squash (after user confirmation, or auto if
--yolo)
- Wait for
deploy.yml to fire; monitor job-by-job
- If
Build & push fails: usually GHCR auth — verify Step 4 link step
- If
Roll VPS container fails at migrate deploy: usually a deps issue — verify Dockerfile satisfies contract item 1
- If smoke test fails: pull container logs via
bin/logs and surface to user
Step 8 — Post-deploy verification
curl -sf -o /dev/null -w "%{http_code}\n" https://<domain>/ must return 200
ssh ... docker ps must show <slug>-web Up <minutes> with the new image ref
gh run list --workflow=deploy.yml --limit 1 shows success
- Print the deploy summary: domain, container, image ref, smoke result
Step 9 — Enable drift sync
- Confirm
infisical-sync.yml is set to run hourly
- Trigger once via
workflow_dispatch to validate
- Verify it's a no-op when nothing has changed (correct behavior)
Step 10 — Wrap up
- Optionally suggest follow-up issues: observability, smoke test expansion, custom domain TLS validation
- Print runbook quick-reference:
- Deploy: push to
main
- Watch:
gh run watch
- Logs:
bin/logs
- Rollback:
bin/rollback (defaults to :bootstrap)
- Manual sync:
gh workflow run infisical-sync.yml
Failure handling
If any step fails:
- Do not proceed. Surface the failure to the user.
- Do not roll back what's already provisioned — VPS slot, DNS record, GH secrets are all idempotent and safe to leave in place.
- Diagnose with
bin/logs if it's a runtime failure, with gh run view --log-failed if it's a CI failure.
- Loop back to Step 0 contract check if the failure is shape-related (missing eslintrc, untracked dir, etc.). Often the right fix is to update the scaffold in forge, not patch around it here.
Files this skill ships with
ship-to-vps/
├── SKILL.md (this file)
├── references/
│ ├── shippability-contract.md handshake spec
│ └── infisical-flow.md secrets lifecycle
└── templates/
├── Dockerfile.nextjs-prisma7
├── eslintrc.json
├── dockerignore
├── github/
│ ├── workflows/{ci,deploy,infisical-sync}.yml
│ ├── ISSUE_TEMPLATE/{feature,bug,chore,config}.yml
│ └── pull_request_template.md
├── vps/
│ ├── docker-compose.yml
│ └── site.caddy
├── docs/AGENTS.md
└── bin/{logs,rollback}
All template files use {{name}} markers. The skill's render step must handle two substitution patterns:
- Simple inline:
{{slug}} → perfume-emporio. Same-line replacement, no indentation handling needed.
- Multi-line block with indentation preservation:
{{public_build_args}}, {{public_build_args_yaml}}, {{public_build_placeholders_yaml}}. These expand to multiple lines that must inherit the indentation of the line containing the marker. A naive sed s/X/Y/g will collapse newlines into literal \n strings and break YAML/Dockerfile syntax.
A correct render function:
def render(template_text, params):
# Pattern 1: simple inline (no newlines in value)
for k, v in params.items():
if "\n" not in str(v):
template_text = template_text.replace("{{" + k + "}}", str(v))
# Pattern 2: multi-line block (preserve indentation of marker line)
for k, v in params.items():
if "\n" in str(v):
marker = "{{" + k + "}}"
for line in template_text.splitlines():
if marker in line:
indent = line[: len(line) - len(line.lstrip())]
indented = ("\n" + indent).join(v.splitlines())
template_text = template_text.replace(line, line.replace(marker, indented))
return template_text
The dry-run in /tmp/ship-to-vps-dryrun/ uses a simple sed-based renderer that does NOT handle pattern 2 correctly — it's a validation harness only. The real skill must implement the proper renderer (or call out to one).
1---2name: ship-to-vps3description: Ships any web app (Next.js / SvelteKit / Remix / FastAPI / etc.) to a Hetzner-class VPS via GitHub Container Registry, GitHub Actions, Caddy, and Cloudflare DNS. Auto-build → migrate → swap → smoke-test, with rollback and observability helpers. Trigger when the user says "ship it", "deploy this", "set up CI/CD", "wire this to my VPS", or "auto-build on push". Designed to take over right where `forge` leaves off — both skills share the `shippability-contract.md` handshake.4---56# Ship-to-VPS Skill78Take a repo that satisfies `references/shippability-contract.md` and put it on the user's VPS, behind Caddy, with auto-deploy on push to `main`. Every step is idempotent — re-running the skill on an already-shipped repo verifies and reports drift, doesn't destroy.910## When to use1112- After `forge` has scaffolded an app13- When the user says: "ship this", "deploy to my VPS", "wire up CI/CD", "set up auto-deploy"14- When an existing app needs to be migrated from manual `docker compose up` deploys to a CI flow1516## Hard rules17181. **Never break the live container.** Every change is staged; the live container is only swapped after migrations succeed AND a new image is pulled. The previous image stays available as `:bootstrap` (or as the previous SHA tag) for one-command rollback.192. **Never write a secret to a file outside `/opt/<slug>/.env` on the VPS.** Not to GH Secrets (except SSH/Infisical bootstrap), not to the repo, not to shell history.203. **Never push to a GHCR package without the OCI source label.** Manual unlinked pushes break the deploy job's `GITHUB_TOKEN` auth path.214. **Never assume the user's VPS has port X free.** Probe before binding.225. **Never co-tenant another app's Docker network or volume.** Each app gets `/opt/<slug>/` + its own Docker network + its own postgres volume (if needed).236. **Never commit `.env*`.** Already gitignored across user repos — verify, don't re-add.247. **If the repo fails `references/shippability-contract.md`, do NOT proceed.** Either fix the violations (with user approval) or hand back to `forge` to re-scaffold.2526## Inputs the skill needs2728> **Defaults come from `~/.forge/ship-to-vps-config.json` if it exists.**29> Run `forge configure ship-to-vps` to set persistent defaults (VPS host, SSH key, GHCR namespace, Infisical domain).30> If the config file is absent or a value is unset, ask the user once and remember in project memory.3132If unset, ask the user once and remember in a project memory:3334| Input | Example | Where it lands |35|---|---|---|36| `<slug>` | `perfume-emporio` | repo name, Infisical project slug, `/opt/<slug>/`, container name, Caddy site filename |37| `<slug_underscored>` | derived: `perfume_emporio` | Postgres user/db name (Postgres rejects hyphens in identifiers) |38| `<domain>` | `example.com` | Cloudflare A-record, Caddy site `:443` host, `NEXT_PUBLIC_SITE_URL` |39| `<vps_host>` | `<your.vps.ip>` | GH Secret `VPS_HOST`, A-record value |40| `<vps_user>` | `root` | GH Secret `VPS_USER`, SSH user |41| `<ssh_key_path>` | `~/.ssh/<your-deploy-key>` | source for GH Secret `VPS_SSH_KEY` |42| `<ssh_key_relpath>` | `.ssh/<your-deploy-key>` | embedded in `bin/{logs,rollback}` as `$HOME/<relpath>` |43| `<ghcr_namespace>` | `teckedd-code2save` | image path `ghcr.io/<namespace>/<slug>` |44| `<framework>` | `nextjs` | Dockerfile template selection (`Dockerfile.nextjs-prisma7`) |45| `<framework_display>` | `Next.js 14` | AGENTS.md stack line |46| `<language>` | `TypeScript` | AGENTS.md stack line |47| `<orm>` | `Prisma 7` | AGENTS.md stack line |48| `<has_db>` | `true` | docker-compose includes postgres + migrations step |49| `<host_port>` | auto-picked from 13000–13999 | web container port binding |50| `<db_host_port>` | auto-picked from 15000–15999 | postgres port binding (127.0.0.1 only) |51| `<caddy_priority>` | `40`-`60` per existing sites | Caddy site filename ordering |52| `<infisical_project_id>` | derived from `.infisical.json` | AGENTS.md "Secrets UI" link |53| `<cloudflare_zone_id>` | derived from `<domain>` | Cloudflare API target |54| `<public_build_args>` | rendered from Infisical `NEXT_PUBLIC_*` keys | Dockerfile ARG/ENV block |55| `<public_build_args_yaml>` | same data as above, YAML form | deploy.yml `build-args:` lines |56| `<public_build_placeholders_yaml>` | placeholders for CI build | ci.yml `env:` block |5758### Derivation rules5960- `slug_underscored`: `slug` with `-` → `_`61- `ssh_key_relpath`: `ssh_key_path` with leading `~/` stripped62- `host_port` / `db_host_port`: Step 1 probes `ssh ... 'ss -tlnp'` and picks the lowest free port in range63- `caddy_priority`: list existing `/etc/caddy/sites/*.caddy` files, pick a 2-digit number not yet used (default 50)64- `infisical_project_id`: read from `.infisical.json` `workspaceId` field65- `cloudflare_zone_id`: extracted from domain via `curl ... /zones?name=<root_domain>`66- `public_build_args*`: query Infisical prod, filter keys matching `^NEXT_PUBLIC_`, render three forms:67 - Dockerfile: `ARG NEXT_PUBLIC_X\nENV NEXT_PUBLIC_X=${NEXT_PUBLIC_X}` per key68 - deploy.yml: `NEXT_PUBLIC_X=${{ vars.NEXT_PUBLIC_X }}` per key (indented under `build-args: |`)69 - ci.yml: `NEXT_PUBLIC_X: "ci-placeholder"` per key (or the real value if non-sensitive)7071## Workflow7273### Step 0 — Preflight & contract check741. Verify SSH to VPS works: `ssh -i <ssh_key> <user>@<host> 'hostname; docker ps'`752. Verify `gh auth status` shows `write:packages` + `repo` + `workflow` scopes763. Verify Infisical project exists for this repo: `cat .infisical.json | jq .workspaceId`774. **Run the shippability contract checker**: `~/.claude/skills/ship-to-vps/check-shippability.sh <repo-path>`. Exits 0 if shippable, prints PASS/FAIL per item. If any item fails, present the list and offer:78 - **a)** Fill the gaps via this skill (Step 3 will render templates for missing infra; app-level gaps like missing `package.json` scripts go back to forge)79 - **b)** Hand back to `forge` to re-scaffold the whole repo properly805. Verify `<domain>` is on a Cloudflare-managed zone the user has API access to (if user opted into Cloudflare integration).8182### Step 1 — VPS slot provisioning831. Probe a free host port in 13000–13999: `ssh ... 'ss -tlnp | grep -oE ":1[3-4][0-9]{3}"' | sort -u`842. Create `/opt/<slug>/` directory tree853. Drop `templates/vps/docker-compose.yml` rendered with this project's variables864. Render initial `/opt/<slug>/.env` from Infisical `prod` (Phase 5 of `infisical-flow.md`)875. Drop `templates/vps/site.caddy` into `/etc/caddy/sites/` and reload Caddy886. If `<has_db>`: bring up only the postgres service first, wait for healthcheck897. **Do not start the web service yet** — there's no image in GHCR to pull yet (handled in Step 4)9091### Step 2 — Cloudflare DNS921. List existing A-records for the zone: `curl -H "Authorization: Bearer $CF_TOKEN" https://api.cloudflare.com/client/v4/zones/<zone_id>/dns_records?type=A`932. If `<domain>` doesn't exist: create A-record pointing to `<vps_host>` with `proxied=false` (Caddy handles TLS termination)943. If it exists pointing elsewhere: present diff, ask before overwriting954. Verify: `dig +short <domain>` returns `<vps_host>` (may need ~60s)9697### Step 3 — Repo: drop shippable artifacts98For each artifact, check if it exists. If yes, diff against template and ask before overwriting. If no, write fresh.99100- `Dockerfile` — `templates/Dockerfile.<framework>-<orm>` (e.g. `Dockerfile.nextjs-prisma7`)101- `.eslintrc.json` (if Next.js, and missing)102- `.dockerignore` (if missing)103- `public/.gitkeep` (if Next.js + public/ empty)104- `.github/workflows/{ci,deploy,infisical-sync}.yml`105- `.github/ISSUE_TEMPLATE/{feature,bug,chore,config}.yml`106- `.github/pull_request_template.md`107- `AGENTS.md`108- `CONTRIBUTING.md` (3-liner pointing at AGENTS.md)109- `bin/{logs,rollback}` (chmod +x)110111All artifacts are templated with `<slug>`, `<domain>`, `<ghcr_namespace>`, `<vps_host>`, `<host_port>`.112113### Step 4 — GHCR bootstrap1141. **Locate an existing live image**, in this priority:115 1. `ssh ... 'docker ps --format "{{.Image}}" --filter "name=<slug>-web"'` → if it returns a tag, that's the live image. SSH there and `docker tag` + `docker push` from VPS. Most reliable.116 2. Local: `docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "^(<slug>-web|<slug>):latest$"` → if matches, push from laptop.117 3. Neither exists: do a one-shot local `docker build .` of the repo, tag as `:bootstrap` + `:latest`, push.1182. **Login to GHCR for the push**: pipe `gh auth token` (must have `write:packages`) through `docker login ghcr.io -u <gh_user> --password-stdin`. Never put the token on a command line.1193. **Tag and push**: both `:bootstrap` (immutable rollback target) and `:latest`. Verify with `gh api users/<namespace>/packages/container/<slug>/versions --jq '.[0]'`.1204. **Verify image works**: `docker run --rm <bootstrap-ref> node -e "console.log('ok')"` (or framework-equivalent boot check).1215. **Walk user through linking the package to the repo** — this is a UI-only step:122 - Print: `Open https://github.com/users/<namespace>/packages/container/<slug>/settings → "Manage Actions access" → Add Repository: <slug> with Write role`123 - Wait for user confirmation before proceeding. Test the link worked by checking the package's `repository` field via `gh api`.1246. **Clean up GHCR docker creds from VPS** if Step 1's push happened from VPS: `ssh ... 'docker logout ghcr.io'`. The CI workflow re-auths fresh each run with ephemeral token.125126### Step 5 — GitHub Secrets + Variables seeding1271. **GitHub Secrets** (`gh secret set` with stdin to keep secrets out of shell history):128 - `VPS_SSH_KEY` ← `cat <ssh_key_path>` piped via stdin (NOT `--body`, which lands on the command line)129 - `VPS_HOST` ← `<vps_host>` (low-sensitivity, `--body` ok)130 - `VPS_USER` ← `<vps_user>`131 - `INFISICAL_CLIENT_ID` ← `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` from `~/.infisical/projects/<slug>.env`132 - `INFISICAL_CLIENT_SECRET` ← `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` from same file1332. **Discover NEXT_PUBLIC_* keys** by querying Infisical prod env (these are public values safe to log):134 ```bash135 sec <slug> prod -- sh -c 'env | grep ^NEXT_PUBLIC_ | sort'136 ```1373. **GitHub Variables** — for each `NEXT_PUBLIC_*` key from step 2:138 ```bash139 gh variable set "$KEY" --repo <owner>/<repo> --body "$VAL"140 ```1414. **Generate `<public_build_args>`, `<public_build_args_yaml>`, `<public_build_placeholders_yaml>`** from the same discovered keys (see "Derivation rules" in Inputs section). These get baked into Dockerfile / deploy.yml / ci.yml when Step 3 re-renders them, OR if Step 3 already ran, edit them now and amend.142143> If new `NEXT_PUBLIC_*` keys are added to Infisical later, the `infisical-sync.yml` workflow auto-re-mirrors them to GitHub Variables. But the Dockerfile + deploy.yml + ci.yml will still need a manual ARG/build-arg line added — same as `~/.claude/skills/ship-to-vps/references/infisical-flow.md` Phase 2 drift note.144145### Step 6 — First commit + PR1461. Branch `chore/ship-to-vps-bootstrap`1472. Stage only the artifacts from Step 31483. Commit with conventional message1494. Open PR — body explains everything, links to the shippability contract and Infisical flow1505. Wait for CI to pass151152### Step 7 — Merge + first deploy1531. Merge with squash (after user confirmation, or auto if `--yolo`)1542. Wait for `deploy.yml` to fire; monitor job-by-job1553. If `Build & push` fails: usually GHCR auth — verify Step 4 link step1564. If `Roll VPS container` fails at `migrate deploy`: usually a deps issue — verify Dockerfile satisfies contract item 11575. If smoke test fails: pull container logs via `bin/logs` and surface to user158159### Step 8 — Post-deploy verification1601. `curl -sf -o /dev/null -w "%{http_code}\n" https://<domain>/` must return 2001612. `ssh ... docker ps` must show `<slug>-web` `Up <minutes>` with the new image ref1623. `gh run list --workflow=deploy.yml --limit 1` shows `success`1634. Print the deploy summary: domain, container, image ref, smoke result164165### Step 9 — Enable drift sync1661. Confirm `infisical-sync.yml` is set to run hourly1672. Trigger once via `workflow_dispatch` to validate1683. Verify it's a no-op when nothing has changed (correct behavior)169170### Step 10 — Wrap up1711. Optionally suggest follow-up issues: observability, smoke test expansion, custom domain TLS validation1722. Print runbook quick-reference:173 - Deploy: push to `main`174 - Watch: `gh run watch`175 - Logs: `bin/logs`176 - Rollback: `bin/rollback` (defaults to `:bootstrap`)177 - Manual sync: `gh workflow run infisical-sync.yml`178179## Failure handling180181If any step fails:1821. **Do not proceed.** Surface the failure to the user.1832. **Do not roll back what's already provisioned** — VPS slot, DNS record, GH secrets are all idempotent and safe to leave in place.1843. **Diagnose with `bin/logs`** if it's a runtime failure, with `gh run view --log-failed` if it's a CI failure.1854. **Loop back to Step 0 contract check** if the failure is shape-related (missing eslintrc, untracked dir, etc.). Often the right fix is to update the scaffold in forge, not patch around it here.186187## Files this skill ships with188189```190ship-to-vps/191├── SKILL.md (this file)192├── references/193│ ├── shippability-contract.md handshake spec194│ └── infisical-flow.md secrets lifecycle195└── templates/196 ├── Dockerfile.nextjs-prisma7197 ├── eslintrc.json198 ├── dockerignore199 ├── github/200 │ ├── workflows/{ci,deploy,infisical-sync}.yml201 │ ├── ISSUE_TEMPLATE/{feature,bug,chore,config}.yml202 │ └── pull_request_template.md203 ├── vps/204 │ ├── docker-compose.yml205 │ └── site.caddy206 ├── docs/AGENTS.md207 └── bin/{logs,rollback}208```209210All template files use `{{name}}` markers. The skill's render step must handle two substitution patterns:2112121. **Simple inline**: `{{slug}}` → `perfume-emporio`. Same-line replacement, no indentation handling needed.2132. **Multi-line block with indentation preservation**: `{{public_build_args}}`, `{{public_build_args_yaml}}`, `{{public_build_placeholders_yaml}}`. These expand to multiple lines that must inherit the indentation of the line containing the marker. A naive `sed s/X/Y/g` will collapse newlines into literal `\n` strings and break YAML/Dockerfile syntax.214215A correct render function:216217```python218def render(template_text, params):219 # Pattern 1: simple inline (no newlines in value)220 for k, v in params.items():221 if "\n" not in str(v):222 template_text = template_text.replace("{{" + k + "}}", str(v))223 # Pattern 2: multi-line block (preserve indentation of marker line)224 for k, v in params.items():225 if "\n" in str(v):226 marker = "{{" + k + "}}"227 for line in template_text.splitlines():228 if marker in line:229 indent = line[: len(line) - len(line.lstrip())]230 indented = ("\n" + indent).join(v.splitlines())231 template_text = template_text.replace(line, line.replace(marker, indented))232 return template_text233```234235The dry-run in `/tmp/ship-to-vps-dryrun/` uses a simple `sed`-based renderer that does NOT handle pattern 2 correctly — it's a validation harness only. The real skill must implement the proper renderer (or call out to one).