| name | vps-deploy |
| description | Deploy applications to VPS servers with automatic runtime detection, reverse proxy setup, SSL, and rollback support.
Trigger phrases: "deploy", "sunucuya gonder", "VPS'e kur", "push to server", "release to production"
|
VPS Deploy
Deploy any application to a VPS with Docker or bare metal, including reverse proxy, SSL, health checks, and rollback.
Overview
This skill handles the full deployment lifecycle:
- Analyze the project to detect runtime and choose deploy method
- Read or create deploy configuration
- Set up reverse proxy (Nginx or Caddy)
- Configure SSL certificates
- Deploy the application (Docker or bare metal)
- Verify deployment with health checks
- Rollback if something goes wrong
- Generate
deploy.sh and update.sh scripts for one-click future deploys
Pre-Deploy Checklist
Step 1: Project Analysis
Detect the project type and choose the deploy method.
Detection logic:
if [ -f Dockerfile ] || [ -f docker-compose.yml ]; then
METHOD="docker"
elif [ -f package.json ]; then
METHOD="bare"
RUNTIME="node"
elif [ -f requirements.txt ] || [ -f pyproject.toml ] || [ -f Pipfile ]; then
METHOD="bare"
RUNTIME="python"
elif [ -f go.mod ]; then
METHOD="bare"
RUNTIME="go"
else
METHOD="unknown"
fi
Rules:
- If
method: auto in config, use detection logic above
- If
method: docker, use Docker regardless of Dockerfile presence (create one if missing)
- If
method: bare, use bare metal deploy with runtime-specific process manager
- If no runtime detected and method is auto, ask the user
Step 2: Configuration
Read .deploy.yml from the project root. If it does not exist, ask the user for each value.
Config format (.deploy.yml):
host: 192.168.1.100
user: deploy
ssh_key: ~/.ssh/id_rsa
app_port: 3000
health_check: /api/health
deploy_path: /var/www/myapp
method: auto
domain: myapp.example.com
ssl: true
reverse_proxy: auto
Field descriptions:
host — VPS IP address or hostname (required)
user — SSH username (required)
ssh_key — path to SSH private key (default: ~/.ssh/id_rsa)
app_port — port the application listens on (required)
health_check — HTTP path for health verification (default: /)
deploy_path — absolute path on server where app is deployed (required)
method — deploy method: auto detects, docker forces Docker, bare forces bare metal
domain — domain name for reverse proxy and SSL (optional, uses IP if not set)
ssl — enable SSL certificate provisioning (default: true if domain is set)
reverse_proxy — auto detects installed proxy, or force nginx / caddy
Verify SSH connectivity:
ssh -i $SSH_KEY -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new $USER@$HOST "echo 'SSH connection OK'"
Step 3: Reverse Proxy
Auto-detect which reverse proxy is installed on the server, then generate the appropriate config.
Detection:
ssh -i $SSH_KEY $USER@$HOST "command -v caddy && echo 'CADDY' || (command -v nginx && echo 'NGINX' || echo 'NONE')"
Rules:
- If
reverse_proxy: auto — use Caddy if installed, otherwise Nginx. Install Nginx if neither is found.
- If
reverse_proxy: nginx — use Nginx, install if missing.
- If
reverse_proxy: caddy — use Caddy, install if missing.
Nginx Config Template
ssh -i $SSH_KEY $USER@$HOST "cat > /etc/nginx/sites-available/$APP_NAME" << 'NGINX_CONF'
server {
listen 80;
server_name DOMAIN_PLACEHOLDER;
location / {
proxy_pass http://127.0.0.1:APP_PORT_PLACEHOLDER;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
NGINX_CONF
ssh -i $SSH_KEY $USER@$HOST "ln -sf /etc/nginx/sites-available/$APP_NAME /etc/nginx/sites-enabled/$APP_NAME"
ssh -i $SSH_KEY $USER@$HOST "nginx -t && systemctl reload nginx"
Caddy Config Template
ssh -i $SSH_KEY $USER@$HOST "cat >> /etc/caddy/Caddyfile" << 'CADDY_CONF'
DOMAIN_PLACEHOLDER {
reverse_proxy 127.0.0.1:APP_PORT_PLACEHOLDER
}
CADDY_CONF
ssh -i $SSH_KEY $USER@$HOST "systemctl reload caddy"
Replace DOMAIN_PLACEHOLDER with the domain value and APP_PORT_PLACEHOLDER with app_port.
Step 4: SSL Certificates
Caddy (Automatic)
Caddy handles SSL automatically. No extra steps needed. When the domain is configured in the Caddyfile, Caddy obtains and renews Let's Encrypt certificates on its own.
Nginx (Certbot)
ssh -i $SSH_KEY $USER@$HOST "command -v certbot || (apt-get update && apt-get install -y certbot python3-certbot-nginx)"
ssh -i $SSH_KEY $USER@$HOST "certbot --nginx -d $DOMAIN --non-interactive --agree-tos -m admin@$DOMAIN"
ssh -i $SSH_KEY $USER@$HOST "systemctl enable certbot.timer && systemctl start certbot.timer"
ssh -i $SSH_KEY $USER@$HOST "certbot renew --dry-run"
Rules:
- Skip SSL if
ssl: false or no domain is configured
- Never proceed with SSL for bare IP addresses
- Verify DNS resolves to the server IP before requesting a certificate
RESOLVED_IP=$(dig +short $DOMAIN)
if [ "$RESOLVED_IP" != "$HOST" ]; then
echo "WARNING: $DOMAIN resolves to $RESOLVED_IP, not $HOST. SSL will fail."
fi
Step 5: Deploy
5a. Backup Current Version
Always backup before deploying.
ssh -i $SSH_KEY $USER@$HOST "mkdir -p $DEPLOY_PATH/releases"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
ssh -i $SSH_KEY $USER@$HOST "
if [ -d $DEPLOY_PATH/current ]; then
cp -a $DEPLOY_PATH/current $DEPLOY_PATH/releases/$TIMESTAMP
echo 'Backup created: releases/$TIMESTAMP'
else
echo 'No current version to backup (first deploy)'
fi
"
ssh -i $SSH_KEY $USER@$HOST "
cd $DEPLOY_PATH/releases && ls -dt */ | tail -n +4 | xargs -r rm -rf
"
5b. Docker Deploy
docker build -t $APP_NAME:latest .
docker save $APP_NAME:latest | gzip > /tmp/$APP_NAME.tar.gz
scp -i $SSH_KEY /tmp/$APP_NAME.tar.gz $USER@$HOST:/tmp/
ssh -i $SSH_KEY $USER@$HOST "
# Load the image
docker load < /tmp/$APP_NAME.tar.gz
# Stop existing container if running
docker stop $APP_NAME 2>/dev/null || true
docker rm $APP_NAME 2>/dev/null || true
# Run the new container
docker run -d \
--name $APP_NAME \
--restart unless-stopped \
-p 127.0.0.1:$APP_PORT:$APP_PORT \
$APP_NAME:latest
# Clean up
rm /tmp/$APP_NAME.tar.gz
"
rm /tmp/$APP_NAME.tar.gz
5c. Bare Metal — Node.js with PM2
rsync -avz --delete \
--exclude node_modules \
--exclude .git \
--exclude .env.local \
-e "ssh -i $SSH_KEY" \
./ $USER@$HOST:$DEPLOY_PATH/current/
ssh -i $SSH_KEY $USER@$HOST "
cd $DEPLOY_PATH/current
# Install dependencies
npm ci --production
# Install PM2 globally if not present
command -v pm2 || npm install -g pm2
# Detect start script
START_SCRIPT=\$(node -e \"console.log(require('./package.json').scripts.start || 'node index.js')\")
# Stop existing process if running
pm2 stop $APP_NAME 2>/dev/null || true
pm2 delete $APP_NAME 2>/dev/null || true
# Start the application
pm2 start ecosystem.config.js --name $APP_NAME 2>/dev/null || \
pm2 start npm --name $APP_NAME -- start
# Save PM2 process list and set up startup script
pm2 save
pm2 startup systemd -u $USER --hp /home/$USER 2>/dev/null || true
"
5d. Bare Metal — Python with systemd
rsync -avz --delete \
--exclude __pycache__ \
--exclude .git \
--exclude .venv \
--exclude .env.local \
-e "ssh -i $SSH_KEY" \
./ $USER@$HOST:$DEPLOY_PATH/current/
ssh -i $SSH_KEY $USER@$HOST "
cd $DEPLOY_PATH/current
# Create/update virtualenv
python3 -m venv venv
./venv/bin/pip install -r requirements.txt 2>/dev/null || ./venv/bin/pip install -e . 2>/dev/null
# Detect the entry point
if [ -f manage.py ]; then
EXEC_CMD='$DEPLOY_PATH/current/venv/bin/gunicorn --bind 127.0.0.1:$APP_PORT --workers 3 config.wsgi:application'
elif [ -f app.py ] || [ -f main.py ]; then
ENTRY=\$([ -f app.py ] && echo app.py || echo main.py)
EXEC_CMD='$DEPLOY_PATH/current/venv/bin/python \$ENTRY'
fi
# Write systemd service file
sudo tee /etc/systemd/system/$APP_NAME.service > /dev/null << SERVICE
[Unit]
Description=$APP_NAME
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$DEPLOY_PATH/current
ExecStart=\$EXEC_CMD
Restart=always
RestartSec=5
Environment=PORT=$APP_PORT
[Install]
WantedBy=multi-user.target
SERVICE
# Enable and restart the service
sudo systemctl daemon-reload
sudo systemctl enable $APP_NAME
sudo systemctl restart $APP_NAME
"
5e. Bare Metal — Go with systemd
GOOS=linux GOARCH=amd64 go build -o /tmp/$APP_NAME .
scp -i $SSH_KEY /tmp/$APP_NAME $USER@$HOST:$DEPLOY_PATH/current/$APP_NAME
ssh -i $SSH_KEY $USER@$HOST "
chmod +x $DEPLOY_PATH/current/$APP_NAME
# Write systemd service file
sudo tee /etc/systemd/system/$APP_NAME.service > /dev/null << SERVICE
[Unit]
Description=$APP_NAME
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$DEPLOY_PATH/current
ExecStart=$DEPLOY_PATH/current/$APP_NAME
Restart=always
RestartSec=5
Environment=PORT=$APP_PORT
[Install]
WantedBy=multi-user.target
SERVICE
# Enable and restart the service
sudo systemctl daemon-reload
sudo systemctl enable $APP_NAME
sudo systemctl restart $APP_NAME
"
Step 6: Health Check
Run immediately after deploy. Wait up to 30 seconds for the service to become healthy.
HEALTH_URL="http://127.0.0.1:$APP_PORT$HEALTH_CHECK"
MAX_RETRIES=6
RETRY_DELAY=5
ssh -i $SSH_KEY $USER@$HOST "
for i in \$(seq 1 $MAX_RETRIES); do
STATUS=\$(curl -s -o /dev/null -w '%{http_code}' $HEALTH_URL 2>/dev/null)
if [ \"\$STATUS\" = '200' ]; then
echo 'Health check PASSED (HTTP 200)'
exit 0
fi
echo \"Attempt \$i/$MAX_RETRIES: HTTP \$STATUS — retrying in ${RETRY_DELAY}s...\"
sleep $RETRY_DELAY
done
echo 'Health check FAILED after $MAX_RETRIES attempts'
exit 1
"
Process verification:
ssh -i $SSH_KEY $USER@$HOST "
if [ '$METHOD' = 'docker' ]; then
docker ps --filter name=$APP_NAME --format '{{.Status}}'
else
# Check systemd or PM2
systemctl is-active $APP_NAME 2>/dev/null || pm2 show $APP_NAME 2>/dev/null | grep status
fi
"
If health check fails, trigger rollback automatically.
Step 7: Rollback
Restore the most recent backup from releases/ and restart the service.
LATEST_RELEASE=$(ssh -i $SSH_KEY $USER@$HOST "ls -dt $DEPLOY_PATH/releases/*/ 2>/dev/null | head -1")
if [ -z "$LATEST_RELEASE" ]; then
echo "ERROR: No release backups found. Cannot rollback."
exit 1
fi
echo "Rolling back to: $LATEST_RELEASE"
ssh -i $SSH_KEY $USER@$HOST "
# Restore the backup
rm -rf $DEPLOY_PATH/current
cp -a $LATEST_RELEASE $DEPLOY_PATH/current
# Restart the service
if [ '$METHOD' = 'docker' ]; then
# For Docker: the backup contains the image tag info
docker stop $APP_NAME 2>/dev/null || true
docker rm $APP_NAME 2>/dev/null || true
# Re-run from the previous image (tagged during backup)
docker run -d --name $APP_NAME --restart unless-stopped \
-p 127.0.0.1:$APP_PORT:$APP_PORT \
$APP_NAME:previous
else
# For bare metal: restart the service
sudo systemctl restart $APP_NAME 2>/dev/null || pm2 restart $APP_NAME 2>/dev/null
fi
echo 'Rollback complete.'
"
Step 8: Generate Deploy Scripts
After a successful deployment, generate project-specific deploy.sh and update.sh scripts so the user can deploy and update with a single command.
Always ask the user: "Deploy başarılı. Gelecekte tek komutla deploy/update yapabilmeniz için deploy.sh ve update.sh scriptlerini oluşturmamı ister misiniz?"
Why Project-Specific?
Generic scripts cannot cover all cases. Every project has different:
- Build steps (npm build, go build, pip install, docker compose, custom Makefile)
- Runtime configurations (environment variables, config files, .env handling)
- Process managers (PM2 with ecosystem.config.js, systemd with custom unit, Docker with specific flags)
- Dependency installation (npm ci, pip install, bundle install, go mod download)
- File exclusions (project-specific directories like uploads/, data/, logs/)
- Pre/post deploy hooks (database migrations, cache clearing, asset compilation)
DO NOT use a generic template. Analyze the actual project and generate scripts tailored to it.
How to Generate
- Analyze the project — read package.json scripts, Dockerfile, Makefile, existing CI/CD configs, docker-compose.yml, .env.example, etc.
- Identify the exact build/start commands this project uses
- Check for project-specific needs:
- Does it need a build step? (
npm run build, go build -o app, asset compilation)
- Does it have database migrations? (add migration step to deploy)
- Does it use environment variables? (handle .env file sync)
- Does it have a custom start command? (check package.json
start, Procfile, etc.)
- Does it use docker-compose? (use
docker compose up -d instead of plain docker)
- Does it have pre/post deploy hooks? (test suites, seed scripts)
- Generate two scripts customized to this project
deploy.sh — First-Time Full Setup + Deploy
Must include (adapted to the project):
- Config loading from
.deploy.yml
- SSH verification
- Reverse proxy setup (Nginx or Caddy — based on what was used in Step 3)
- SSL setup (based on what was used in Step 4)
- Backup current version to
releases/ with timestamp
- Project-specific build commands
- Transfer and deployment (Docker or bare metal — based on Step 1 detection)
- Project-specific dependency installation
- Project-specific process start/restart
- Health check with retry loop
- Automatic rollback on failure
- Release pruning (keep last 3)
update.sh — Quick Code Update + Restart
Must include (adapted to the project):
- Config loading from
.deploy.yml
- Backup current version
- Sync code to server (rsync or docker build+transfer)
- Project-specific dependency installation (only if needed)
- Project-specific build step (if applicable)
- Process restart
- Health check with retry loop
- Automatic rollback on failure
Script Structure Template
Use this as a structural guide, but fill in project-specific commands:
#!/usr/bin/env bash
set -euo pipefail
Script Generation Rules
- Always set executable permission:
chmod +x deploy.sh update.sh
- Add to .gitignore consideration: Ask the user if
.deploy.yml should be in .gitignore (it may contain server credentials)
- Never overwrite existing scripts without user confirmation
- If the user already has
deploy.sh or update.sh, show a diff and ask before replacing
- Scripts must read config from
.deploy.yml — no hardcoded server credentials
- Include comments explaining each project-specific decision so the user can modify later
- Test the scripts mentally — trace through each command and verify it would work for this specific project
Step 9: Safety Rules
These rules are mandatory and must never be skipped:
-
Never deploy without user confirmation. Always show the deploy plan (method, target host, domain, app port) and wait for explicit approval before executing.
-
Always backup before deploy. The current version must be copied to releases/ with a timestamp before any deployment begins. If backup fails, abort the deploy.
-
Always run health check after deploy. Every deployment must be followed by an HTTP health check and process verification. If the health check fails, trigger automatic rollback.
-
Keep the last 3 releases. After a successful deploy, prune the releases/ directory to keep only the 3 most recent backups. Never delete all backups.
-
Never deploy to production without knowing the target. Always confirm the host, deploy path, and domain with the user. Double-check if the host looks like a production server.
-
Always verify SSH access first. Test the SSH connection before starting any deploy operation. Fail fast if the connection cannot be established.
-
Never expose app ports directly. The application should bind to 127.0.0.1 and be accessible only through the reverse proxy.
-
SSL is required when a domain is configured. If domain is set and ssl is not explicitly false, always set up SSL.