| name | deploy-verify |
|---|---|
| description | Production deployment closed-loop verification workflow. Ensures zero-surprise deployments with a mandatory 7-step process: pre-deploy checks, backup, deploy (with atomic swap and sensitive file exclusion), file integrity verification (SHA-256 diff), functional verification (HTTP + page checks), log monitoring (with secret redaction), and team notification. Covers database migrations, cache clearing, rollback with security review, and cross-OS permission fixes. Works with any web stack (PHP, Node.js, Python, Go, etc.) and any deployment method (tar, rsync, scp, CI/CD). Use this skill whenever deploying code to a remote server, syncing files to production, pushing to a live environment, or when the user says "deploy", "sync to server", "push to production", "upload to server", "go live", or any variation. Also use proactively after making code changes that should be deployed. If something goes wrong in production and you suspect a deployment issue (missing files, old versions, permission errors), use this skill to diagnose. |
A battle-tested 7-step workflow that ensures every deployment to a production server is complete, verified, and monitored. Born from real incidents where "it's deployed" turned out to mean "only 3 of 18 changed files actually made it to the server."
Note: This document is written in English for AI agent consumption. When communicating deployment status or issues to the user, use their preferred language.
If the project already has deployment scripts (e.g., deploy.sh, deploy-prod.sh),
use them instead of running raw commands. These scripts encode project-specific
knowledge (correct SSH flags, server paths, permission fixes) that this generic
workflow cannot cover.
Use this skill to:
- Verify that existing scripts follow the 7-step pattern
- Fill gaps where scripts skip steps (e.g., missing integrity verification)
- Guide manual deployment when no script exists
- Diagnose failures when something goes wrong after scripted deployment
PRE-CHECK → BACKUP → DEPLOY → VERIFY FILES → VERIFY FUNCTION → MONITOR LOGS → NOTIFY
↓ ↓ ↓ ↓ ↓ ↓ ↓
Syntax Snapshot Transfer SHA-256 HTTP 200 No new errors Team
+ scope + offsite + perms diff = 0 + pages work for 2-5 min aware
+ build + rotate + caches
Every step must pass before proceeding to the next. If any step fails, stop and fix before continuing.
NEVER hardcode passwords, API keys, or tokens in deployment scripts or source code.
-
SSH key authentication (strongly recommended):
ssh-keygen -t ed25519 -C "deploy-key" ssh-copy-id -i ~/.ssh/id_ed25519.pub -p PORT root@SERVER # Then scripts need no password at all
-
Password file (minimum acceptable):
# Store password outside the repo, mode 600, secure cleanup echo 'your-password' > ~/.deploy_pw && chmod 600 ~/.deploy_pw sshpass -f ~/.deploy_pw ssh ... # After deploy session: shred -u ~/.deploy_pw
-
Environment variables (for CI/CD):
export DEPLOY_PASS="..." # set in CI secrets, never in repo sshpass -p "$DEPLOY_PASS" ssh ...
# .gitignore — block credential files
*.sshpw
*.pem
*.key
.env*Audit: Run grep -rn 'sshpass -p' *.sh periodically to find hardcoded passwords.
Before touching the server, verify locally that the code is ready.
Run language-specific syntax checks on changed files:
# PHP
find app config core -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors"
# Node.js / TypeScript
npx tsc --noEmit # or: node -c file.js
# Python
python -m py_compile file.py # or: python -m compileall .
# Go
go build ./... && go vet ./...Understand what you're deploying and its blast radius:
| What changed | Impact | Extra steps needed |
|---|---|---|
| Routes / middleware | Global — all pages affected | Test all page types |
| Templates / views | Specific pages affected | Test affected pages |
| Frontend JS/CSS | Need rebuild/bundle | Run build before deploy |
| Database schema | May need migration | See Step 3.5 below |
| Config files | May need env var updates | Check server .env |
| Core framework | Everything affected | Full verification |
npm run build # or: node scripts/build.js
ls -la dist/ # verify timestamps updatedgit diff --stat HEAD~1 # what changed in the last commit
git log --oneline -5 # recent commit contextAlways have a rollback path. Store backups in at least TWO locations.
# Primary: on the server (fast restore)
ssh SERVER "tar czf /root/backup_$(date +%Y%m%d_%H%M%S).tar.gz \
-C /path/to/site app/ config/ public/ core/ resources/"
# Secondary: pull a copy locally (disaster recovery)
scp SERVER:/root/backup_LATEST.tar.gz ~/backups/server_$(date +%Y%m%d).tar.gz# Keep only last 10 backups on server
ssh SERVER "ls -t /root/backup_*.tar.gz | tail -n +11 | xargs -r rm"# MySQL
ssh SERVER "mysqldump -u USER -pPASS DB_NAME > /root/db_backup_$(date +%Y%m%d_%H%M%S).sql"
# PostgreSQL
ssh SERVER "pg_dump -U USER DB_NAME > /root/db_backup_$(date +%Y%m%d_%H%M%S).sql"# tar method (recommended)
tar czf - \
--exclude='.env' --exclude='.env.*' \
--exclude='*.pem' --exclude='*.key' --exclude='*.p12' \
--exclude='storage/database/*.sqlite' \
--exclude='storage/logs/*' \
--exclude='storage/sessions/*' \
--exclude='node_modules' --exclude='vendor' \
app/ config/ core/ public/ resources/ scripts/ \
| ssh SERVER "cd /path/to/site && tar xzf - --no-same-owner --no-same-permissions"
# rsync method
rsync -avz --chmod=D755,F644 \
--exclude='.env' --exclude='.env.*' --exclude='*.pem' --exclude='*.key' \
--exclude='storage/logs/*' --exclude='storage/sessions/*' \
-e "ssh -p PORT" app/ config/ public/ root@SERVER:/path/to/site/Files to NEVER deploy (server-specific):
.env— database credentials, API keys, environment flags*.pem,*.key— SSL certificates, private keysstorage/database/— production database filesstorage/logs/,storage/sessions/— runtime datavendor/,node_modules/— install from lock file on server
Network interruptions can leave the server in an inconsistent state.
# Safer: deploy to staging directory first, then swap
ssh SERVER "mkdir -p /tmp/deploy_staging"
tar czf - --exclude='.env' app/ config/ | \
ssh SERVER "cd /tmp/deploy_staging && tar xzf - --no-same-owner"
# Only after full extraction succeeds, copy to production
ssh SERVER "rsync -a /tmp/deploy_staging/ /path/to/site/ && rm -rf /tmp/deploy_staging"When deploying from macOS/Windows to Linux, file ownership gets mangled.
# Fix ownership — use the correct web server user
ssh SERVER "chown -R www:www /path/to/site/app/ /path/to/site/config/ /path/to/site/public/"
# Fix file permissions — distinguish scripts from code files
ssh SERVER "find /path/to/site/app /path/to/site/config /path/to/site/resources \
-type f -not -name '*.sh' -not -name '.user.ini' -perm 600 | xargs -r chmod 644"
ssh SERVER "find /path/to/site/scripts -type f -name '*.sh' | xargs -r chmod 750"
ssh SERVER "find /path/to/site -type d | xargs -r chmod 755"Local php -l may pass but server may fail due to different PHP version or extensions.
# Verify changed PHP files parse correctly on the server
ssh SERVER "php -l /path/to/site/app/Controllers/ChangedFile.php"
# Batch check all recently deployed files
ssh SERVER "find /path/to/site/app /path/to/site/config -name '*.php' -newer /root/backup_LATEST.tar.gz -exec php -l {} \;" 2>&1 | grep -v "No syntax errors"Choose the restart method based on what changed and your infrastructure:
What did you change?
├── .php files ──────────────→ restart (clears OPcache)
├── .conf / .ini / php-fpm ──→ restart
├── Static assets only ──────→ reload (no OPcache involved)
└── Any of the above, BUT
server behind CDN? ──────→ USR2 signal (graceful, no 520)
+ opcache_reset() separately
# Option A: Full restart (clears OPcache, brief downtime)
# Use when: standalone server, no CDN/proxy in front, changed .php files
ssh SERVER "/etc/init.d/php-fpm restart"
# Option B: Graceful reload via USR2 (no downtime)
# Use when: server behind Cloudflare/CDN where restart causes 520/502
ssh SERVER "kill -USR2 $(cat /run/php-fpm.pid)"
# Clear OPcache separately (USR2 does NOT clear it):
ssh SERVER "php -r 'opcache_reset();'"
# Option C: Reload (does NOT clear OPcache)
# Use when: only static assets changed (CSS, JS, images)
ssh SERVER "systemctl reload php-fpm"
# Node.js (PM2)
ssh SERVER "pm2 restart app-name"
# Python (gunicorn)
ssh SERVER "systemctl restart gunicorn"Warning: If your server is behind Cloudflare or a reverse proxy, a full
restartcreates a brief window triggering 520 errors. Use Option B or graceful restart.
# FastCGI cache (nginx)
ssh SERVER "find /www/server/fastcgi_cache -type f -delete 2>/dev/null; nginx -s reload"
# Varnish
ssh SERVER "varnishadm 'ban req.url ~ .'"
# CDN cache (Cloudflare)
curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache" \
-H "Authorization: Bearer TOKEN" -d '{"purge_everything":true}'
# Redis application cache
ssh SERVER "redis-cli FLUSHDB" # or selective: redis-cli DEL key_patternDatabase schema changes must be coordinated with code deployment.
| Change type | Order | Rationale |
|---|---|---|
| Add nullable column | Migrate FIRST, then deploy | Old code ignores new column |
| Add NOT NULL column | Deploy code (with default) FIRST | Old DB won't have column |
| Drop column | Deploy code that stops using it FIRST | Old code would crash |
| Add index | Migrate FIRST (can be done live) | No code dependency |
| Drop table | Deploy code FIRST, then migrate | Old code references it |
| Rename column | Two-step: add new → deploy → copy → drop old | Atomic rename breaks old code |
# Backup database FIRST (see Step 2)
# Run migration
ssh SERVER "cd /path/to/site && php artisan migrate"
# or: ssh SERVER "mysql -u USER -pPASS DB_NAME < migration.sql"
# Verify
ssh SERVER "mysql -u USER -pPASS DB_NAME -e 'DESCRIBE table_name;'"# Reverse migration (preferred — preserves new data)
ssh SERVER "cd /path/to/site && php artisan migrate:rollback"
# Full DB restore (loses data created after backup — last resort)
ssh SERVER "mysql -u USER -pPASS DB_NAME < /root/db_backup_XXXXXXXX.sql"Warning: If the migration ran in production and users created new data, restoring from backup will LOSE that data. Prefer reverse migrations.
This is the step most people skip — and it's the most important one.
# Detect local checksum command
if command -v sha256sum &>/dev/null; then
SUMCMD="sha256sum"
elif command -v shasum &>/dev/null; then
SUMCMD="shasum -a 256"
else
echo "ERROR: No SHA-256 tool found"; exit 1
fi
# Local checksums
find app config core bootstrap resources public/assets scripts \
-type f \( -name "*.php" -o -name "*.js" -o -name "*.css" -o -name "*.html" \
-o -name "*.json" -o -name "*.sh" -o -name "*.py" -o -name "*.go" \) \
-not -name ".env" -not -name "*.key" -not -name "*.pem" \
-exec $SUMCMD {} \; | awk '{print $1, $NF}' | sort -k2 > /tmp/local_checksums.txt
# Remote checksums (Linux)
ssh SERVER "cd /path/to/site && find app config core bootstrap resources public/assets scripts \
-type f \( -name '*.php' -o -name '*.js' -o -name '*.css' -o -name '*.html' \
-o -name '*.json' -o -name '*.sh' -o -name '*.py' -o -name '*.go' \) \
-not -name '.env' -not -name '*.key' -not -name '*.pem' \
-exec sha256sum {} \;" | awk '{print $1, $NF}' | sort -k2 > /tmp/remote_checksums.txt
# Compare — must show 0 differences
diff /tmp/local_checksums.txt /tmp/remote_checksums.txtNote: Using $NF (last field) instead of $2 handles filenames with spaces.
- Same file, different hash → old version on server, re-deploy
- Local only → missing on server, re-sync
- Remote only → server-generated file, usually fine
Rule: don't declare "deployed" until the diff count is 0.
- Every deploy: verify all changed files (minimum, non-negotiable)
- Weekly: forced full SHA-256 sweep of all files, regardless of deploy count
- User reports missing feature: full sweep immediately
- First deploy to new server: full sweep mandatory
- After multiple incremental deploys in a day: full sweep at end of day
Files being present doesn't mean the app works. Test it.
# Use HEAD requests to avoid triggering business logic or polluting analytics
ssh SERVER "curl -s -I -o /dev/null -w '%{http_code}' \
-H 'Host: example.com' http://localhost/"
# Expected: 200
# Health endpoint (if available — preferred over page checks)
ssh SERVER "curl -s http://localhost/health | \
python3 -c 'import sys,json; d=json.load(sys.stdin); print(\"OK\" if d.get(\"status\")==\"ok\" else \"FAIL\")'"| Change type | What to test |
|---|---|
| Middleware / routing | Every major page type (home, detail, list, API) |
| Admin templates | Admin panel login + all menu sections visible |
| Frontend JS/CSS | Hard refresh, check browser console for errors |
| API endpoints | curl each changed endpoint, verify response format |
| Database changes | Test queries that use changed tables/columns |
| Auth / session | Login flow, protected pages, logout |
After deployment, watch for new errors for 2-5 minutes.
- Minimum: 2 minutes of active monitoring
- Routing/middleware changes: 5 minutes
- Database migrations: 15 minutes
Security note: Error logs may contain database passwords, API keys, and user PII. Always redact sensitive data when viewing logs, especially during screen sharing.
# Redacted log monitoring — strips passwords, tokens, and keys
ssh SERVER "tail -50 /path/to/site/storage/logs/error.log 2>/dev/null" | \
grep -i "error\|fatal\|exception" | \
sed 's/password=[^ ]*/password=***REDACTED***/gi' | \
sed 's/Bearer [A-Za-z0-9._-]*/Bearer ***REDACTED***/g' | \
sed "s/token=[^ &'\"]*/token=***REDACTED***/gi"
# Or just count errors (zero information leakage)
ssh SERVER "tail -50 /path/to/site/storage/logs/error.log 2>/dev/null" | \
grep -ci "error\|fatal\|exception"
# Nginx error log
ssh SERVER "tail -20 /var/log/nginx/error.log 2>/dev/null" | grep -v "favicon"- No new errors → Deployment successful
- New errors related to your changes → Fix immediately or rollback
- Pre-existing errors → Note them, don't block on them
For team environments or critical deployments, send a notification.
# Telegram
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d chat_id="${CHAT_ID}" \
-d text="Deploy to ${SERVER} completed at $(date). Status: OK"
# Slack
curl -s -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"Deploy to ${SERVER} completed at $(date)\"}"Even for solo developers, a notification creates an audit trail.
Security warning: Rolling back to an older version may re-introduce security vulnerabilities that were fixed in the deployment you're reverting. If the current deployment was a security fix, prefer hot-patching the old version instead.
ssh SERVER "ls -lt /root/backup_*.tar.gz | head -5"ssh SERVER "cd /path/to/site && \
tar xzf /root/backup_YYYYMMDD_HHMMSS.tar.gz --no-same-owner --no-same-permissions && \
chown -R www:www app/ config/ public/ core/ resources/ && \
find . -type f -perm 600 -not -name '.user.ini' | xargs -r chmod 644"ssh SERVER "/etc/init.d/php-fpm restart"
ssh SERVER "find /www/server/fastcgi_cache -type f -delete 2>/dev/null; nginx -s reload"
ssh SERVER "redis-cli FLUSHDB 2>/dev/null"Run Step 5 (Verify Function) and Step 6 (Monitor Logs).
ssh SERVER "echo '[ROLLBACK] $(date): rolled back to backup_XXXXXXXX. Reason: ...' >> /var/log/deploy_audit.log"When only one or a few files caused the issue, a full tar restore is overkill.
# If you backed up individual files before deploying
ssh SERVER "cp /path/to/site/app/Controller.php.bak /path/to/site/app/Controller.php"
ssh SERVER "chown www:www /path/to/site/app/Controller.php"
# Or restore a single file from the tar backup
ssh SERVER "tar xzf /root/backup_XXXXXXXX.tar.gz -C /path/to/site app/Controller.php"
# Batch single-file rollback (multiple files from same backup)
ssh SERVER "tar xzf /root/backup_XXXXXXXX.tar.gz -C /path/to/site \
app/Controller.php config/routes.php public/assets/js/dist/app.min.js"After single-file rollback, still restart the application server and verify.
If migrations were run, code rollback alone is NOT enough. See Step 3.5 for database-specific rollback strategies.
- Check
git logfor the last known-good commit git checkout <good-commit>locally- Re-deploy through the full 7-step workflow
When deploying to multiple servers, deploy in order of least risk:
- Deploy to the least critical server first
- Run full verification (Steps 4-6)
- Wait 5-10 minutes, monitor for error spikes
- If clean, deploy to remaining servers one at a time
- If issues found, fix before proceeding
| Item | Staging | Production |
|---|---|---|
| .env | Test DB, debug=true | Prod DB, debug=false |
| Error display | Detailed errors | Log only |
| Caches | Disabled | Enabled |
| Deploy frequency | Every commit | Batched, low-traffic window |
The 7 steps map directly to CI/CD stages:
| Step | CI/CD stage |
|---|---|
| Pre-check | Build: lint, syntax, unit tests |
| Backup | Pre-deploy job: SSH backup |
| Deploy | Deploy job: rsync/tar/docker push |
| Verify files | Post-deploy: checksum script |
| Verify function | Smoke tests: curl health endpoints |
| Monitor logs | Post-deploy: tail + alert on errors |
| Notify | Final job: Slack/Telegram webhook |
- PRE-CHECK: Syntax passes, scope understood, assets built
- BACKUP: Server backup + local copy, DB backed up if migrating
- DEPLOY: Files transferred (secrets excluded), permissions fixed, caches cleared
- VERIFY FILES: SHA-256 diff shows 0 differences
- VERIFY FUNCTION: HTTP 200 on key pages, changed features work
- MONITOR LOGS: No new errors for 2+ minutes (redacted output)
- NOTIFY: Team informed of deployment status
| Anti-pattern | Consequence | Correct approach |
|---|---|---|
| Deploy only changed files, skip verification | Files silently stay outdated | Full SHA-256 diff |
| Say "deployed" without checking | Broken features discovered later | Evidence before claims |
| Skip permission fix on cross-OS deploy | 500 errors (web server can't read) | Always chown after deploy |
reload instead of restart for PHP-FPM |
OPcache serves stale bytecode | restart or USR2 + opcache_reset |
| Deploy without backup | Can't rollback | Always backup first |
| Don't check error logs | Silent errors accumulate | Tail logs 2-5 minutes |
| Deploy .env to production | Overwrites server credentials | Always exclude .env |
chmod 644 everything |
Shell scripts lose execute bit | Distinguish .sh from code files |
| Deploy frontend JS without rebuild | Browser loads stale bundles | Rebuild + cache-bust first |
| Skip DB backup before migration | Can't reverse bad migration | Always backup DB first |
-
18-file silent desync — Incremental deployments over weeks left 18 files outdated. Admin panel missing entire menu sections. Root cause: never ran full checksum comparison.
-
macOS ownership = Linux 500 — Files from macOS had owner
501:staffwith600permissions. Web server couldn't read them. Entire site 500 for 30 minutes. Root cause: no--no-same-ownerand nochown. -
OPcache served old code — PHP-FPM
reloaddidn't clear OPcache. New code on disk but old bytecode executed. Root cause: reload ≠ restart for OPcache. -
CDN 520 from restart — PHP-FPM
restartbehind Cloudflare caused brief upstream failure. All active users got 520 errors. Root cause: should have used graceful USR2. -
.env overwritten — tar deployed entire config/ directory including local .env. Production DB credentials replaced with dev values. Site connected to wrong database. Root cause: no
--exclude='.env'in deployment command.