Most early-stage products do not need Kubernetes, a managed container platform or a multi-region setup. They need one server that is set up properly, deploys predictably, backs itself up and tells someone when it is unwell. Docker Compose on a single Linux machine covers that surprisingly far, and it is cheap enough that the budget goes into the product instead of the platform.
This is the setup I reach for when a startup or small business needs a web app, an API, a database and a few background jobs running reliably. It is not the only valid approach, but every piece here exists because I have seen what happens without it.
When a single server is the right call
A single server is a good fit when:
- Traffic is moderate and predictable, and a few minutes of downtime during a rare incident is acceptable.
- The team is small, and nobody's full-time job is operating infrastructure.
- You want costs that are flat and easy to understand.
- You would rather move later, with real usage data, than design for scale you might never reach.
It is the wrong call when you have strict uptime commitments in a contract, regulatory requirements for redundancy, or workloads that genuinely spike beyond what one machine can handle. Be honest about which group you are in. "We might go viral" is not a capacity plan; a load test is.
The important part is that a well-structured Compose setup does not trap you. Each service is already a container with its configuration in environment variables, so moving to a bigger machine, a second server or a managed platform later is a migration, not a rewrite.
The shape of the stack
A typical layout looks like this:
- Reverse proxy (Caddy or Traefik) that terminates HTTPS and routes domains to services. Caddy is my default because automatic certificates and a short, readable config remove a whole category of mistakes.
- App services: for example a Next.js frontend and a .NET Core or Node API, each in its own container.
- Database: Postgres in a container with its data on a named volume or a bind mount you know the path of.
- Workers: background jobs, queues or scheduled tasks as separate services using the same image as the API where possible.
- Monitoring agents: something that ships metrics and logs off the box.
Only the proxy publishes ports to the internet. Everything else talks over an internal Docker network:
services:
caddy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
api:
image: registry.example.com/app/api:${API_TAG}
env_file: .env.api
depends_on: [db]
db:
image: postgres:16
env_file: .env.db
volumes:
- pg_data:/var/lib/postgresql/data
volumes:
caddy_data:
pg_data:
Note what is not there: no ports: on the database. A Postgres port exposed to the internet with a weak password is one of the most common ways small projects get compromised. If you need to reach the database from your laptop, use an SSH tunnel.
Harden the host before anything else
The container setup is only as safe as the machine under it. The minimum I apply on a fresh server:
- SSH with keys only; password login and root password login disabled.
- A firewall that allows 22, 80 and 443 and nothing else. Remember that Docker manipulates iptables directly, so ports you publish in Compose can bypass a host firewall like ufw. The safe habit is simply not publishing ports you do not want public.
- Unattended security updates for the OS.
- A non-root user for day-to-day work, and the Docker socket treated as root-equivalent, because it is.
- Time sync, a sensible hostname and swap configured, so memory spikes slow things down instead of killing the database.
None of this is exotic. It is just easy to skip when the goal is "get it live by Friday".
Configuration and secrets
Keep configuration in environment files that live on the server, outside the repository, with tight file permissions. Commit an .env.example that lists every variable with a placeholder so nobody has to guess what a service needs.
Pin image versions. postgres:latest will eventually upgrade a major version underneath you on a routine pull, and a database major upgrade is something you want to do deliberately, with a backup in hand.
Deploys that are boring
The goal is a deploy you can run on a Friday afternoon without thinking about it. A simple, reliable flow:
- CI builds the image, runs tests and pushes it to a registry with an immutable tag (the commit SHA, not just
latest). - The deploy step connects to the server, updates the tag in the environment, then runs
docker compose pullanddocker compose up -dfor the changed services. - A health check confirms the new version answers correctly; if not, you set the previous tag and run the same command again.
Because tags are immutable, rollback is the same operation as a deploy. That symmetry matters more than any tool choice. Database migrations are the one part that is not automatically reversible, so write them to be backwards compatible: add columns before the code uses them, and remove old ones in a later release.
Add health checks to your containers and restart: unless-stopped so a crashed process comes back on its own. Docker restarting a service is not a substitute for knowing why it crashed, but it buys you time.
Backups you have actually restored
A backup strategy has three parts, and most setups only have the first:
- Taking backups: a scheduled
pg_dump(or physical backups with a tool like pgBackRest for larger databases) plus any uploaded files. - Storing them elsewhere: copies on the same server disappear with the server. Push them to object storage in a different provider or region, encrypted.
- Restoring them: periodically restore into a scratch database and check the data is there. An untested backup is a hope, not a backup.
Write down how long a restore takes and how much data you could lose between backups. Those two numbers are what a client or investor actually wants to know when they ask "are we backed up?".
Monitoring and logs
On one server, you need to know four things quickly: is the site up, is the machine running out of disk or memory, are errors increasing, and what happened just before something broke.
- Uptime checks from outside the server, hitting a real endpoint, alerting a person by email or chat.
- Host metrics: CPU, memory, disk and disk growth. Full disks are the most common self-inflicted outage on single servers, usually from logs or old images. Configure Docker log rotation and prune unused images on a schedule.
- Application logs and errors shipped off the box to a hosted logging or monitoring service, so they survive when the server does not.
- Alerts tuned to be rare and meaningful. If an alert fires every day and nobody acts, it will be ignored on the day it matters.
Hosting several projects on one machine
A single server can host several small sites and apps safely if you keep them separated: one Compose project per application, each with its own network and volumes, and one shared reverse proxy that owns ports 80 and 443. Adding a site then means adding a block to the proxy config and reloading it, without touching the other projects. Always validate the proxy configuration before reloading, because a syntax error there takes every site down at once.
Knowing when to move on
Signs you have outgrown one server: you are scaling the machine up repeatedly, deploys cause noticeable downtime that matters to users, the database and app compete for resources, or you now have uptime commitments. The usual next steps, in order, are moving the database to a managed service or its own machine, then running two app servers behind a load balancer. Because everything is already containerized and configured by environment, each step is contained.
A short checklist
- Only the proxy publishes ports; database is internal.
- SSH keys only, firewall on, automatic security updates.
- Pinned image versions and immutable deploy tags.
- One-command deploy and the same command for rollback.
- Off-site, encrypted backups, with a tested restore.
- External uptime checks, disk alerts, log rotation, shipped logs.
This is how the products in my Pro Clean+ and NeonTask case studies are built and run: containerized services, CI/CD and a setup one person can operate with confidence. If you want a second pair of eyes on your current server or a setup like this built for you, get in touch.