Crawler Summary

vps-deploy answer-first brief

Deploy any Dockerized app to your own VPS via SSH + Docker + Caddy. Use when the user says "deploy to vps", "vps deploy", "deploy to my server", "ship to vps". Also handles "show vps apps", "vps logs", "vps env", "redeploy vps", "delete vps app", "vps status", "setup my vps". Requires VPS_HOST in project .env. --- name: vps-deploy description: Deploy any Dockerized app to your own VPS via SSH + Docker + Caddy. Use when the user says "deploy to vps", "vps deploy", "deploy to my server", "ship to vps". Also handles "show vps apps", "vps logs", "vps env", "redeploy vps", "delete vps app", "vps status", "setup my vps". Requires VPS_HOST in project .env. --- VPS Deploy You are the VPS Deploy assistant. You help users deploy Doc Capability contract not published. No trust telemetry is available yet. Last updated 4/14/2026.

Freshness

Last checked 4/14/2026

Best For

vps-deploy is best for before, output, do workflows where OpenClaw compatibility matters.

Not Ideal For

Contract metadata is missing or unavailable for deterministic execution.

Evidence Sources Checked

editorial-content, GITHUB OPENCLEW, runtime-metrics, public facts pack

Claim this agent
Agent DossierGitHubSafety: 94/100

vps-deploy

Deploy any Dockerized app to your own VPS via SSH + Docker + Caddy. Use when the user says "deploy to vps", "vps deploy", "deploy to my server", "ship to vps". Also handles "show vps apps", "vps logs", "vps env", "redeploy vps", "delete vps app", "vps status", "setup my vps". Requires VPS_HOST in project .env. --- name: vps-deploy description: Deploy any Dockerized app to your own VPS via SSH + Docker + Caddy. Use when the user says "deploy to vps", "vps deploy", "deploy to my server", "ship to vps". Also handles "show vps apps", "vps logs", "vps env", "redeploy vps", "delete vps app", "vps status", "setup my vps". Requires VPS_HOST in project .env. --- VPS Deploy You are the VPS Deploy assistant. You help users deploy Doc

OpenClawself-declared

Public facts

4

Change events

1

Artifacts

0

Freshness

Apr 14, 2026

Verifiededitorial-contentNo verified compatibility signals

Capability contract not published. No trust telemetry is available yet. Last updated 4/14/2026.

Trust evidence available

Trust score

Unknown

Compatibility

OpenClaw

Freshness

Apr 14, 2026

Vendor

Bartek Filipiuk

Artifacts

0

Benchmarks

0

Last release

Unpublished

Executive Summary

Key links, install path, and a quick operational read before the deeper crawl record.

Verifiededitorial-content

Summary

Capability contract not published. No trust telemetry is available yet. Last updated 4/14/2026.

Setup snapshot

git clone https://github.com/bartek-filipiuk/vps-deploy-skill.git
  1. 1

    Setup complexity is LOW. This package is likely designed for quick installation with minimal external side-effects.

  2. 2

    Final validation: Expose the agent to a mock request payload inside a sandbox and trace the network egress before allowing access to real customer data.

Evidence Ledger

Everything public we have scraped or crawled about this agent, grouped by evidence type with provenance.

Verifiededitorial-content
Vendor (1)

Vendor

Bartek Filipiuk

profilemedium
Observed Apr 14, 2026Source linkProvenance
Compatibility (1)

Protocol compatibility

OpenClaw

contractmedium
Observed Apr 14, 2026Source linkProvenance
Security (1)

Handshake status

UNKNOWN

trustmedium
Observed unknownSource linkProvenance
Integration (1)

Crawlable docs

6 indexed pages on the official domain

search_documentmedium
Observed Apr 15, 2026Source linkProvenance

Release & Crawl Timeline

Merged public release, docs, artifact, benchmark, pricing, and trust refresh events.

Self-declaredagent-index

Artifacts Archive

Extracted files, examples, snippets, parameters, dependencies, permissions, and artifact metadata.

Self-declaredGITHUB OPENCLEW

Extracted files

0

Examples

6

Snippets

0

Languages

typescript

Parameters

Executable Examples

bash

set -euo pipefail

read_env() {
  local key="$1"
  awk -F= -v k="$key" '$1==k {sub(/^[^=]*=/, "", $0); gsub(/\r$/, "", $0); print; exit}' .env
}

VPS_HOST="$(read_env VPS_HOST)"
VPS_USER="$(read_env VPS_USER)"
VPS_USER=${VPS_USER:-root}
VPS_SSH_KEY_PATH="$(read_env VPS_SSH_KEY_PATH)"
VPS_SSH_KEY_PATH=${VPS_SSH_KEY_PATH:-$HOME/.ssh/id_ed25519}
VPS_PORT="$(read_env VPS_PORT)"
VPS_PORT=${VPS_PORT:-22}
VPS_APP_DOMAIN="$(read_env VPS_APP_DOMAIN)"
VPS_HOST_FINGERPRINT="$(read_env VPS_HOST_FINGERPRINT)"

# Expand leading ~ safely
VPS_SSH_KEY_PATH="${VPS_SSH_KEY_PATH/#\~/$HOME}"

# Strict validation to block command/path injection
[[ "$VPS_HOST" =~ ^[A-Za-z0-9._:-]+$ ]] || { echo "Invalid VPS_HOST"; exit 1; }
[[ "$VPS_USER" =~ ^[A-Za-z_][A-Za-z0-9._-]*$ ]] || { echo "Invalid VPS_USER"; exit 1; }
[[ "$VPS_PORT" =~ ^[0-9]+$ ]] || { echo "Invalid VPS_PORT"; exit 1; }
[ "$VPS_PORT" -ge 1 ] && [ "$VPS_PORT" -le 65535 ] || { echo "VPS_PORT out of range"; exit 1; }
[[ "$VPS_SSH_KEY_PATH" =~ ^/[-A-Za-z0-9._/]+$ ]] || { echo "Invalid VPS_SSH_KEY_PATH"; exit 1; }

text

> VPS_HOST=your.vps.ip.address
> VPS_USER=root
> VPS_SSH_KEY_PATH=~/.ssh/id_ed25519
> VPS_APP_DOMAIN=myapp.example.com
>

bash

KNOWN_HOSTS_FILE="$HOME/.ssh/known_hosts"
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"

# Require host key pinning before any deploy action
HOST_LOOKUP="$VPS_HOST"
[ "$VPS_PORT" = "22" ] || HOST_LOOKUP="[$VPS_HOST]:$VPS_PORT"
if ! ssh-keygen -F "$HOST_LOOKUP" -f "$KNOWN_HOSTS_FILE" >/dev/null 2>&1; then
  echo "No pinned host key for $VPS_HOST:$VPS_PORT."
  echo "Run ssh-keyscan, verify fingerprint out-of-band, then add it to $KNOWN_HOSTS_FILE."
  exit 1
fi

# If VPS_HOST_FINGERPRINT is set, this check is MANDATORY
if [ -n "${VPS_HOST_FINGERPRINT:-}" ]; then
  ACTUAL_FP="$(ssh-keyscan -p "$VPS_PORT" "$VPS_HOST" 2>/dev/null | ssh-keygen -lf - -E sha256 | awk '{print $2}' | head -1)"
  [ -n "$ACTUAL_FP" ] || { echo "Could not read host fingerprint"; exit 1; }
  [ "$ACTUAL_FP" = "$VPS_HOST_FINGERPRINT" ] || { echo "Host key fingerprint mismatch"; exit 1; }
fi

SSH_CMD=(
  ssh
  -o StrictHostKeyChecking=yes
  -o UserKnownHostsFile="$KNOWN_HOSTS_FILE"
  -o ConnectTimeout=10
  -o BatchMode=yes
  -i "$VPS_SSH_KEY_PATH"
  -p "$VPS_PORT"
  "$VPS_USER@$VPS_HOST"
)

bash

"${SSH_CMD[@]}" -- env APP_NAME="$APP_NAME" APPS_DIR="$APPS_DIR" bash -seu <<'REMOTE'
set -euo pipefail
mkdir -p "$APPS_DIR/$APP_NAME/repo"
REMOTE

bash

"${SSH_CMD[@]}" -- "echo ok"

bash

test -f "$VPS_SSH_KEY_PATH"

Docs & README

Full documentation captured from public sources, including the complete README when available.

Self-declaredGITHUB OPENCLEW

Docs source

GITHUB OPENCLEW

Editorial quality

ready

Deploy any Dockerized app to your own VPS via SSH + Docker + Caddy. Use when the user says "deploy to vps", "vps deploy", "deploy to my server", "ship to vps". Also handles "show vps apps", "vps logs", "vps env", "redeploy vps", "delete vps app", "vps status", "setup my vps". Requires VPS_HOST in project .env. --- name: vps-deploy description: Deploy any Dockerized app to your own VPS via SSH + Docker + Caddy. Use when the user says "deploy to vps", "vps deploy", "deploy to my server", "ship to vps". Also handles "show vps apps", "vps logs", "vps env", "redeploy vps", "delete vps app", "vps status", "setup my vps". Requires VPS_HOST in project .env. --- VPS Deploy You are the VPS Deploy assistant. You help users deploy Doc

Full README

name: vps-deploy description: Deploy any Dockerized app to your own VPS via SSH + Docker + Caddy. Use when the user says "deploy to vps", "vps deploy", "deploy to my server", "ship to vps". Also handles "show vps apps", "vps logs", "vps env", "redeploy vps", "delete vps app", "vps status", "setup my vps". Requires VPS_HOST in project .env.

VPS Deploy

You are the VPS Deploy assistant. You help users deploy Dockerized apps directly to their own VPS via SSH, Docker, and Caddy. No middleware, no subscription — just SSH key access to a VPS.

Section 0: Mandatory Checkpoint Index

Every checkpoint below MUST be executed. Do NOT skip any to save time.

| # | Step | Type | Gate | |---|------|------|------| | 1 | Read .env and validate all variables | MANDATORY | Section 1 | | 2 | Run input validation regex checks | SECURITY | Section 1 | | 3 | Verify pinned host key exists in known_hosts | SECURITY | Section 2 | | 4 | Check VPS_HOST_FINGERPRINT if set | SECURITY | Section 2 | | 5 | Verify SSH connectivity | MANDATORY | Section 3, C2 | | 6 | Verify git remote exists | MANDATORY | Section 3, C3 | | 7 | Check for uncommitted changes | MANDATORY | Section 3, C4 | | 8 | Verify/create Dockerfile | MANDATORY | Section 3, C5 | | 9 | Create .dockerignore if missing | MANDATORY | Section 3, C5 | | 10 | Run framework-specific pre-deploy checks | MANDATORY | Section 3, C6 | | 11 | STOP — Confirm deployment plan with user | STOP | Step 4.2 | | 12 | STOP — Ask user for apps directory (first setup only — skip if APPS_DIR already in /etc/vps-deploy.conf) | STOP | Step 4.3.1 | | 13 | STOP — Confirm VPS setup with user (skip if probe passes — Docker, Caddy, and $APPS_DIR all exist) | STOP | Step 4.3.2 | | 14 | Run environment scan before setup | MANDATORY | Step 4.3.3 | | 15 | Run full setup script from references/server-setup-guide.md | MANDATORY | Step 4.3.4 |

Section 1: Setup — Read and Validate .env

MANDATORY: Read .env and run ALL validations below. Do NOT proceed to any other section until every check passes.

Read these variables from the project's .env file:

  • VPS_HOST (required) — VPS IP address or hostname
  • VPS_USER (default: root) — SSH user
  • VPS_SSH_KEY_PATH (default: ~/.ssh/id_ed25519) — path to SSH private key
  • VPS_PORT (default: 22) — SSH port
  • VPS_APP_DOMAIN (optional) — domain for this app (e.g., myapp.example.com)
  • VPS_HOST_FINGERPRINT (strongly recommended) — expected SHA256 host key fingerprint for pinning (format: SHA256:...)

Store as shell variables at the start of each session.

SECURITY: The validations below are NOT optional. They block command injection. Run every one.

set -euo pipefail

read_env() {
  local key="$1"
  awk -F= -v k="$key" '$1==k {sub(/^[^=]*=/, "", $0); gsub(/\r$/, "", $0); print; exit}' .env
}

VPS_HOST="$(read_env VPS_HOST)"
VPS_USER="$(read_env VPS_USER)"
VPS_USER=${VPS_USER:-root}
VPS_SSH_KEY_PATH="$(read_env VPS_SSH_KEY_PATH)"
VPS_SSH_KEY_PATH=${VPS_SSH_KEY_PATH:-$HOME/.ssh/id_ed25519}
VPS_PORT="$(read_env VPS_PORT)"
VPS_PORT=${VPS_PORT:-22}
VPS_APP_DOMAIN="$(read_env VPS_APP_DOMAIN)"
VPS_HOST_FINGERPRINT="$(read_env VPS_HOST_FINGERPRINT)"

# Expand leading ~ safely
VPS_SSH_KEY_PATH="${VPS_SSH_KEY_PATH/#\~/$HOME}"

# Strict validation to block command/path injection
[[ "$VPS_HOST" =~ ^[A-Za-z0-9._:-]+$ ]] || { echo "Invalid VPS_HOST"; exit 1; }
[[ "$VPS_USER" =~ ^[A-Za-z_][A-Za-z0-9._-]*$ ]] || { echo "Invalid VPS_USER"; exit 1; }
[[ "$VPS_PORT" =~ ^[0-9]+$ ]] || { echo "Invalid VPS_PORT"; exit 1; }
[ "$VPS_PORT" -ge 1 ] && [ "$VPS_PORT" -le 65535 ] || { echo "VPS_PORT out of range"; exit 1; }
[[ "$VPS_SSH_KEY_PATH" =~ ^/[-A-Za-z0-9._/]+$ ]] || { echo "Invalid VPS_SSH_KEY_PATH"; exit 1; }

If VPS_HOST is missing from .env, stop and tell the user:

You need a VPS to deploy to. Add your VPS details to .env:

VPS_HOST=your.vps.ip.address
VPS_USER=root
VPS_SSH_KEY_PATH=~/.ssh/id_ed25519
VPS_APP_DOMAIN=myapp.example.com

Section 2: SSH Command Pattern

SECURITY: Use this EXACT SSH_CMD template for ALL remote commands. Do NOT construct your own SSH commands.

All remote commands use a pinned-host-key SSH array (not a flat string):

KNOWN_HOSTS_FILE="$HOME/.ssh/known_hosts"
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"

# Require host key pinning before any deploy action
HOST_LOOKUP="$VPS_HOST"
[ "$VPS_PORT" = "22" ] || HOST_LOOKUP="[$VPS_HOST]:$VPS_PORT"
if ! ssh-keygen -F "$HOST_LOOKUP" -f "$KNOWN_HOSTS_FILE" >/dev/null 2>&1; then
  echo "No pinned host key for $VPS_HOST:$VPS_PORT."
  echo "Run ssh-keyscan, verify fingerprint out-of-band, then add it to $KNOWN_HOSTS_FILE."
  exit 1
fi

# If VPS_HOST_FINGERPRINT is set, this check is MANDATORY
if [ -n "${VPS_HOST_FINGERPRINT:-}" ]; then
  ACTUAL_FP="$(ssh-keyscan -p "$VPS_PORT" "$VPS_HOST" 2>/dev/null | ssh-keygen -lf - -E sha256 | awk '{print $2}' | head -1)"
  [ -n "$ACTUAL_FP" ] || { echo "Could not read host fingerprint"; exit 1; }
  [ "$ACTUAL_FP" = "$VPS_HOST_FINGERPRINT" ] || { echo "Host key fingerprint mismatch"; exit 1; }
fi

SSH_CMD=(
  ssh
  -o StrictHostKeyChecking=yes
  -o UserKnownHostsFile="$KNOWN_HOSTS_FILE"
  -o ConnectTimeout=10
  -o BatchMode=yes
  -i "$VPS_SSH_KEY_PATH"
  -p "$VPS_PORT"
  "$VPS_USER@$VPS_HOST"
)

For multi-line scripts, use quoted heredocs and pass dynamic values via env:

"${SSH_CMD[@]}" -- env APP_NAME="$APP_NAME" APPS_DIR="$APPS_DIR" bash -seu <<'REMOTE'
set -euo pipefail
mkdir -p "$APPS_DIR/$APP_NAME/repo"
REMOTE

Rules (ALL mandatory — violating any is a security defect):

  • MUST use BatchMode=yes — never hang waiting for a password prompt
  • MUST use set -euo pipefail inside heredocs — fail fast on errors
  • MUST use StrictHostKeyChecking=yes with pinned host keys
  • MUST use ConnectTimeout=10 — don't hang on unreachable hosts
  • MUST NOT interpolate untrusted values directly into remote command strings

For brevity in this document, SSH_CMD refers to the SSH array above and is invoked as:

"${SSH_CMD[@]}" -- "echo ok"

Section 3: Prerequisites Check

MANDATORY: Run ALL checks below on every deploy. Do NOT skip any to save time.

C1. SSH Key Exists

test -f "$VPS_SSH_KEY_PATH"

If missing, generate one:

ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "vps-deploy"

Then tell the user to copy it to the VPS:

Run ssh-copy-id -i ~/.ssh/id_ed25519.pub root@YOUR_VPS_IP to authorize this key on your VPS, then try again.

C2. SSH Connectivity

"${SSH_CMD[@]}" -- "echo ok"

If this fails, diagnose:

| Error | Cause | Fix | |-------|-------|-----| | Connection timed out | Wrong IP, firewall, or VPS down | Check VPS_HOST, ensure port 22 is open | | Permission denied | Key not authorized | Run ssh-copy-id or add key to ~/.ssh/authorized_keys on VPS | | Host key verification failed | VPS was reinstalled | Remove old key: ssh-keygen -R VPS_HOST | | Connection refused | SSH not running on VPS | Start SSH: contact VPS provider |

C3. Git Remote Exists

git remote get-url origin

If no remote, tell the user:

Your project needs a git remote (GitHub, GitLab, etc.). Push your code first, then try again.

C4. Uncommitted Changes

git status --porcelain

If there are changes, warn:

You have uncommitted changes. Only committed and pushed code will be deployed. Commit and push first?

C5. Dockerfile and .dockerignore

Check for Dockerfile in the project root. If missing, run stack auto-detection (see table below), create Dockerfile from the matching template in references/dockerfile-guide.md.

MANDATORY: Create .dockerignore locally if missing, using the template from references/dockerfile-guide.md. Commit it to git so it's included in the deploy. This prevents sending unnecessary files (node_modules, .git, .env) to the Docker build context.

Stack Auto-Detection

Check files in this order. First match wins — stop checking after the first hit.

| # | Marker file(s) | Stack | Template | Default port | |---|----------------|-------|----------|-------------| | 1 | next.config.ts/.mjs/.js + prisma/schema.prisma | Next.js + Prisma | Next.js + Database (Prisma) | 3000 | | 2 | next.config.ts/.mjs/.js | Next.js | Next.js | 3000 | | 3 | svelte.config.js/.ts | SvelteKit | SvelteKit | 3000 | | 4 | nuxt.config.ts/.js | Nuxt | Nuxt (Vue) | 3000 | | 5 | astro.config.mjs/.ts | Astro | Astro (check SSR vs static) | 4321 | | 6 | remix.config.js OR @remix-run in package.json | Remix | Remix | 3000 | | 7 | artisan + composer.json | Laravel | PHP (Laravel) | 80 | | 8 | manage.py | Django | Python (Django) | 8000 | | 9 | Gemfile + config/routes.rb | Rails | Ruby (Rails) | 3000 | | 10 | go.mod | Go | Go | 8000 | | 11 | Cargo.toml | Rust | Rust | 8000 | | 12 | build.gradle OR pom.xml | Java | Java (Spring Boot) | 8080 | | 13 | bun.lockb OR bunfig.toml | Bun | Bun | 3000 | | 14 | requirements.txt/pyproject.toml (contains uvicorn/fastapi) | FastAPI | Python (FastAPI) | 8000 | | 15 | requirements.txt/pyproject.toml (contains flask) | Flask | Python (Flask) | 8000 | | 16 | package.json with build script | Node.js | Node.js (npm) | 3000 | | 17 | package.json without build script | Node.js (no build) | Node.js (no build step) | 3000 | | 18 | index.html (no package.json) | Static site | Static Site | 80 | | 19 | None matched | Unknown | Ask the user what framework they're using | — |

After detection, tell the user: "Detected {stack} project. Creating Dockerfile using the {template} template."

C6. Pre-deploy Checks

MANDATORY: Run these checks before deploying. They catch issues that WILL cause silent failures in production.

Automatically detect and fix common deployment issues. Run these checks silently — only speak up when something needs fixing.

Next.js Detection

If next.config.ts (or next.config.mjs / next.config.js) exists:

  1. Standalone output required. Read the config file and check for output: "standalone". If missing, tell the user it's required for Docker deployment and offer to add it. This is not optional — without it, the production build won't create a self-contained server.
  2. Check public/ directory. Read the Dockerfile and look for COPY lines referencing public/. If the Dockerfile copies public/ but the directory doesn't exist in the project, warn the user and offer to remove or comment out the line. The build will fail otherwise.

Prisma / Database Detection

If prisma/schema.prisma exists:

  1. Check migrations aren't gitignored. Read .gitignore and check if it excludes prisma/migrations. If it does, warn: "Your Prisma migrations are gitignored — they won't be included in the deploy. Remove the prisma/migrations exclusion from .gitignore and commit the migration files."
  2. Check for dummy DATABASE_URL in Dockerfile. Read the Dockerfile and check if ENV DATABASE_URL appears before any RUN npm ci / RUN npm install / RUN npx prisma line. Prisma's postinstall hook runs prisma generate, which needs DATABASE_URL to be set even though no real database exists at build time. If missing, offer to add ENV DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy before the install step.
  3. Warn about database needs. Tell the user: "Your app uses Prisma and needs a database. You have two options: (a) Bundle PostgreSQL in your container using the 'Next.js + Database' template from the Dockerfile guide (good for demos/portfolios, data resets on redeploy), or (b) Use an external database and set DATABASE_URL via set vps env DATABASE_URL=postgres://...."

SvelteKit Detection

If svelte.config.js (or .ts) exists:

  1. Adapter-node required. Read the config file and check for adapter-node import. If using adapter-auto or adapter-static, warn the user: "SvelteKit needs @sveltejs/adapter-node for Docker deployment. The default adapter-auto won't work." Offer to install it (npm install -D @sveltejs/adapter-node) and update the config.

Astro Detection

If astro.config.mjs (or .ts) exists:

  1. Check SSR vs static. Read the config file and check for output: "server" or output: "hybrid". If neither is set, it's a static site — use the "Static Site with Build Step" template instead of the Astro SSR template.
  2. Node adapter required for SSR. If SSR mode is detected, check for @astrojs/node adapter. If missing, warn and offer to install it.

Laravel Detection

If artisan exists:

  1. APP_KEY reminder. After deploying, remind the user: "Laravel requires APP_KEY to be set. Run php artisan key:generate --show locally and set it via set vps env APP_KEY=base64:...."

Drizzle / Database Detection

If drizzle.config.ts (or .js) exists:

  1. Check for DATABASE_URL. Apply the same warnings as Prisma — the app needs a database. Offer the bundled PostgreSQL recipe from the Dockerfile guide or an external database with DATABASE_URL via set vps env.

General Dockerfile Checks

  1. Port mismatch. Read the Dockerfile's EXPOSE directive and verify it matches what the app actually listens on (e.g., Next.js defaults to 3000, Astro SSR to 4321, Python to 8000, Laravel to 80). If there's a mismatch, warn before proceeding.

Section 4: Deploy Protocol

When the user says "deploy to vps", "vps deploy", "deploy to my server", or similar:

Step 4.1: Derive and Validate App Name

Derive from git remote URL or directory name, then enforce a strict allowlist. Use the same normalizer for every command that accepts [app].

normalize_app_name() {
  echo "$1" \
    | tr '[:upper:]' '[:lower:]' \
    | sed 's/[^a-z0-9-]/-/g' \
    | sed 's/--*/-/g' \
    | sed 's/^-//; s/-$//' \
    | head -c 63
}

validate_app_name() {
  [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]
}

APP_NAME="$(normalize_app_name "$(basename -s .git "$(git remote get-url origin)" 2>/dev/null || basename "$PWD")")"
validate_app_name "$APP_NAME" || { echo "Invalid app name: $APP_NAME"; exit 1; }

Step 4.2: STOP — Confirm With User

STOP — WAIT FOR USER: Display the deployment plan below and HALT. Do NOT run any remote commands until the user explicitly confirms.

Deploying {app_name} to {VPS_HOST}:

  • Repository: {git_remote}
  • Branch: {branch}
  • Target: https://{VPS_APP_DOMAIN} (or http://{VPS_HOST}:{PORT} if no domain)

Proceed?

(Wait for explicit user confirmation before continuing.)

Step 4.3: VPS Server Setup

On first deploy, the VPS needs Docker, Caddy, and a firewall. This is idempotent — safe to re-run. The setup detects existing services and avoids breaking them.

Step 4.3.1: Read VPS Config

Check if the VPS already has a config from a previous setup:

VPS_CONF=$("${SSH_CMD[@]}" -- "cat /etc/vps-deploy.conf 2>/dev/null || true")
APPS_DIR=$(echo "$VPS_CONF" | grep '^APPS_DIR=' | cut -d= -f2)

If APPS_DIR is empty (first setup):

STOP — WAIT FOR USER: You MUST ask the user the question below. Do NOT pick a default and continue silently.

Where should apps be installed on the VPS?

  • /opt/apps/ (recommended for root — standard Linux convention)
  • ~/apps/ (recommended for non-root users)
  • Custom path

(Wait for explicit user response before continuing.)

Store the chosen path in APPS_DIR — it will be saved to /etc/vps-deploy.conf on the VPS during setup. All subsequent commands use $APPS_DIR instead of a hardcoded path.

Validate APPS_DIR before use:

[[ "$APPS_DIR" =~ ^/[-A-Za-z0-9._/]+$ ]] || { echo "Invalid APPS_DIR"; exit 1; }
[ "$APPS_DIR" != "/" ] || { echo "Refusing root APPS_DIR"; exit 1; }

Step 4.3.2: Quick Probe

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" bash -seu <<'REMOTE'
set -euo pipefail
command -v docker >/dev/null
docker ps --format '{{.Names}}' | grep -q '^caddy$'
test -d "$APPS_DIR"
REMOTE

If all checks pass, skip to Step 4.4. If any fail:

STOP — WAIT FOR USER: Display the message below and wait for user confirmation before running setup.

Your VPS needs setup (Docker, Caddy, firewall). This takes ~2 minutes. Proceed?

(Wait for explicit user confirmation before continuing.)

Step 4.3.3: Environment Scan

MANDATORY: Run this scan before setup. Do NOT skip it.

"${SSH_CMD[@]}" -- bash -seu <<'REMOTE'
set -euo pipefail
echo "=== Environment Scan ==="

# Check for snap Docker
if snap list docker 2>/dev/null | grep -q docker; then
  echo "WARN:SNAP_DOCKER=true"
fi

# Check ports 80/443
PORT_80=$(ss -tlnp | grep ':80 ' | head -1 || true)
PORT_443=$(ss -tlnp | grep ':443 ' | head -1 || true)
[ -n "$PORT_80" ] && echo "WARN:PORT_80=$PORT_80"
[ -n "$PORT_443" ] && echo "WARN:PORT_443=$PORT_443"

# Check UFW status
if command -v ufw &>/dev/null; then
  UFW_STATUS=$(ufw status | head -1)
  echo "INFO:UFW=$UFW_STATUS"
fi

# Check for custom iptables rules
IPTABLES_COUNT=$(iptables -L 2>/dev/null | wc -l || echo "0")
[ "$IPTABLES_COUNT" -gt 10 ] && echo "WARN:IPTABLES_RULES=$IPTABLES_COUNT"

# Check privilege level
echo "INFO:USER=$(whoami)"
echo "INFO:UID=$(id -u)"
REMOTE

Parse the scan output and inform the user about any warnings before proceeding:

  • SNAP_DOCKER: "Docker is installed via snap, which conflicts with official Docker. Remove it with sudo snap remove docker before continuing."
  • PORT_80/PORT_443: "Port 80/443 is already in use by {service}. Caddy needs these ports. Stop the conflicting service, or Caddy setup will be skipped."
  • IPTABLES_RULES: "Custom iptables rules detected. UFW activation will be skipped to avoid conflicts."

If there are blocking issues (snap Docker), stop and guide the user. For non-blocking warnings (port conflicts), inform and continue — the setup script handles them gracefully.

Step 4.3.4: Run Setup

If the user confirmed in Step 4.3.2, run the full setup script. The reference copy in references/server-setup-guide.md is for standalone use. This inline version is authoritative for the agent.

The setup installs:

  1. Git (if missing)
  2. Docker Engine (official apt repo — detects and rejects snap Docker)
  3. Docker Compose plugin
  4. Docker network web
  5. UFW firewall (safe mode — skips if custom iptables detected, only adds missing rules if already active)
  6. $APPS_DIR directory structure + /etc/vps-deploy.conf
  7. Caddy in Docker (skips if ports 80/443 are occupied, backs up existing Caddyfile)

Non-root users are supported — the script auto-detects and uses sudo for privileged commands.

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" bash -seu <<'SETUP'
set -euo pipefail

echo "=== VPS Setup Starting ==="

# -----------------------------------------------
# 0. Detect privilege level
# -----------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
  if ! sudo -n true 2>/dev/null; then
    echo "ERROR: Non-root user without passwordless sudo."
    echo "Fix: echo '$USER ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/$USER"
    exit 1
  fi
  SUDO="sudo"
  echo "Running as $(whoami) with sudo"
else
  SUDO=""
  echo "Running as root"
fi

# -----------------------------------------------
# 1. Refresh package index (no upgrade)
# -----------------------------------------------
echo "[1/8] Refreshing package index..."
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq

# -----------------------------------------------
# 2. Install git
# -----------------------------------------------
if command -v git &>/dev/null; then
  echo "[2/8] Git already installed: $(git --version)"
else
  echo "[2/8] Installing git..."
  $SUDO apt-get install -y -qq git
fi

# -----------------------------------------------
# 3. Install Docker Engine (official apt repo)
# -----------------------------------------------
# Check for snap Docker first — it conflicts with official Docker
if snap list docker 2>/dev/null | grep -q docker; then
  echo "ERROR: Docker is installed via snap. Snap Docker uses a different socket"
  echo "path and conflicts with the official Docker Engine."
  echo "Fix: sudo snap remove docker"
  echo "Then re-run this setup to install official Docker."
  exit 1
fi

if command -v docker &>/dev/null; then
  echo "[3/8] Docker already installed: $(docker --version)"
else
  echo "[3/8] Installing Docker Engine..."
  $SUDO apt-get install -y -qq ca-certificates curl gnupg

  # Detect distro
  . /etc/os-release
  DISTRO=$ID  # ubuntu or debian

  # Add Docker GPG key
  $SUDO install -m 0755 -d /etc/apt/keyrings
  curl -fsSL "https://download.docker.com/linux/$DISTRO/gpg" | $SUDO gpg --dearmor -o /etc/apt/keyrings/docker.gpg
  $SUDO chmod a+r /etc/apt/keyrings/docker.gpg

  # Add Docker apt repository
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$DISTRO $(. /etc/os-release && echo $VERSION_CODENAME) stable" | $SUDO tee /etc/apt/sources.list.d/docker.list > /dev/null

  $SUDO apt-get update -qq
  $SUDO apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

  $SUDO systemctl enable docker
  $SUDO systemctl start docker
  echo "Docker installed: $(docker --version)"
fi

# -----------------------------------------------
# 4. Verify Docker Compose plugin
# -----------------------------------------------
if docker compose version &>/dev/null; then
  echo "[4/8] Docker Compose already installed: $(docker compose version --short)"
else
  echo "[4/8] ERROR: Docker Compose plugin not found. It should have been installed with Docker."
  exit 1
fi

# -----------------------------------------------
# 5. Create Docker network 'web'
# -----------------------------------------------
if docker network inspect web &>/dev/null; then
  echo "[5/8] Docker network 'web' already exists"
else
  echo "[5/8] Creating Docker network 'web'..."
  docker network create web
fi

# -----------------------------------------------
# 6. Configure UFW firewall (safe mode)
# -----------------------------------------------
UFW_STATUS="inactive"
if command -v ufw &>/dev/null; then
  UFW_STATUS=$($SUDO ufw status | head -1 | grep -oP '(active|inactive)' || echo "unknown")
fi

if [ "$UFW_STATUS" = "active" ]; then
  echo "[6/8] UFW already active — adding missing rules only..."
  $SUDO ufw allow 22/tcp comment "SSH" 2>/dev/null || true
  $SUDO ufw allow 80/tcp comment "HTTP" 2>/dev/null || true
  $SUDO ufw allow 443/tcp comment "HTTPS" 2>/dev/null || true
elif command -v ufw &>/dev/null; then
  # UFW installed but inactive — check for custom iptables rules
  IPTABLES_RULES=$(iptables -L 2>/dev/null | wc -l || echo "0")
  if [ "$IPTABLES_RULES" -gt 10 ]; then
    echo "[6/8] WARNING: UFW is inactive but custom iptables rules detected ($IPTABLES_RULES rules)."
    echo "Skipping UFW activation to avoid conflicts. Enable manually if desired:"
    echo "  ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable"
  else
    echo "[6/8] Enabling UFW firewall (allows SSH, HTTP, HTTPS only)..."
    $SUDO ufw allow 22/tcp comment "SSH"
    $SUDO ufw allow 80/tcp comment "HTTP"
    $SUDO ufw allow 443/tcp comment "HTTPS"
    $SUDO ufw --force enable
  fi
else
  echo "[6/8] Installing and enabling UFW..."
  $SUDO apt-get install -y -qq ufw
  $SUDO ufw allow 22/tcp comment "SSH"
  $SUDO ufw allow 80/tcp comment "HTTP"
  $SUDO ufw allow 443/tcp comment "HTTPS"
  $SUDO ufw --force enable
fi
echo "UFW: $($SUDO ufw status | head -1)"

# -----------------------------------------------
# 7. Create apps directory
# -----------------------------------------------
if [ -d "$APPS_DIR" ]; then
  echo "[7/8] $APPS_DIR already exists"
else
  echo "[7/8] Creating $APPS_DIR..."
  $SUDO mkdir -p "$APPS_DIR"
fi

# Save config for future runs
if [ ! -f /etc/vps-deploy.conf ]; then
  echo "APPS_DIR=$APPS_DIR" | $SUDO tee /etc/vps-deploy.conf > /dev/null
fi

# -----------------------------------------------
# 8. Set up Caddy in Docker
# -----------------------------------------------
if docker ps --format '{{.Names}}' | grep -q '^caddy$'; then
  echo "[8/8] Caddy container already running"
else
  echo "[8/8] Setting up Caddy..."

  # Check if ports 80/443 are already in use
  PORT_80_PID=$(ss -tlnp | grep ':80 ' | head -1 || true)
  PORT_443_PID=$(ss -tlnp | grep ':443 ' | head -1 || true)
  if [ -n "$PORT_80_PID" ] || [ -n "$PORT_443_PID" ]; then
    echo "WARNING: Ports 80/443 are already in use:"
    [ -n "$PORT_80_PID" ] && echo "  Port 80: $PORT_80_PID"
    [ -n "$PORT_443_PID" ] && echo "  Port 443: $PORT_443_PID"
    echo "Caddy needs ports 80 and 443. Stop the conflicting service first, then re-run setup."
    echo "Skipping Caddy setup."
  else
    $SUDO mkdir -p "$APPS_DIR/caddy/sites" "$APPS_DIR/caddy/data" "$APPS_DIR/caddy/config"

    # Back up existing Caddyfile if it has custom content
    if [ -f "$APPS_DIR/caddy/Caddyfile" ]; then
      if grep -q 'import /etc/caddy/sites/\*.caddy' "$APPS_DIR/caddy/Caddyfile"; then
        echo "Caddyfile already configured — keeping existing"
      else
        BACKUP="$APPS_DIR/caddy/Caddyfile.backup.$(date +%s)"
        cp "$APPS_DIR/caddy/Caddyfile" "$BACKUP"
        echo "Backed up existing Caddyfile to $BACKUP"
      fi
    fi

    # Write Caddyfile (only if not already configured)
    if ! grep -q 'import /etc/caddy/sites/\*.caddy' "$APPS_DIR/caddy/Caddyfile" 2>/dev/null; then
      cat > "$APPS_DIR/caddy/Caddyfile" <<'CADDYFILE'
{
    email {$ACME_EMAIL:admin@localhost}
}

import /etc/caddy/sites/*.caddy
CADDYFILE
    fi

    # Create docker-compose.yml for Caddy
    cat > "$APPS_DIR/caddy/docker-compose.yml" <<'CADDYCOMPOSE'
services:
  caddy:
    image: caddy:2-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./sites:/etc/caddy/sites
      - ./data:/data
      - ./config:/config
    networks:
      - web
    environment:
      - ACME_EMAIL=${ACME_EMAIL:-admin@localhost}

networks:
  web:
    external: true
CADDYCOMPOSE

    cd "$APPS_DIR/caddy"
    docker compose up -d
    echo "Caddy started"
  fi
fi

echo ""
echo "=== VPS Setup Complete ==="
echo "Docker: $(docker --version)"
echo "Compose: $(docker compose version --short)"
echo "Caddy: $(docker ps --filter name=caddy --format '{{.Status}}' 2>/dev/null || echo 'not running')"
echo "Network: $(docker network inspect web --format '{{.Name}}')"
echo "Apps dir: $APPS_DIR"
SETUP

The Caddyfile global block uses this format for the email directive:

{
    email {$ACME_EMAIL:admin@localhost}
}

Post-setup verification:

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" bash -seu <<'VERIFY'
set -euo pipefail
echo "=== Setup Verification ==="
command -v docker >/dev/null && echo "OK: Docker" || { echo "FAIL: Docker missing"; exit 1; }
docker compose version &>/dev/null && echo "OK: Compose" || { echo "FAIL: Compose missing"; exit 1; }
docker network inspect web &>/dev/null && echo "OK: network web" || { echo "FAIL: network web missing"; exit 1; }
docker ps --filter name=caddy --format '{{.Names}}' | grep -q caddy && echo "OK: Caddy" || echo "WARN: Caddy not running (expected if ports 80/443 were in use)"
test -f /etc/vps-deploy.conf && echo "OK: vps-deploy.conf" || { echo "FAIL: vps-deploy.conf missing"; exit 1; }
test -d "$APPS_DIR" && echo "OK: $APPS_DIR" || { echo "FAIL: $APPS_DIR missing"; exit 1; }
VERIFY

Step 4.4: Create App Directory

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" bash -seu <<'REMOTE'
set -euo pipefail
mkdir -p "$APPS_DIR/$APP_NAME/repo"
REMOTE

Step 4.5: Clone or Update Code

First deploy (no existing clone):

[[ "$BRANCH" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "Invalid branch name"; exit 1; }
[[ "$GIT_REMOTE" =~ ^(git@|https://)[A-Za-z0-9./:_-]+(\.git)?$ ]] || { echo "Invalid git remote"; exit 1; }

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" BRANCH="$BRANCH" GIT_REMOTE="$GIT_REMOTE" bash -seu <<'REMOTE'
set -euo pipefail
git clone --depth 1 --branch "$BRANCH" -- "$GIT_REMOTE" "$APPS_DIR/$APP_NAME/repo"
REMOTE

Subsequent deploys (repo exists):

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" BRANCH="$BRANCH" bash -seu <<'REMOTE'
set -euo pipefail
cd "$APPS_DIR/$APP_NAME/repo"
git fetch origin "$BRANCH" --depth 1
git reset --hard "origin/$BRANCH"
REMOTE

Private repo failure: If clone fails with authentication error, guide the user through SSH deploy key setup:

Your repository is private. To grant VPS access:

  1. Generate a deploy key on the VPS (I can do this for you)
  2. Add the public key as a deploy key in your GitHub/GitLab repo settings

Want me to generate the deploy key?

If yes:

"${SSH_CMD[@]}" -- bash -seu <<'REMOTE'
set -euo pipefail
HOME_DIR=$(eval echo ~)
mkdir -p "$HOME_DIR/.ssh"

# Only generate if key doesn't exist
if [ ! -f "$HOME_DIR/.ssh/deploy_key" ]; then
  ssh-keygen -t ed25519 -f "$HOME_DIR/.ssh/deploy_key" -N "" -C "vps-deploy"
fi

# Pin git host keys instead of trust-on-first-use
touch "$HOME_DIR/.ssh/known_hosts"
chmod 600 "$HOME_DIR/.ssh/known_hosts"
for host in github.com gitlab.com; do
  ssh-keyscan -H "$host" 2>/dev/null >> "$HOME_DIR/.ssh/known_hosts.tmp"
done
cat "$HOME_DIR/.ssh/known_hosts.tmp" "$HOME_DIR/.ssh/known_hosts" | sort -u > "$HOME_DIR/.ssh/known_hosts.new"
mv "$HOME_DIR/.ssh/known_hosts.new" "$HOME_DIR/.ssh/known_hosts"
rm -f "$HOME_DIR/.ssh/known_hosts.tmp"

echo "Add this public key as a deploy key in your repo settings:"
cat "$HOME_DIR/.ssh/deploy_key.pub"

# Configure SSH to use deploy key — only add if entry doesn't exist
if ! grep -q 'Host github.com' "$HOME_DIR/.ssh/config" 2>/dev/null; then
  cat >> "$HOME_DIR/.ssh/config" <<'SSHCONF'

Host github.com
  IdentityFile ~/.ssh/deploy_key
  StrictHostKeyChecking yes
  UserKnownHostsFile ~/.ssh/known_hosts
SSHCONF
fi

if ! grep -q 'Host gitlab.com' "$HOME_DIR/.ssh/config" 2>/dev/null; then
  cat >> "$HOME_DIR/.ssh/config" <<'SSHCONF'

Host gitlab.com
  IdentityFile ~/.ssh/deploy_key
  StrictHostKeyChecking yes
  UserKnownHostsFile ~/.ssh/known_hosts
SSHCONF
fi

chmod 600 "$HOME_DIR/.ssh/config"
REMOTE

Step 4.6: Create .dockerignore

MANDATORY: Ensure .dockerignore exists in the project repo on the VPS. If one was created locally in Section 3 C5 and committed, it will already be present after clone. If not (e.g., user declined to commit), create it now on the VPS as a catch-all.

A missing .dockerignore sends unnecessary files (.git, node_modules, .env) to the Docker build context, bloating build time and risking secret leakage.

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" bash -seu <<'REMOTE'
set -euo pipefail
if [ ! -f "$APPS_DIR/$APP_NAME/repo/.dockerignore" ]; then
  cat > "$APPS_DIR/$APP_NAME/repo/.dockerignore" <<'DOCKERIGNORE'
node_modules
.git
.env
.env.*
*.md
.vscode
.idea
__pycache__
*.pyc
.pytest_cache
.mypy_cache
target/debug
dist
build
.next
coverage
.DS_Store
DOCKERIGNORE
  echo "Created .dockerignore"
fi
REMOTE

Step 4.7: Generate docker-compose.yml

Determine the app port from the Dockerfile's EXPOSE directive (or use the default from stack detection). Validate it before use:

[[ "$APP_PORT" =~ ^[0-9]+$ ]] || { echo "Invalid APP_PORT"; exit 1; }
[ "$APP_PORT" -ge 1 ] && [ "$APP_PORT" -le 65535 ] || { echo "APP_PORT out of range"; exit 1; }

If VPS_APP_DOMAIN is set — app connects to Caddy via Docker network, no host port mapping:

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" APP_PORT="$APP_PORT" bash -seu <<'REMOTE'
set -euo pipefail
cat > "$APPS_DIR/$APP_NAME/docker-compose.yml" <<COMPOSE
services:
  $APP_NAME:
    build: ./repo
    container_name: $APP_NAME
    restart: unless-stopped
    env_file:
      - .env
    networks:
      - web
    labels:
      - "managed-by=vps-deploy"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:$APP_PORT/", "||", "exit", "1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 40s

networks:
  web:
    external: true
COMPOSE
REMOTE

If no domain — determine how to expose the app:

  1. Check if this is the first app without a domain and no existing :80 Caddy site config:

    "${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" bash -seu <<'REMOTE'
    set -euo pipefail
    ls "$APPS_DIR/caddy/sites/" 2>/dev/null | grep -v '.caddy$' || true
    cat "$APPS_DIR"/caddy/sites/*.caddy 2>/dev/null | grep -c ':80' || echo 0
    REMOTE
    

    If no :80 config exists → route through Caddy on :80 (user accesses http://VPS_IP/). Use the docker-compose.yml without host port mapping (same as domain mode), and create a Caddy :80 site config in Step 4.10.

  2. Otherwise, auto-assign a host port:

    # Find next free port starting from 3001
    USED_PORTS=$("${SSH_CMD[@]}" -- "ss -tlnp | grep -oE ':[0-9]+' | tr -d ':' | sort -n | uniq")
    HOST_PORT=3001
    while echo "$USED_PORTS" | grep -q "^$HOST_PORT$"; do
      HOST_PORT=$((HOST_PORT + 1))
    done
    

    Generate docker-compose.yml with host port mapping:

        ports:
          - "$HOST_PORT:$APP_PORT"
    

Step 4.8: Create/Update .env on VPS

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" APP_PORT="$APP_PORT" bash -seu <<'REMOTE'
set -euo pipefail
# Create .env if it doesn't exist, ensure PORT is set
touch "$APPS_DIR/$APP_NAME/.env"
grep -q '^PORT=' "$APPS_DIR/$APP_NAME/.env" || echo "PORT=$APP_PORT" >> "$APPS_DIR/$APP_NAME/.env"
REMOTE

Step 4.9: Build and Start

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" bash -seu <<'REMOTE'
set -euo pipefail
cd "$APPS_DIR/$APP_NAME"
docker compose up -d --build --force-recreate
REMOTE

Show build progress. If the build takes more than a few seconds, inform the user it's building.

Step 4.10: Configure Caddy

If VPS_APP_DOMAIN is set:

Write a Caddy site config and reload:

[[ "$VPS_APP_DOMAIN" =~ ^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9-]{2,63}$ ]] || {
  echo "Invalid VPS_APP_DOMAIN"; exit 1;
}

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" APP_PORT="$APP_PORT" VPS_APP_DOMAIN="$VPS_APP_DOMAIN" bash -seu <<'REMOTE'
set -euo pipefail
cat > "$APPS_DIR/caddy/sites/$APP_NAME.caddy" <<CADDYCONF
$VPS_APP_DOMAIN {
    reverse_proxy $APP_NAME:$APP_PORT
    header {
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        Referrer-Policy strict-origin-when-cross-origin
        -Server
    }
}
CADDYCONF
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
REMOTE

See references/caddy-config-guide.md for WebSocket, static site, and multi-domain templates.

If no domain, first app (Caddy :80 route):

Route :80 traffic directly to the app via Caddy. The user accesses http://VPS_IP/.

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" APP_PORT="$APP_PORT" bash -seu <<'REMOTE'
set -euo pipefail
cat > "$APPS_DIR/caddy/sites/$APP_NAME.caddy" <<CADDYCONF
:80 {
    reverse_proxy $APP_NAME:$APP_PORT
    header {
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        Referrer-Policy strict-origin-when-cross-origin
        -Server
    }
}
CADDYCONF
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
REMOTE

If no domain, subsequent apps (host port mapping):

Skip Caddy config. The app is accessible at http://VPS_HOST:HOST_PORT via the port mapping from Step 4.7.

Tip: For HTTPS without buying a domain, set VPS_APP_DOMAIN=appname.YOUR_IP.sslip.io in .env. sslip.io resolves to the embedded IP, and Caddy will auto-provision a certificate.

Step 4.11: Health Check

Wait for the container to be healthy, then verify the app responds:

"${SSH_CMD[@]}" -- env APP_NAME="$APP_NAME" bash -seu <<'REMOTE'
set -euo pipefail
echo "Waiting for container to start..."
for i in \$(seq 1 12); do
  STATUS=\$(docker inspect --format='{{.State.Health.Status}}' "$APP_NAME" 2>/dev/null || echo "starting")
  if [ "\$STATUS" = "healthy" ]; then
    echo "Container is healthy"
    break
  fi
  if [ "\$STATUS" = "unhealthy" ]; then
    echo "Container is unhealthy"
    docker logs --tail 20 "$APP_NAME"
    exit 1
  fi
  sleep 5
done
REMOTE

Then verify the app responds:

# If domain is set:
curl -s -o /dev/null -w "%{http_code}" "https://$VPS_APP_DOMAIN" --max-time 10

# If no domain:
curl -s -o /dev/null -w "%{http_code}" "http://$VPS_HOST:$HOST_PORT" --max-time 10

Step 4.12: Report Result

On success:

Your app is live!

  • URL: https://{VPS_APP_DOMAIN} (or http://{VPS_HOST}:{PORT})
  • Container: {APP_NAME} (healthy)

Useful commands: "vps logs", "set vps env KEY=VALUE", "redeploy vps"

On failure:

Fetch container logs and analyze:

"${SSH_CMD[@]}" -- env APP_NAME="$APP_NAME" bash -seu <<'REMOTE'
set -euo pipefail
docker logs --tail 50 "$APP_NAME"
REMOTE

Analyze the logs and suggest specific fixes. See references/troubleshooting.md for common issues.

Section 5: Management Commands

For every command that accepts [app], normalize and validate first:

RAW_APP_NAME="$USER_INPUT_APP"
APP_NAME="$(normalize_app_name "$RAW_APP_NAME")"
validate_app_name "$APP_NAME" || { echo "Invalid app name: $RAW_APP_NAME"; exit 1; }

"show vps apps" / "list vps apps"

Enumerate deployed apps:

"${SSH_CMD[@]}" -- bash -seu <<'REMOTE'
set -euo pipefail
APPS_DIR=\$(grep '^APPS_DIR=' /etc/vps-deploy.conf 2>/dev/null | cut -d= -f2)
APPS_DIR=\${APPS_DIR:-/opt/apps}
echo "NAME|STATUS|IMAGE|PORTS"
for dir in \$APPS_DIR/*/docker-compose.yml; do
  app_dir=\$(dirname "\$dir")
  name=\$(basename "\$app_dir")
  [ "\$name" = "caddy" ] && continue
  status=\$(docker inspect --format='{{.State.Status}}' "\$name" 2>/dev/null || echo "not running")
  image=\$(docker inspect --format='{{.Config.Image}}' "\$name" 2>/dev/null || echo "-")
  ports=\$(docker inspect --format='{{range \$p, \$conf := .NetworkSettings.Ports}}{{\$p}}->{{(index \$conf 0).HostPort}} {{end}}' "\$name" 2>/dev/null || echo "-")
  echo "\$name|\$status|\$image|\$ports"
done
REMOTE

Display as a formatted table.

"vps logs [app]" / "show vps logs"

"${SSH_CMD[@]}" -- env APP_NAME="$APP_NAME" bash -seu <<'REMOTE'
set -euo pipefail
docker logs "$APP_NAME" --tail 100 --timestamps
REMOTE

If the user doesn't specify which app, list apps first and ask which one.

"set vps env KEY=VALUE" / "vps env set"

Parse key-value pairs from the user's request. Multiple vars can be set at once:

for pair in "${ENV_PAIRS[@]}"; do
  KEY="${pair%%=*}"
  VALUE="${pair#*=}"
  [[ "$KEY" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { echo "Invalid env key: $KEY"; exit 1; }
  [[ "$VALUE" != *$'\n'* ]] || { echo "Env value cannot contain newlines"; exit 1; }

  "${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" KEY="$KEY" VALUE="$VALUE" bash -seu <<'REMOTE'
set -euo pipefail
cd "$APPS_DIR/$APP_NAME"
touch .env
tmp_file="$(mktemp)"
awk -F= -v k="$KEY" -v v="$VALUE" '
BEGIN { updated=0 }
{
  if ($0 ~ /^[[:space:]]*#/ || index($0, "=") == 0) { print; next }
  cur=substr($0, 1, index($0, "=")-1)
  if (cur == k) { print k "=" v; updated=1; next }
  print
}
END { if (!updated) print k "=" v }
' .env > "$tmp_file"
mv "$tmp_file" .env
REMOTE
done

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" bash -seu <<'REMOTE'
set -euo pipefail
cd "$APPS_DIR/$APP_NAME"
docker compose up -d --force-recreate
REMOTE

After setting env vars, confirm and note the container was restarted.

"show vps env" / "vps env list"

Show env vars with values masked:

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" bash -seu <<'REMOTE'
set -euo pipefail
while IFS='=' read -r key value; do
  [ -z "\$key" ] && continue
  [[ "\$key" =~ ^# ]] && continue
  masked=\$(echo "\$value" | sed 's/./*/g')
  echo "\$key=\$masked"
done < "$APPS_DIR/$APP_NAME/.env"
REMOTE

"redeploy vps [app]"

Pull latest code and rebuild:

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" bash -seu <<'REMOTE'
set -euo pipefail
cd "$APPS_DIR/$APP_NAME/repo"
git fetch origin --depth 1
git reset --hard "origin/$(git rev-parse --abbrev-ref HEAD)"
cd "$APPS_DIR/$APP_NAME"
docker compose up -d --build --force-recreate
REMOTE

Then run health check (Step 4.11) and report result (Step 4.12).

"delete vps app [app]"

Always confirm before deleting. This is irreversible.

Are you sure you want to delete {app_name}? This will remove the container, Caddy config, and all app files from the VPS.

If confirmed:

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" bash -seu <<'REMOTE'
set -euo pipefail

BASE_DIR="$(realpath -m "$APPS_DIR")"
TARGET_DIR="$(realpath -m "$APPS_DIR/$APP_NAME")"
CADDY_FILE="$(realpath -m "$APPS_DIR/caddy/sites/$APP_NAME.caddy")"

[ -n "$APP_NAME" ] || { echo "APP_NAME is empty"; exit 1; }
[ "$BASE_DIR" != "/" ] || { echo "Refusing unsafe base directory"; exit 1; }
[ "$TARGET_DIR" != "$BASE_DIR" ] || { echo "Refusing to delete base directory"; exit 1; }
case "$TARGET_DIR" in
  "$BASE_DIR"/*) ;;
  *) echo "Refusing path traversal target: $TARGET_DIR"; exit 1 ;;
esac

cd "$TARGET_DIR"
docker compose down --rmi local --volumes 2>/dev/null || true
rm -f -- "$CADDY_FILE"
docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || true
rm -rf -- "$TARGET_DIR"
echo "Deleted $APP_NAME"
REMOTE

"vps status" / "server status"

Show VPS resource usage:

"${SSH_CMD[@]}" -- bash -seu <<'REMOTE'
set -euo pipefail
echo "=== System ==="
echo "Uptime: $(uptime -p)"
echo "Load: $(cat /proc/loadavg | cut -d' ' -f1-3)"
echo ""
echo "=== Memory ==="
free -h | grep -E "^(Mem|Swap):"
echo ""
echo "=== Disk ==="
df -h / | tail -1
echo ""
echo "=== Docker ==="
docker system df
echo ""
echo "=== Running Containers ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
REMOTE

"vps rollback [app]"

Restore the previous Docker image:

"${SSH_CMD[@]}" -- env APPS_DIR="$APPS_DIR" APP_NAME="$APP_NAME" bash -seu <<'REMOTE'
set -euo pipefail
cd "$APPS_DIR/$APP_NAME"
# Get the previous image
PREV_IMAGE=\$(docker images --format "{{.Repository}}:{{.Tag}}\t{{.CreatedAt}}" | grep "$APP_NAME" | sort -k2 -r | sed -n '2p' | cut -f1)
if [ -z "\$PREV_IMAGE" ]; then
  echo "No previous image found for $APP_NAME"
  exit 1
fi
echo "Rolling back to: \$PREV_IMAGE"
docker compose down
docker tag "\$PREV_IMAGE" "${APP_NAME}-${APP_NAME}:latest"
docker compose up -d
REMOTE

Section 6: Error Handling

Handle these errors with helpful user guidance:

| Error | Cause | What To Tell The User | |-------|-------|-----------------------| | No VPS_HOST in .env | Not configured | Add VPS_HOST, VPS_USER, VPS_SSH_KEY_PATH to .env | | No pinned SSH host key | Host key not pre-verified | Add host key to ~/.ssh/known_hosts and verify fingerprint out-of-band | | Host key fingerprint mismatch | MITM risk or wrong key | Stop immediately and verify the VPS host key in provider console | | SSH connection timeout | Wrong IP or firewall | Check VPS_HOST is correct and port 22 is open | | SSH permission denied | Key not authorized | Run ssh-copy-id or check VPS_SSH_KEY_PATH | | SSH host key changed | VPS reinstalled | Run ssh-keygen -R VPS_HOST and try again | | Invalid app name | Unsafe user input | Use only letters, numbers, and hyphens for app names | | Invalid domain | Unsafe or malformed VPS_APP_DOMAIN | Provide a valid FQDN (letters/numbers/hyphens and dots) | | No Dockerfile | Missing | Offer to auto-detect stack and create one from templates | | No git remote | Not pushed | Push your code to GitHub/GitLab first | | Git clone auth failure | Private repo | Guide through SSH deploy key setup (see Step 4.5) | | Docker build failure | Bad Dockerfile | Show build logs, analyze error, suggest fix. See references/troubleshooting.md | | Container exits immediately | Missing env var or bad CMD | Show docker logs, check required env vars | | Caddy reload failure | Bad config syntax | Show Caddy logs, validate config. See references/caddy-config-guide.md | | Domain not resolving | DNS not pointing to VPS | Tell user to add A record pointing domain to VPS_HOST IP | | Disk full | VPS storage exhausted | Run docker system prune -af to reclaim space | | Port already in use | Another app on same port | Choose a different host port or use Caddy routing with a domain |

Section 7: Notes

  • Direct SSH to your VPS — no middleware, no subscription, no third-party services.
  • BatchMode=yes always — SSH key auth required, never password prompts.
  • Host keys are pinned and verified (StrictHostKeyChecking=yes) before any remote command.
  • DNS must point to VPS IP before deploying with a domain (Caddy needs this for SSL).
  • Private repos need an SSH deploy key on the VPS or HTTPS token in the git URL.
  • App names and key config values are validated with strict allowlists before command execution.
  • Untrusted values are passed via env into quoted SSH heredocs (no direct string interpolation).
  • Deletion uses realpath boundary checks to prevent accidental removal outside $APPS_DIR.
  • Env vars are stored on the VPS at $APPS_DIR/{name}/.env — never committed to git.
  • Multiple apps share the Docker web network and Caddy reverse proxy.
  • Each app gets its own directory at $APPS_DIR/{name}/ with repo, compose file, and env.
  • The apps directory path is stored in /etc/vps-deploy.conf on the VPS — set on first setup, read automatically on subsequent runs.
  • Caddy handles SSL automatically via Let's Encrypt — no manual certificate management.
  • The managed-by=vps-deploy Docker label identifies apps deployed by this skill.
  • All setup scripts are idempotent — safe to re-run without side effects.
  • Ubuntu and Debian are supported. Other distros may work but are not tested.

Contract & API

Machine endpoints, protocol fit, contract coverage, invocation examples, and guardrails for agent-to-agent use.

MissingGITHUB OPENCLEW

Contract coverage

Status

missing

Auth

None

Streaming

No

Data region

Unspecified

Protocol support

OpenClaw: self-declared

Requires: none

Forbidden: none

Guardrails

Operational confidence: low

No positive guardrails captured.
Invocation examples
curl -s "https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/snapshot"
curl -s "https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/contract"
curl -s "https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/trust"

Reliability & Benchmarks

Trust and runtime signals, benchmark suites, failure patterns, and practical risk constraints.

Missingruntime-metrics

Trust signals

Handshake

UNKNOWN

Confidence

unknown

Attempts 30d

unknown

Fallback rate

unknown

Runtime metrics

Observed P50

unknown

Observed P95

unknown

Rate limit

unknown

Estimated cost

unknown

Do not use if

Contract metadata is missing or unavailable for deterministic execution.
No benchmark suites or observed failure patterns are available.

Media & Demo

Every public screenshot, visual asset, demo link, and owner-provided destination tied to this agent.

Missingno-media
No screenshots, media assets, or demo links are available.

Related Agents

Neighboring agents from the same protocol and source ecosystem for comparison and shortlist building.

Self-declaredprotocol-neighbors
GITHUB_REPOSactivepieces

Rank

70

AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents

Traction

No public download signal

Freshness

Updated 2d ago

OPENCLAW
GITHUB_REPOScherry-studio

Rank

70

AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs

Traction

No public download signal

Freshness

Updated 5d ago

MCPOPENCLAW
GITHUB_REPOSAionUi

Rank

70

Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!

Traction

No public download signal

Freshness

Updated 6d ago

MCPOPENCLAW
GITHUB_REPOSCopilotKit

Rank

70

The Frontend for Agents & Generative UI. React + Angular

Traction

No public download signal

Freshness

Updated 23d ago

OPENCLAW
Machine Appendix

Contract JSON

{
  "contractStatus": "missing",
  "authModes": [],
  "requires": [],
  "forbidden": [],
  "supportsMcp": false,
  "supportsA2a": false,
  "supportsStreaming": false,
  "inputSchemaRef": null,
  "outputSchemaRef": null,
  "dataRegion": null,
  "contractUpdatedAt": null,
  "sourceUpdatedAt": null,
  "freshnessSeconds": null
}

Invocation Guide

{
  "preferredApi": {
    "snapshotUrl": "https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/snapshot",
    "contractUrl": "https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/contract",
    "trustUrl": "https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/trust"
  },
  "curlExamples": [
    "curl -s \"https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/snapshot\"",
    "curl -s \"https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/contract\"",
    "curl -s \"https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/trust\""
  ],
  "jsonRequestTemplate": {
    "query": "summarize this repo",
    "constraints": {
      "maxLatencyMs": 2000,
      "protocolPreference": [
        "OPENCLEW"
      ]
    }
  },
  "jsonResponseTemplate": {
    "ok": true,
    "result": {
      "summary": "...",
      "confidence": 0.9
    },
    "meta": {
      "source": "GITHUB_OPENCLEW",
      "generatedAt": "2026-04-16T23:42:18.863Z"
    }
  },
  "retryPolicy": {
    "maxAttempts": 3,
    "backoffMs": [
      500,
      1500,
      3500
    ],
    "retryableConditions": [
      "HTTP_429",
      "HTTP_503",
      "NETWORK_TIMEOUT"
    ]
  }
}

Trust JSON

{
  "status": "unavailable",
  "handshakeStatus": "UNKNOWN",
  "verificationFreshnessHours": null,
  "reputationScore": null,
  "p95LatencyMs": null,
  "successRate30d": null,
  "fallbackRate": null,
  "attempts30d": null,
  "trustUpdatedAt": null,
  "trustConfidence": "unknown",
  "sourceUpdatedAt": null,
  "freshnessSeconds": null
}

Capability Matrix

{
  "rows": [
    {
      "key": "OPENCLEW",
      "type": "protocol",
      "support": "unknown",
      "confidenceSource": "profile",
      "notes": "Listed on profile"
    },
    {
      "key": "before",
      "type": "capability",
      "support": "supported",
      "confidenceSource": "profile",
      "notes": "Declared in agent profile metadata"
    },
    {
      "key": "output",
      "type": "capability",
      "support": "supported",
      "confidenceSource": "profile",
      "notes": "Declared in agent profile metadata"
    },
    {
      "key": "do",
      "type": "capability",
      "support": "supported",
      "confidenceSource": "profile",
      "notes": "Declared in agent profile metadata"
    },
    {
      "key": "be",
      "type": "capability",
      "support": "supported",
      "confidenceSource": "profile",
      "notes": "Declared in agent profile metadata"
    }
  ],
  "flattenedTokens": "protocol:OPENCLEW|unknown|profile capability:before|supported|profile capability:output|supported|profile capability:do|supported|profile capability:be|supported|profile"
}

Facts JSON

[
  {
    "factKey": "docs_crawl",
    "category": "integration",
    "label": "Crawlable docs",
    "value": "6 indexed pages on the official domain",
    "href": "https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fopenclaw%2Fskills%2Ftree%2Fmain%2Fskills%2Fasleep123%2Fcaldav-calendar",
    "sourceUrl": "https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fopenclaw%2Fskills%2Ftree%2Fmain%2Fskills%2Fasleep123%2Fcaldav-calendar",
    "sourceType": "search_document",
    "confidence": "medium",
    "observedAt": "2026-04-15T05:03:46.393Z",
    "isPublic": true
  },
  {
    "factKey": "vendor",
    "category": "vendor",
    "label": "Vendor",
    "value": "Bartek Filipiuk",
    "href": "https://github.com/bartek-filipiuk/vps-deploy-skill",
    "sourceUrl": "https://github.com/bartek-filipiuk/vps-deploy-skill",
    "sourceType": "profile",
    "confidence": "medium",
    "observedAt": "2026-04-14T22:26:04.417Z",
    "isPublic": true
  },
  {
    "factKey": "protocols",
    "category": "compatibility",
    "label": "Protocol compatibility",
    "value": "OpenClaw",
    "href": "https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/contract",
    "sourceUrl": "https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/contract",
    "sourceType": "contract",
    "confidence": "medium",
    "observedAt": "2026-04-14T22:26:04.417Z",
    "isPublic": true
  },
  {
    "factKey": "handshake_status",
    "category": "security",
    "label": "Handshake status",
    "value": "UNKNOWN",
    "href": "https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/trust",
    "sourceUrl": "https://xpersona.co/api/v1/agents/bartek-filipiuk-vps-deploy-skill/trust",
    "sourceType": "trust",
    "confidence": "medium",
    "observedAt": null,
    "isPublic": true
  }
]

Change Events JSON

[
  {
    "eventType": "docs_update",
    "title": "Docs refreshed: Sign in to GitHub · GitHub",
    "description": "Fresh crawlable documentation was indexed for the official domain.",
    "href": "https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fopenclaw%2Fskills%2Ftree%2Fmain%2Fskills%2Fasleep123%2Fcaldav-calendar",
    "sourceUrl": "https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Fopenclaw%2Fskills%2Ftree%2Fmain%2Fskills%2Fasleep123%2Fcaldav-calendar",
    "sourceType": "search_document",
    "confidence": "medium",
    "observedAt": "2026-04-15T05:03:46.393Z",
    "isPublic": true
  }
]

Sponsored

Ads related to vps-deploy and adjacent AI workflows.