AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Ship To Vps

skill-teckedd-code2save-ai-build-tools-ship-to-vps · by teckedd-code2save

>

No reviews yet
0 installs
39 views
0.0% view→install

Install

$ agentstack add skill-teckedd-code2save-ai-build-tools-ship-to-vps

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No 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.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-teckedd-code2save-ai-build-tools-ship-to-vps)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
2mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

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 →
Are you the author of Ship To Vps? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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 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

  1. 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.
  2. Never write a secret to a file outside /opt//.env on the VPS. Not to GH Secrets (except SSH/Infisical bootstrap), not to the repo, not to shell history.
  3. Never push to a GHCR package without the OCI source label. Manual unlinked pushes break the deploy job's GITHUB_TOKEN auth path.
  4. Never assume the user's VPS has port X free. Probe before binding.
  5. Never co-tenant another app's Docker network or volume. Each app gets /opt// + its own Docker network + its own postgres volume (if needed).
  6. Never commit .env*. Already gitignored across user repos — verify, don't re-add.
  7. 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 | |---|---|---| | ` | 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: 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=
  • 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

  1. Verify SSH to VPS works: ssh -i @ 'hostname; docker ps'
  2. Verify gh auth status shows write:packages + repo + workflow scopes
  3. Verify Infisical project exists for this repo: cat .infisical.json | jq .workspaceId
  4. 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.json scripts go back to forge)
  • b) Hand back to forge to re-scaffold the whole repo properly
  1. Verify `` is on a Cloudflare-managed zone the user has API access to (if user opted into Cloudflare integration).

Step 1 — VPS slot provisioning

  1. Probe a free host port in 13000–13999: ssh ... 'ss -tlnp | grep -oE ":1[3-4][0-9]{3}"' | sort -u
  2. Create /opt// directory tree
  3. Drop templates/vps/docker-compose.yml rendered with this project's variables
  4. Render initial /opt//.env from Infisical prod (Phase 5 of infisical-flow.md)
  5. Drop templates/vps/site.caddy into /etc/caddy/sites/ and reload Caddy
  6. If ``: bring up only the postgres service first, wait for healthcheck
  7. Do not start the web service yet — there's no image in GHCR to pull yet (handled in Step 4)

Step 2 — Cloudflare DNS

  1. List existing A-records for the zone: curl -H "Authorization: Bearer $CF_TOKEN" https://api.cloudflare.com/client/v4/zones//dns_records?type=A
  2. If ` doesn't exist: create A-record pointing to with proxied=false` (Caddy handles TLS termination)
  3. If it exists pointing elsewhere: present diff, ask before overwriting
  4. Verify: dig +short returns `` (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.

  • Dockerfiletemplates/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.md
  • AGENTS.md
  • CONTRIBUTING.md (3-liner pointing at AGENTS.md)
  • bin/{logs,rollback} (chmod +x)

All artifacts are templated with `, , , , `.

Step 4 — GHCR bootstrap

  1. Locate an existing live image, in this priority:
  2. ssh ... 'docker ps --format "{{.Image}}" --filter "name=-web"' → if it returns a tag, that's the live image. SSH there and docker tag + docker push from VPS. Most reliable.
  3. Local: docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "^(-web|):latest$" → if matches, push from laptop.
  4. Neither exists: do a one-shot local docker build . of the repo, tag as :bootstrap + :latest, push.
  5. Login to GHCR for the push: pipe gh auth token (must have write:packages) through docker login ghcr.io -u --password-stdin. Never put the token on a command line.
  6. Tag and push: both :bootstrap (immutable rollback target) and :latest. Verify with gh api users//packages/container//versions --jq '.[0]'.
  7. Verify image works: docker run --rm node -e "console.log('ok')" (or framework-equivalent boot check).
  8. 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 repository field via gh api.
  1. 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

  1. GitHub Secrets (gh secret set with stdin to keep secrets out of shell history):
  • VPS_SSH_KEYcat piped via stdin (NOT --body, which lands on the command line)
  • VPS_HOST ← ` (low-sensitivity, --body` ok)
  • VPS_USER ← ``
  • INFISICAL_CLIENT_IDINFISICAL_UNIVERSAL_AUTH_CLIENT_ID from ~/.infisical/projects/.env
  • INFISICAL_CLIENT_SECRETINFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET from same file
  1. **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' ``

  1. GitHub Variables — for each NEXT_PUBLIC_* key from step 2:

``bash gh variable set "$KEY" --repo / --body "$VAL" ``

  1. 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

  1. Branch chore/ship-to-vps-bootstrap
  2. Stage only the artifacts from Step 3
  3. Commit with conventional message
  4. Open PR — body explains everything, links to the shippability contract and Infisical flow
  5. Wait for CI to pass

Step 7 — Merge + first deploy

  1. Merge with squash (after user confirmation, or auto if --yolo)
  2. Wait for deploy.yml to fire; monitor job-by-job
  3. If Build & push fails: usually GHCR auth — verify Step 4 link step
  4. If Roll VPS container fails at migrate deploy: usually a deps issue — verify Dockerfile satisfies contract item 1
  5. If smoke test fails: pull container logs via bin/logs and surface to user

Step 8 — Post-deploy verification

  1. curl -sf -o /dev/null -w "%{http_code}\n" https:/// must return 200
  2. ssh ... docker ps must show -web Up with the new image ref
  3. gh run list --workflow=deploy.yml --limit 1 shows success
  4. Print the deploy summary: domain, container, image ref, smoke result

Step 9 — Enable drift sync

  1. Confirm infisical-sync.yml is set to run hourly
  2. Trigger once via workflow_dispatch to validate
  3. Verify it's a no-op when nothing has changed (correct behavior)

Step 10 — Wrap up

  1. Optionally suggest follow-up issues: observability, smoke test expansion, custom domain TLS validation
  2. 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:

  1. Do not proceed. Surface the failure to the user.
  2. Do not roll back what's already provisioned — VPS slot, DNS record, GH secrets are all idempotent and safe to leave in place.
  3. Diagnose with bin/logs if it's a runtime failure, with gh run view --log-failed if it's a CI failure.
  4. 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:

  1. Simple inline: {{slug}}perfume-emporio. Same-line replacement, no indentation handling needed.
  2. 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).

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.