Install
$ agentstack add skill-teckedd-code2save-ai-build-tools-ship-to-vps ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
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//.envon 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//+ 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 toforgeto 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 | |---|---|---| | ` | perfume-emporio | repo name, Infisical project slug, /opt//, container name, Caddy site filename | | | derived: perfumeemporio | Postgres user/db name (Postgres rejects hyphens in identifiers) | | | example.com | Cloudflare A-record, Caddy site :443 host, NEXTPUBLICSITEURL | | | | GH Secret VPSHOST, A-record value | | | root | GH Secret VPSUSER, SSH user | | | ~/.ssh/ | source for GH Secret VPSSSHKEY | | | .ssh/ | embedded in bin/{logs,rollback} as $HOME/ | | | teckedd-code2save | image path ghcr.io// | | | nextjs | Dockerfile template selection (Dockerfile.nextjs-prisma7) | | | Next.js 14 | AGENTS.md stack line | | | TypeScript | AGENTS.md stack line | | | Prisma 7 | AGENTS.md stack line | | | true | docker-compose includes postgres + migrations step | | | auto-picked from 13000–13999 | web container port binding | | | auto-picked from 15000–15999 | postgres port binding (127.0.0.1 only) | | | 40-60 per existing sites | Caddy site filename ordering | | | derived from .infisical.json | AGENTS.md "Secrets UI" link | | | derived from | Cloudflare API target | | | rendered from Infisical NEXTPUBLIC* keys | Dockerfile ARG/ENV block | | | same data as above, YAML form | deploy.yml build-args: lines | | | 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=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 @ '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. 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 `` 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//directory tree - Drop
templates/vps/docker-compose.ymlrendered with this project's variables - Render initial
/opt//.envfrom Infisicalprod(Phase 5 ofinfisical-flow.md) - Drop
templates/vps/site.caddyinto/etc/caddy/sites/and reload Caddy - If ``: 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//dns_records?type=A - If `
doesn't exist: create A-record pointing towithproxied=false` (Caddy handles TLS termination) - If it exists pointing elsewhere: present diff, ask before overwriting
- Verify:
dig +shortreturns `` (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.-(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 `, , , , `.
Step 4 — GHCR bootstrap
- Locate an existing live image, in this priority:
ssh ... 'docker ps --format "{{.Image}}" --filter "name=-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 "^(-web|):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 --password-stdin. Never put the token on a command line. - Tag and push: both
:bootstrap(immutable rollback target) and:latest. Verify withgh api users//packages/container//versions --jq '.[0]'. - Verify image works:
docker run --rm 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//packages/container//settings → "Manage Actions access" → Add Repository: 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←catpiped via stdin (NOT--body, which lands on the command line)VPS_HOST← `(low-sensitivity,--body` ok)VPS_USER← ``INFISICAL_CLIENT_ID←INFISICAL_UNIVERSAL_AUTH_CLIENT_IDfrom~/.infisical/projects/.envINFISICAL_CLIENT_SECRET←INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRETfrom same file
- **Discover NEXTPUBLIC keys* by querying Infisical prod env (these are public values safe to log):
``bash sec prod -- sh -c 'env | grep ^NEXT_PUBLIC_ | sort' ``
- GitHub Variables — for each
NEXT_PUBLIC_*key from step 2:
``bash gh variable set "$KEY" --repo / --body "$VAL" ``
- Generate `
,,` 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.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:///must return 200ssh ... docker psmust show-webUpwith 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)
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/logsif it's a runtime failure, withgh run view --log-failedif 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 naivesed s/X/Y/gwill collapse newlines into literal\nstrings 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).
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: teckedd-code2save
- Source: teckedd-code2save/ai-build-tools
- License: MIT
- Homepage: https://teckedd-code2save.github.io/ai-build-tools/
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.