You Don't Need Kubernetes for a 3-Person Team
Kubernetes is impressive engineering. It's also overkill for 90% of the teams reaching for it. Here's what a well-configured single VM actually handles.
I've watched three separate clients spend meaningful engineering time migrating to Kubernetes in the last two years. In all three cases, the problem they were solving was not the problem they actually had. The problem they had was a poorly configured single server.
The pitch vs. the reality
Kubernetes solves real problems — at scale, with large teams, when you have actual multi-region availability requirements or workloads that spike unpredictably across dozens of services. Google needed it. Netflix needs it. Your Laravel app serving 800 requests a day does not need it.
What most small teams actually have is one or two apps, a queue worker, maybe a cron job, a database, and Redis. They've reached for Kubernetes because it sounds right, because their last employer used it, or because a well-meaning DevOps contractor quoted them a migration. The result is a system that requires a certified specialist to debug a failed deployment, costs $300-500/month minimum for a managed cluster (before your actual workloads), and adds 40 minutes to onboarding a new developer.
I've been running managed hosting for clients since before Docker was a thing. I've watched the tooling mature. A well-configured single VM with Docker Compose, a decent reverse proxy, and a few sane operational habits handles more than most small teams realize.
What a single VM actually handles
I run several client apps on individual DigitalOcean Droplets or Hetzner Cloud instances — typically the $24/month (4 vCPU, 8GB RAM) tier for workloads that don't need much, or the $48/month (8 vCPU, 16GB RAM) tier for anything handling real traffic. Here's a rough breakdown of what that hardware quietly absorbs:
- A Laravel app handling 50-200 concurrent users
- PHP-FPM with 20-30 workers
- Nginx as the reverse proxy and TLS terminator
- A queue worker running Laravel Horizon
- Redis for cache and queue
- Postgres or MySQL (with automated backups via
pg_dumpormysqldumpto S3) - Scheduled cron jobs via
cronor Laravel's scheduler - A second staging environment in Docker Compose on the same box
That's a real production stack for a real business. I've had it running stable for healthcare clients where uptime matters and the database contains PHI. The threat model is backups, firewall rules, and encrypted volumes — not pod scheduling.
The actual setup
Here's the docker-compose.yml pattern I come back to for most client deployments. Nothing exotic.
# docker-compose.yml
services:
app:
image: ghcr.io/your-org/your-app:${APP_VERSION:-latest}
restart: unless-stopped
env_file: .env
volumes:
- storage:/var/www/html/storage/app
depends_on:
- redis
networks:
- internal
horizon:
image: ghcr.io/your-org/your-app:${APP_VERSION:-latest}
restart: unless-stopped
command: ["php", "artisan", "horizon"]
env_file: .env
depends_on:
- redis
networks:
- internal
scheduler:
image: ghcr.io/your-org/your-app:${APP_VERSION:-latest}
restart: unless-stopped
command: ["/bin/sh", "-c", "while true; do php artisan schedule:run --verbose --no-interaction && sleep 60; done"]
env_file: .env
networks:
- internal
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- internal
volumes:
storage:
redis_data:
networks:
internal:
driver: bridge
Nginx runs on the host (not in Docker — I want it managing TLS with Certbot without container networking complications), and it proxies to the app container via a socket or exposed port. Postgres also runs on the host so I can use pg_dump in a cron job without orchestration gymnastics.
Deployments are a shell script triggered by a GitHub Actions workflow:
#!/bin/bash
# deploy.sh — runs on the server via SSH from CI
set -euo pipefail
APP_VERSION=$1
cd /var/www/your-app
# Pull new image
docker compose pull app horizon scheduler
# Run migrations before swapping traffic
docker compose run --rm app php artisan migrate --force
# Swap containers with zero-downtime (compose handles the restart)
APP_VERSION=$APP_VERSION docker compose up -d --remove-orphans
# Clear caches
docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache
echo "Deployed $APP_VERSION"
Is this zero-downtime blue-green? No. Is there a 5-10 second window during container restart where a few requests might get a 502? Technically yes. Has any client noticed or complained? Not once. If you need true zero-downtime, you can run two app containers behind Nginx upstream with least_conn — still no Kubernetes required.
The operational basics that actually matter
The things that make a single VM production-grade aren't sexy:
Automated backups. I use a cron job that runs pg_dump, compresses it, and ships it to S3 with a 30-day retention policy. I test restores quarterly. This is more valuable than any orchestration layer.
Firewall. UFW with a default-deny policy. Ports 22, 80, 443 open. Postgres bound to localhost only. Redis bound to localhost only. Done.
Monitoring. I send logs to Papertrail (cheap) and use Uptime Robot for external health checks. For anything with a real SLA, I add a Datadog agent. The important thing is that someone gets paged when the app is down, not that you have a beautiful Kubernetes dashboard.
Swap. Seriously. Add 2-4GB of swap. It has saved me from OOM kills on more than one occasion during a traffic spike while I get to my terminal.
Unattended upgrades. apt install unattended-upgrades with security updates enabled. If you're not doing this, you're accumulating CVE risk silently.
The gotchas
This approach isn't free of tradeoffs. Here's where it actually bites:
Vertical scaling ceiling. When you outgrow the VM, you resize it. DigitalOcean and Hetzner make this mostly painless (a reboot, maybe 5 minutes of downtime). But there is a ceiling, and if your app has workloads that need to scale horizontally across machines, you will eventually feel it.
The database is colocated. I separate the database to a managed service (DigitalOcean Managed Postgres, RDS, whatever) once a client hits meaningful load or has strong compliance requirements. Running Postgres on the same box as your app is fine for small workloads but is a single point of failure and makes scaling awkward.
No automatic pod rescheduling. If the VM goes down, it's down until you bring it back up or your monitoring wakes someone up. For most clients, that's acceptable — they don't have SLAs that require five-nines. If you do, you're in different territory.
Secrets management is manual. .env files on disk, locked down with permissions, backed up encrypted. It works. It's not Vault or AWS Secrets Manager. For regulated industries, I sometimes integrate with AWS Parameter Store and pull secrets at deploy time. That's a one-hour project, not a Kubernetes migration.
When I'd actually reach for Kubernetes
There are real scenarios where it earns its complexity:
- You have 10+ microservices that need independent scaling and deployment schedules
- You have burst workloads that need to spin up 50 workers in 30 seconds and back down to zero
- You have a platform team that owns infra and developers shouldn't be touching deployment configs
- You have genuine multi-region failover requirements in your SLA
None of my current clients are in that bucket. One biotech client I work with processes genomics pipeline jobs that can burst to hundreds of workers — they're on AWS Batch, not Kubernetes, and that's the right call for their workload too. Kubernetes is one answer, not the only answer.
When I wouldn't
If you're a 2-5 person team shipping a web app, an internal tool, or a SaaS product with fewer than a few thousand daily active users, Kubernetes will cost you more in engineering time than it will ever save you in operational reliability. You'll spend time writing Helm charts, debugging ImagePullBackOff errors, managing ingress controllers, and explaining to your junior developer why the app works locally but not in the cluster. That's time you could spend on features.
The teams I've seen struggle most with infrastructure are almost always the ones who over-engineered it early. The teams that ship are usually running something embarrassingly simple.
The honest take
Kubernetes is genuinely impressive engineering. I understand why it exists and why large engineering organizations need it. But "this is what serious engineers use" is not a good reason to adopt a system that requires a 500-page book to operate correctly.
A well-configured single VM, automated backups, a firewall, and a simple deployment script will carry most small teams farther than they expect. Add complexity when the problem demands it — not because the tooling sounds impressive in a job posting.
Need help shipping something like this? Get in touch.