Docker Compose for Everything: The Solo Builder's Deployment Pattern
One file defines your entire stack. One command starts it. One command updates it. That is the whole deployment strategy, and for a solo builder running 1-5 projects, it is the only one you need.
Docker Compose takes a YAML file that describes your services, volumes, networks, and environment variables, and turns it into running containers with docker compose up -d. No orchestration platform. No CI/CD pipeline. No cluster. A file and a command.
Why Compose and Nothing Else
The container orchestration landscape is designed for teams running hundreds of services across dozens of machines. Kubernetes manages rolling deployments across node pools. Nomad schedules workloads with constraint-based placement. Docker Swarm replicates services across a cluster with automatic failover.
None of these problems exist when you are one person running a blog, a database, and maybe a background worker on a single machine. The complexity of these tools is not free. Kubernetes alone requires understanding pods, deployments, services, ingress controllers, persistent volume claims, config maps, secrets, namespaces, and RBAC. That is a full-time specialization for a problem you do not have.
Docker Compose gives you exactly what a solo builder needs: declarative service definitions, dependency ordering, volume persistence, environment configuration, and restart policies. Everything ships in one file that you can read in under a minute. The cognitive overhead is near zero. The operational overhead is a single SSH session and two commands.
The Template That Covers Most Projects
After running Compose files across a handful of projects, the same pattern emerges. Here is the skeleton that handles ~80% of solo builder deployments:
services:
app:
image: your-app:latest
restart: unless-stopped
ports:
- "3000:3000"
env_file:
- .env
volumes:
- app-data:/data
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
env_file:
- .env
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 3s
retries: 5
volumes:
app-data:
pg-data:
Three things to notice. First, restart: unless-stopped means Docker restarts the container on crash, on reboot, on OOM kill, on anything except you explicitly stopping it with docker compose stop. This is your entire process supervision strategy. Second, env_file pulls environment variables from a .env file sitting next to your compose file, keeping secrets out of version control. Third, named volumes (pg-data, app-data) persist across container rebuilds. Delete and recreate the container all you want. The data stays.
The healthcheck on the database matters more than it looks. Without it, depends_on only waits for the container to start, not for Postgres to actually accept connections. Your app will boot, try to connect to a database that is still initializing, and crash. With condition: service_healthy, Compose waits for Postgres to pass its readiness check before starting the app.
Real Stacks You Can Copy
Here are three Compose files for stacks that solo builders actually run. These are not minimal examples. They include the env vars, volumes, and restart policies you will need in production.
Ghost + MySQL:
services:
ghost:
image: ghost:5-alpine
restart: unless-stopped
ports:
- "2368:2368"
environment:
url: https://yourdomain.com
database__client: mysql
database__connection__host: db
database__connection__user: ghost
database__connection__password: ${GHOST_DB_PASSWORD}
database__connection__database: ghost
NODE_ENV: production
volumes:
- ghost-content:/var/lib/ghost/content
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_USER: ghost
MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
MYSQL_DATABASE: ghost
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 3s
retries: 5
volumes:
ghost-content:
mysql-data:
Ghost's default is SQLite, which works fine for small blogs. MySQL becomes worth it around 500+ posts or if you want full-text search that doesn't choke. The .env file for this setup needs two variables: GHOST_DB_PASSWORD and MYSQL_ROOT_PASSWORD.
Node App + Redis:
services:
api:
image: node:22-alpine
restart: unless-stopped
working_dir: /app
command: node server.js
ports:
- "3000:3000"
env_file:
- .env
volumes:
- ./app:/app
- node-modules:/app/node_modules
depends_on:
redis:
condition: service_healthy
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
node-modules:
redis-data:
The separate node-modules volume is a pattern worth knowing. It prevents the host's node_modules from being mounted into the container (which breaks things when host and container have different architectures, like Apple Silicon vs. x86 Linux). The volume keeps container-specific dependencies isolated.
Python App + Postgres:
services:
web:
build: .
restart: unless-stopped
ports:
- "8000:8000"
env_file:
- .env
volumes:
- ./src:/app/src
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- pg-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pg-data:
This one uses build: . instead of a pre-built image, which means Compose builds from the Dockerfile in the current directory. For solo builders deploying their own code rather than off-the-shelf software, this is the standard approach. Push code, run docker compose up -d --build, done.
The .env File
Every Compose file above references environment variables. The .env file sits in the same directory as docker-compose.yml and Compose reads it automatically.
# .env
POSTGRES_USER=appuser
POSTGRES_PASSWORD=a-real-password-not-this
POSTGRES_DB=myapp
REDIS_PASSWORD=another-real-password
GHOST_DB_PASSWORD=yet-another-one
Two rules about this file. First, it never goes into version control. Add .env to your .gitignore immediately. Second, keep a .env.example in the repo with the variable names but no values, so future-you (or a collaborator, if that ever happens) knows what needs to be set.
For more complex setups, you can use multiple env files per service:
services:
app:
env_file:
- .env
- .env.app
db:
env_file:
- .env
- .env.db
I have never needed this for a solo project. One .env file per project has been enough.
The Restart Policy You Always Forget
Docker has four restart policies. Three of them are wrong for production solo deployments.
no: Container stays dead after a crash. Default. Useless for anything you want running persistently.always: Restarts even after you explicitly stop the container. Annoying when you are debugging and want things to stay stopped.on-failure: Restarts on non-zero exit codes but not on system reboot. Your services vanish after a power cycle.unless-stopped: Restarts on crash and reboot, but stays stopped when you explicitly stop it. This is the one you want.
unless-stopped gives you the behavior of a proper process manager without configuration. Container crashes at 3 AM? Docker restarts it. Machine reboots after a power outage? Docker restarts it. You stop the container to debug something? It stays stopped until you start it again.
The number of times I have deployed a Compose stack and forgotten the restart policy, then had services disappear after a reboot, is embarrassing enough that it gets its own section.
The Two Commands
Your entire deployment workflow is two operations.
Deploy (first time or after config changes):
docker compose up -d
The -d flag runs everything in the background. Without it, Compose attaches to the container logs and ties up your terminal. Run it once, walk away.
Update (pull new images and restart):
docker compose pull && docker compose up -d
pull fetches the latest versions of every image in your Compose file. up -d recreates only the containers whose images changed. If nothing changed, nothing restarts. The update is atomic per-service: each container stops and restarts individually, so a multi-service stack is never fully down during an update.
For services you build from source:
docker compose up -d --build
This rebuilds the image from the Dockerfile before starting. Compose is smart enough to use Docker's layer cache, so unchanged layers do not rebuild. A typical code-only change rebuilds in 5-15 seconds.
That is the full operational surface. Deploy, update, done. No pipeline to configure. No webhook to set up. No deployment service to pay for. SSH into the machine, run the command, disconnect.
The Gotcha: Volume Ownership
The problem you will hit, and the one that generates the most confused Stack Overflow questions, is file permissions inside volumes.
Containers run as a specific user (often root, sometimes a dedicated user like postgres or ghost). When a container writes to a mounted volume, the files are owned by whatever UID the container process runs as. When a different container — or you, on the host — tries to read those files, permissions can block access.
The most common symptom: you mount a volume, the app starts, and the logs say "permission denied" on the data directory. The container's process runs as UID 1000, but the volume directory is owned by root.
Fixes, in order of preference:
- Use named volumes (not bind mounts) for database data. Docker manages ownership internally. The Compose files above all use named volumes for exactly this reason.
- Match UIDs when using bind mounts for application code. If the container runs as UID 1000, make sure the host directory is owned by UID 1000.
- Set
user:in the Compose file to force the container to run as a specific UID:user: "1000:1000"
Named volumes dodge this problem entirely for databases and persistent state. Bind mounts (the ./src:/app/src pattern) are for code you are actively editing on the host and want reflected inside the container. Keep the two separate and most permission issues disappear.
When Compose Stops Being Enough
Compose has real limits. Being honest about them matters more than pretending they do not exist.
Compose runs on a single machine. If that machine goes down, everything goes down. There is no automatic failover, no replica set, no health-based rescheduling across nodes. For a solo builder running a blog and a few tools, a few minutes of downtime during a reboot is fine. For a SaaS product with paying customers expecting five-nines uptime, it is not.
Compose does not handle secrets well. The .env file is plain text. Docker has a secrets feature, but it is designed for Swarm mode and adds complexity that defeats the point of Compose's simplicity. For a solo builder, a .env file with strict file permissions (chmod 600) on a machine only you can access is adequate. For a team with shared access to production, it is not.
Compose does not do zero-downtime deployments. When a container updates, it stops before the new one starts. For most solo builder projects, the 1-3 seconds of downtime during an update is invisible. For high-traffic APIs, it matters.
The pattern to watch for: if you find yourself writing scripts that wrap docker compose with health checks, rolling restarts, multi-machine synchronization, or secret rotation, you have outgrown Compose. That is the signal to evaluate Swarm (simpler) or Kubernetes (more capable) or a managed platform like Fly.io or Railway. Not before.
The Solo Builder Deployment Stack
Compose fits into a specific slot in the infrastructure stack. On a single machine — a Mac Mini in a closet, a $5/month VPS, a dedicated server — the full deployment pattern looks like this:
- OrbStack or Docker Engine: Container runtime
- Docker Compose: Service definitions and lifecycle management
- Cloudflare Tunnel: Public access without opening ports or managing TLS certificates
.env+chmod 600: Secrets managementdocker compose pull && docker compose up -d: Updates- Volume mounts: Backups (copy the volume directory, or
docker compose exec db pg_dump)
No CI/CD. No container registry you pay for. No orchestration platform. No infrastructure-as-code tool generating hundreds of lines of configuration for a three-container stack.
Kubernetes solves problems that emerge at scale: multi-region deployments, automatic scaling, canary releases, service mesh networking. Those are real problems. They are not your problems. The gap between a solo builder's actual infrastructure needs and the minimum viable Kubernetes cluster is enormous, and every layer of complexity you add is a layer you have to debug at 2 AM when something breaks.
One Compose file per project. One command to deploy. One command to update. Start there. Move past it when you have the problem that requires moving past it, and not a day before.