VPS Deployment Guide for Solo Devs and Small Teams
A $24/month Hetzner or DigitalOcean droplet runs surprising traffic when nginx, PHP-FPM pools, and queues are tuned. VPS deployment trades managed convenience for control—and you own patching at 2 AM if you skip unattended upgrades.
Initial Server Setup
# As root on fresh Ubuntu 22.04
adduser deploy
usermod -aG sudo deploy
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/
chmod 700 /home/deploy/.ssh && chmod 600 /home/deploy/.ssh/authorized_keys
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
apt update && apt upgrade -y
apt install nginx mysql-server redis-server php8.3-fpm php8.3-mysql -y
Deploy Flow
Clone repo to /var/www/app, point nginx root to public/, run composer install --no-dev, migrate, cache config. Use a deploy user with limited sudo for service reload only. GitHub Actions SSH deploy from CI/CD article works here.
- Certbot for Let's Encrypt:
certbot --nginx -d app.example.com - Cron for Laravel scheduler:
* * * * * cd /var/www/app && php artisan schedule:run - Supervisor for queue workers—three processes default
- Off-site backups: mysqldump nightly to S3 with retention policy
Monitor with Uptime Kuma or Better Stack. Disk fills silently when logs rotate wrong—alert at 80% disk usage.
Hardening Checklist
Disable root SSH login and password authentication after key setup. Install fail2ban for SSH brute force noise reduction—not perfect security, but cuts log spam. Enable automatic security updates for unattended-upgrades on Ubuntu.
Separate database to second VPS or managed service when RAM exceeds 70% sustained. Co-located MySQL and PHP compete for memory; OOM killer takes down both simultaneously—the worst kind of outage.
Document runbook on paper-equivalent: how to restore from backup, rotate SSL, restart queue workers. Bus factor of one is acceptable only if the runbook is not in that person's head alone.
Configure logrotate for Laravel logs and nginx access logs—unbounded storage filled disks cause more VPS outages than hackers in small deployments. Ship logs to external service once traffic exceeds solo debugging comfort; grep on SSH does not scale past midnight incidents.
Scaling Path
Vertical scale VPS first—jump RAM before adding complexity of second server. When single box exceeds 80% RAM sustained, split database or add read replica on managed DB service. Load balancer plus two app servers comes after database externalized—order matters to avoid split-brain data.
Keep infrastructure as code even on VPS—Ansible playbooks reproduce server in 20 minutes when provider hardware fails, versus rebuilding from memory during outage stress.
Snapshot before major OS upgrades; distribution jumps break PHP packages without planned PPA migration. Announce maintenance 72 hours ahead for enterprise clients with contractual notification clauses—SLA credits hurt when communication fails.
Monitoring Stack
Install node_exporter and ship metrics to Grafana Cloud free tier or self-hosted Prometheus before you need them at 2 AM. Alert on CPU sustained above 80%, disk above 85%, and SSL expiry under 14 days. Uptime checks from external regions catch DNS misconfiguration local curls miss.
Restrict sudo for deploy user to systemctl reload nginx and php-fpm only via sudoers.d—full sudo unnecessary and increases blast radius if deploy key leaks. Rotate deploy keys annually and after any contractor offboarding regardless of NDA status.
Maintain offline backup copy of credentials and recovery codes—not in same provider account as VPS. Provider lockout without 2FA backup codes means starting infrastructure from zero under pressure.
Frequently Asked Questions
VPS vs managed platform like Forge?
Forge/Ploi automate nginx, SSL, and deploy hooks for $12–19/month on top of VPS cost—worth it after second server.
How much RAM do I need?
2GB minimum Laravel + MySQL on same box. 4GB comfortable with Redis and queue workers. Separate DB to managed RDS when traffic grows.
Zero-downtime deploys on VPS?
Use symlink releases (Capistrano/Deployer pattern) or blue/green with two paths and nginx reload.