How to Contribute to the WordPress Theme Directory
If you want to help the WordPress Theme Directory, you can review submitted themes, improve the tools and guidance reviewers use, or…
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.
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.
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.
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.
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.
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.
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:
You should look at Kubernetes when one of these isn't OK anymore, and when you have the time to learn and run it.
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.
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:
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