teckedd-code2save/ai-build-tools/ship-to-vps
- community
- ext:[{"type":"hook","description":"Pre/post lifecycle hooks"}]
Checking that the SKILL.md file is still in the repository.
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.
Install
npx vskill@latest install teckedd-code2save/ai-build-tools/ship-to-vpsSemi-extensibleMentions customization, but needs skill-specific knowledge. Read the extensible skills standard
SKILL.md
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
forgehas 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 updeploys 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_TOKENauth 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
forgeto re-scaffold.
Inputs the skill needs
Defaults come from `~/.forge/ship-to-vps-config.json` if it exists.Runforge configure ship-to-vpsto 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:slugwith-→_ssh_key_relpath:ssh_key_pathwith leading~/strippedhost_port/db_host_port: Step 1 probesssh ... 'ss -tlnp'and picks the lowest free port in rangecaddy_priority: list existing/etc/caddy/sites/*.caddyfiles, pick a 2-digit number not yet used (default 50)infisical_project_id: read from.infisical.jsonworkspaceIdfieldcloudflare_zone_id: extracted from domain viacurl ... /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 underbuild-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 statusshowswrite:packages+repo+workflowscopes - 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.jsonscripts go back to forge) - b) Hand back to
forgeto 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.ymlrendered with this project's variables - Render initial
/opt/<slug>/.envfrom Infisicalprod(Phase 5 ofinfisical-flow.md) - Drop
templates/vps/site.caddyinto/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>withproxied=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.mdAGENTS.mdCONTRIBUTING.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 anddocker tag+docker pushfrom 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 havewrite:packages) throughdocker 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 withgh 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
repositoryfield viagh 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 setwith 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,--bodyok)VPS_USER←<vps_user>INFISICAL_CLIENT_ID←INFISICAL_UNIVERSAL_AUTH_CLIENT_IDfrom~/.infisical/projects/<slug>.envINFISICAL_CLIENT_SECRET←INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRETfrom same file
- Discover NEXT_PUBLIC_* keys by querying Infisical prod env (these are public values safe to log):
``bash sec <slug> prod -- sh -c 'env | grep ^NEXT_PUBLIC_ | sort' ``
- GitHub Variables — for each
NEXT_PUBLIC_*key from step 2:
``bash 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 newNEXT_PUBLIC_*keys are added to Infisical later, theinfisical-sync.ymlworkflow 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.mdPhase 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.ymlto fire; monitor job-by-job - If
Build & pushfails: usually GHCR auth — verify Step 4 link step - If
Roll VPS containerfails atmigrate deploy: usually a deps issue — verify Dockerfile satisfies contract item 1 - If smoke test fails: pull container logs via
bin/logsand surface to user
Step 8 — Post-deploy verification
curl -sf -o /dev/null -w "%{http_code}\n" https://<domain>/must return 200ssh ... docker psmust show<slug>-webUp <minutes>with the new image refgh run list --workflow=deploy.yml --limit 1showssuccess- Print the deploy summary: domain, container, image ref, smoke result
Step 9 — Enable drift sync
- Confirm
infisical-sync.ymlis set to run hourly - Trigger once via
workflow_dispatchto validate - Verify it's a no-op when nothing has changed (correct behavior)
This is the start of the file. Read the full SKILL.md on GitHub.
Security
- Security scanPassed, 95 of 100Pattern checks on the skill's files.
- Model reviewPassed
Quality evaluation
- Task evaluationNot runNo evaluation yet. Run one in Skill Studio.
Source
- SKILL.md in the repositoryCheckingskills/ship-to-vps/SKILL.md
Popularity
- Repository stars
- 2
- 7-day trend score
- 5
Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.
Extension points
hookPre/post lifecycle hooks
Works with
Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.
- Claude Code
- Cursor
- GitHub Copilot
- Windsurf
- Codex
- Gemini CLI
- Cline
- Amp
- Roo Code
- Goose
- Continue
- Kilo Code
- Junie
- Trae
- OpenHands
- Qwen Code
- AI Agent
- Tabnine Chat
- Sourcegraph Cody
- Amazon Q
- Firebase Genkit
- Sweep
- Devin
- Phind
- Mentat
- Claude Desktop
- Aider
- GPT Pilot
- Plandex
- bolt.new
- v0
- Replit Agent
- Anse
- TypingMind
- BetterChatGPT
- LibreChat
- Warp AI
- Pieces
- Fig AI
Embed badge
[](https://verified-skill.com/skills/teckedd-code2save/ai-build-tools/ship-to-vps)