Docker Compose on a small VM
Docker Compose describes several containers in one file and starts them together. On a single small server it does most of what Kubernetes would, for a fraction of the memory.
The shape of compose.yml
name: shiqi
services:
web:
image: ${IMAGE} # read from .env next to the file
restart: unless-stopped
environment:
REDIS_URL: redis://redis:6379
mem_limit: 256m
healthcheck:
test: [CMD, wget, -qO-, http://127.0.0.1:3000/api/health]
interval: 30s
depends_on: [redis]
redis:
image: redis:8.8.3-alpine
command: [redis-server, --appendonly, 'yes', --maxmemory, 64mb, --maxmemory-policy, allkeys-lru]
volumes: [redis:/data]
mem_limit: 96m
caddy:
image: caddy:2.10.2-alpine
ports: ['80:80', '443:443']
volumes: [./Caddyfile:/etc/caddy/Caddyfile:ro, caddy_data:/data]
volumes:
redis:
caddy_data:
Things worth knowing:
- Service names are hostnames. Inside the Compose network,
redisresolves to the Redis container, so the app connects toredis://redis:6379. Only the proxy publishes ports to the outside. .envnext tocompose.ymlfills in${VARIABLES}. Keep secrets there, not in the file you commit, andchmod 600it.- Pin image versions (
redis:8.8.3-alpine, notredis:latest) so a restart never surprises you. - Named volumes (
redis:,caddy_data:) survive container recreation. Caddy's volume holds the TLS certificates: losing it means asking Let's Encrypt again, which is rate-limited. restart: unless-stoppedbrings everything back after a reboot.
Memory and safety limits
On a small machine, give every container a ceiling so one leak cannot take the whole server down:
mem_limitper container, and for Redis also--maxmemorywith an eviction policy.- Swap (1 to 2 GB) as a cushion, with
vm.swappiness=10so it is only used under pressure. - Hardening that costs nothing:
read_only: truewith atmpfsfor/tmp,cap_drop: [ALL],security_opt: [no-new-privileges:true].
Budget from 2026-10-09: web 256 MB, Redis 96 MB, Caddy 128 MB, about 200 MB actually used.
Everyday commands
Run them in the folder that holds compose.yml:
docker compose ps # what is running, health status
docker compose logs -f web # follow one service's logs
docker compose pull web && docker compose up -d web # roll out a new image
docker compose restart web
docker compose exec redis redis-cli
docker stats --no-stream # real memory use per container
docker image prune -f # reclaim disk from old images
Deploying a new version is just changing IMAGE in .env and running up -d again; rolling back is the same with the old tag. That is all infra/deploy.sh does.
When to move to Kubernetes
When there is more than one machine, or you need rolling updates without downtime, autoscaling, or lots of services. Until then Compose is simpler and lighter. See Kubernetes on a small node.