Currently Available: Need a skilled Software Developer for your next project?
Categories
Kubernetes

Docker Compose vs Kubernetes: when is one server enough?

Docker Compose runs your containers on one server, and Kubernetes runs them on many. Kubernetes is also a lot more to learn and to run, so you don't want it before you need it. So when do you need it?

To find out, I built a ticket shop and ran it with Docker Compose on one small AWS server with two CPUs. The company is made up. It's called Seatlark, and it sells concert tickets online. A ticket sale is a good test, because a lot of people show up in the same minute when a big concert goes on sale at 10:00. I sent that traffic from a second server until the shop broke. Then I looked at each problem and what Kubernetes does differently there.

A lot of what people want from Kubernetes already works on one server. Docker restarts a container when it crashes, and a volume holds your data when a container gets replaced.

The first limit I hit was traffic. The server handled 300 people with no failed requests, and at 1,000 people about half of the requests failed. I made the page slow on purpose, so don't read much into those numbers. More copies of the web app didn't help, because all the copies share the same two CPUs and those were already full. With Kubernetes the copies can run on other servers, so they bring more CPU with them.

The other limit is the server itself. If it dies, the whole shop is down until it's back, because Compose can't move anything to another machine. Kubernetes can start the containers on another server, as long as there's room and they can still reach their data.

So if your traffic fits on one server and you can live with being down when that server dies, Docker Compose is enough.

What is Docker Compose?

Docker Compose starts all your containers from one file. You could start each one by hand with docker run, but then you have to remember all the settings each time. With Compose you write them into compose.yaml, and one command starts them all:

docker compose up -d

The -d means the command is done right away, and the containers run in the background.

To have something close to a real app, I split Seatlark into eight containers. Each one does one job, the way a lot of companies do it:

Container What it does Written in or based on
front-door Sends each request to the right container Nginx
storefront The web app with the seat map TypeScript
checkout-api Holds seats and creates orders Python
seat-holds Keeps a seat for you for 10 minutes while you pay Redis
orders-db Stores the orders Postgres
payment-service Pretends to charge your card Java
ticket-queue Holds the jobs for making tickets RabbitMQ
ticket-worker Makes the PDF ticket in the background Python

When you buy a ticket, your request first goes to front-door, and it sends you to storefront for the seat map. When you pick a seat, checkout-api puts a hold on it in seat-holds, so nobody else can take it while you pay. Then the order goes into orders-db and you pay through payment-service. At the end checkout-api puts a job into ticket-queue, and ticket-worker makes the ticket in the background, so you don't have to wait for it.

This is the whole file. Each entry under services starts one container here:

services:
  front-door:
    image: nginx:1.29-alpine
    ports:
      - "80:80"
    volumes:
      - ./front-door/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    restart: unless-stopped

  storefront:
    build: ./storefront
    restart: unless-stopped

  checkout-api:
    build: ./checkout-api
    env_file: .env
    restart: unless-stopped

  payment-service:
    build: ./payment-service
    env_file: .env
    restart: unless-stopped

  ticket-worker:
    build: ./ticket-worker
    env_file: .env
    restart: unless-stopped

  orders-db:
    image: postgres:17-alpine
    env_file: .env
    environment:
      PGDATA: /pgdata
    restart: unless-stopped

  seat-holds:
    image: redis:8-alpine
    command: redis-server --save "" --appendonly no
    restart: unless-stopped

  ticket-queue:
    image: rabbitmq:4-alpine
    hostname: ticket-queue
    volumes:
      - queue-data:/var/lib/rabbitmq
    restart: unless-stopped

volumes:
  queue-data:

A few things in there matter for the rest of this article. build makes an image from a folder with a Dockerfile, and image uses a ready-made one. Only front-door has ports, so it's the only container you can reach from outside the server. Compose puts all the containers into one network where they find each other by name, so checkout-api connects to a host called orders-db.

Container vs virtual machine

All eight containers share this one server, so it helps to know what a container is. People often think a container is a small virtual machine, but it isn't. A virtual machine runs its own operating system with its own kernel, and a container doesn't. You can see that on the server:

ps -C postgres,redis-server,java -o pid,comm

Postgres and Redis are in that list, and the Java process is payment-service. They are normal Linux processes on this server, and they all share the same kernel. Linux only gives each one its own view of the files and the network, and it can limit how much CPU and memory it gets.

Virtual machine Container
Kernel Its own Shared with the server
Starting Boots an operating system Starts a process
Extra memory A whole operating system Very little
Isolation Stronger Weaker

Because they share the kernel, containers are less isolated than virtual machines. For your own apps that's usually fine. If you run code from other people, you need to be more careful.

This also matters for the load test later on. Unless you set limits, all eight containers compete for the same two CPUs.

Docker Compose volumes: where the data lives

All of this works because containers are easy to replace. A deploy for example throws away the old container and starts a new one. So for each container you need to know what happens to its data when it's replaced.

For me the data is the hard part. I bought a ticket and looked it up in Postgres:

docker compose exec orders-db psql -U seatlark seatlark_orders -c "select id, seat from orders"

Then I removed the database container and started a new one:

docker compose rm -sf orders-db
docker compose up -d orders-db

The order was gone. I set it up like that on purpose. PGDATA points to /pgdata inside the container, so the data went away with the container. The official Postgres image normally makes an anonymous volume by itself. A new container doesn't get that volume back, so you lose the order in the same way, but the old files stay on the disk.

So I added a volume to orders-db, and I also added it to the list of volumes at the end of the file:

    volumes:
      - orders-data:/pgdata

Then I did the same test, and this time the order was still there. The files are in /var/lib/docker/volumes/seatlark_orders-data/_data on the server's disk, and the new container gets the same folder.

orders-data is a named volume, so Docker takes care of that folder for you. The other kind you see in the file is a bind mount. front-door uses one to get its nginx.conf from the project folder. You use a bind mount for files you edit yourself, and a named volume for data the app writes.

A volume only helps when the container goes away. If that disk dies, the orders are gone, so you still need backups. With Postgres you could do this:

docker compose exec -T orders-db pg_dump -U seatlark seatlark_orders > orders.sql

Then copy that file to another machine, because a backup on the same disk doesn't help you.

Not all data is like that. For each kind of data you have to decide how much you care about it:

Data Where it lives If the container is replaced
Orders Volume orders-data They stay
Seat holds Redis in memory, saving is switched off They are gone, and people pick their seat again
Jobs in the queue Volume queue-data They stay
PDF tickets Not stored here, the worker makes them from the order Nothing to lose here
Config and secrets .env file on the server They stay, in plain text

When the holds are gone, a seat still can't be sold twice. The seat column in Postgres is unique, and the order goes in before the payment. So a second buyer gets stopped before anyone is charged.

The .env file works, but it's not great for something like a payment key. At least don't put it in Git, and make sure only the right user on the server can read it.

If your app has uploaded files, you could put them in a volume on this one server. But the usual place is object storage like S3, and that also makes it easier to move to more servers later.

Docker Compose restart policy

The next thing people expect from a platform is that a container comes back after it crashes. On one server Docker already does that. Each service in the Compose file has the restart policy restart: unless-stopped. When I crashed checkout-api, Docker restarted it.

Restart policy What it does
no Never restarts the container, this is the default
on-failure Restarts it when it exits with an error
always Always restarts it. If you stop it by hand, it comes back when Docker itself restarts
unless-stopped Like always, but a container you stopped by hand stays stopped

If you try this yourself, docker kill counts as stopping the container by hand, so Docker doesn't restart it. The crash has to come from inside the container.

With unless-stopped the containers also come back when the server reboots, because Docker starts them again. Logs stay with the container. You read them with docker compose logs, and they are gone when the container is removed.

So for crashes you don't need Kubernetes. The harder cases are too much traffic and deploys, and then the server itself can go down.

Docker Compose in production: where one server stops

For the load test I used k6. It's a tool that acts like a lot of people using your site. Here each of these people opens the page and waits a bit. Then they load the seat data and wait again. Each request gets two seconds, and after that it counts as failed. I made the page a bit slow on purpose with some extra CPU work per request, so this small server gets full fast. With a bigger server the same thing happens later.

Test Result
300 people, one storefront No failed requests
1,000 people, one storefront About half of the requests failed, and the page returned 504
1,000 people, three storefront copies About the same, because both CPUs were already full
120 people, replacing checkout-api Calls to checkout-api failed for about three seconds, and the pages kept loading

Too much load. At 1,000 people the requests come in faster than the server can answer them, so they wait in line until they run out of time. A 504 is Nginx saying that the container behind it didn't answer in time.

The first thing you try is more copies:

docker compose up -d --scale storefront=3

This only works because storefront has no fixed port or container name, and because Nginx looks up the name again every few seconds and finds all the copies. It didn't help much here. top showed 0% CPU idle, which means both CPUs were completely busy, and more copies can't help when the CPUs are full. You could get a bigger server, and for a while that's often enough. But it's still one machine. With Kubernetes you can add servers and run more copies on them. Adding servers alone doesn't move the copies already running, and it only helps if the app is what's full and not the database.

Deploys. A deploy is when you put a new version of your app live. With Compose that means replacing its container, so I tried that with checkout-api while 120 people were on the site. I had no new code to ship, so I forced it with --force-recreate:

docker compose up -d --force-recreate checkout-api

Compose stops the old container and then starts the new one, and the new one needs a moment. For about three seconds nothing could answer the calls to checkout-api, so those calls failed. How long that takes depends on how fast your app starts. The pages kept loading, because they come from storefront. If you click Buy in those three seconds, you get an error and have to click again.

For a lot of apps that's no problem, and you can deploy when few people are around. If it is a problem, you can write a script that starts a second copy first, but Compose doesn't do that for you. Kubernetes can start the new copy first and remove the old one when the new one is ready.

Losing the server. This is the limit you can't work around on one server. Everything runs on that one machine, and Compose can't move anything to another one. So if the server dies, the whole shop is down until someone fixes it or sets up a new one. This also happens when nothing is broken, because a kernel update means a reboot, and the shop is down until the server is back. If the disk survives, the orders are still on it, and a new server needs that disk or a backup before it can serve them. With Kubernetes other servers can take over the containers, as long as there's room and they can still reach their data.

When to use Kubernetes instead of Docker Compose

If you want to run this on more servers, you have to figure out which server runs what, and who restarts things when a server dies. And then also how traffic finds the containers, and where the data goes. Kubernetes does most of that for you. I only ran Compose in this test, so the Kubernetes column below is how it works and not something I measured.

Docker Compose Kubernetes
Where it runs One server Many servers in a cluster
A container crashes Docker restarts it Kubernetes restarts it
Too much traffic More copies share the same CPUs Copies can run on other servers, which helps if the app is what's full and not the database
A server dies Everything on it is down until it's back Containers get started on other servers, if there's room and they can reach their data
Deploying Compose stops the old container before it starts the new one, so calls to it can fail for a moment Rolling updates can wait until new copies are ready before they remove the old ones
Data A volume on that server Still your problem, a disk on one server doesn't move on its own

I think Docker Compose on one server is enough when these are true for you:

  • Your traffic fits on one machine, also at the peak.
  • It's OK if part of your app is gone for a few seconds during a deploy, or you deploy when few people are around.
  • If the server dies, you can live with being down until you've fixed it, and you have backups.

You should look at Kubernetes when one of these isn't OK anymore, and when you have the time to learn and run it.

Questions you might have

Does this work on a Mac or on Windows? Yes. Docker Desktop runs a small Linux virtual machine there, and the containers are processes inside that virtual machine.

Is Docker Swarm or ECS an option? Both also run containers on more than one server. Swarm rarely comes up in job ads. ECS is run by AWS, and with ECS Anywhere it can also use your own servers.

Why not Heroku or Render? They are fine when they fit. They run the servers for you and you give up some control. You still take care of your app and its data.

Should I move to Kubernetes then? Not because of this article. If one server does the job for you, then stay on one server.

How I ran this

I used two t3.medium servers in AWS with Ubuntu 24.04. Each has two CPUs and 4 GB of memory. One ran the shop and one ran k6. I tested this on November 6, 2026.

These are the image versions:

  • Postgres 17
  • Redis 8
  • RabbitMQ 4
  • Nginx 1.29
What I'm building

Delegate tasks. Get software.

Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.

Take a look at vroni.com

Email updates

Usually a new article and a few links I found interesting.

No spam. Unsubscribe with one click.

Leave a Reply

Your email address will not be published. Required fields are marked *