Three docker run commands with slightly different flags is three places where your setup can quietly drift the next time you reboot. One docker-compose.yml with a reverse proxy in front of a couple of services fixes that for good: services find each other by name, only one port faces the internet, every container has its own memory and CPU ceiling, and updating is a pull and an up -d, not a ritual. We build a stack out of Caddy, Vaultwarden, and Uptime Kuma, and we're honest about where this approach runs out of road.
One service in Docker on a VPS is easy. Several services on one VPS is a different question entirely, once you're spinning up each one with its own long docker run command, one you'll have no chance of reproducing exactly six months from now. Docker Compose fixes this with a single file that describes the entire stack: images, ports, volumes, environment variables, and how the pieces talk to each other. Below: a real stack built from the Caddy reverse proxy and two services behind it, a network where containers resolve each other by name, volumes that make backups make sense, per-container CPU and memory ceilings, an update flow with no ritual attached, and a straight answer about where this stops being enough.
Takeaways up front. One
docker-compose.ymlbrings up the whole stack with a single command and builds a shared network where containers see each other by service name, no manual DNS involved. Only the reverse proxy should face outward: everything else reaches its neighbors over the internal network, and a port only gets published to the host when something outside the Docker network genuinely needs it. Data that has to survive a container rebuild goes into a volume - a named Docker volume or a plain host directory - and that's what you back up, not the container. Every service gets its owndeploy.resources.limitsceiling on CPU and memory, so a container that leaks or spikes only takes itself down. Updating meansdocker compose pullthendocker compose up -d; rolling back means switching the tag back and runningup -dagain, and that only works if you pinned a tag in the first place instead of leaving the service onlatest.
The instinct after your first container works is to spin up the second one the same way: copy the command, swap the image and the port, add another docker run -d --name ... -p ... -v ... --restart=always .... It works. Right up until the third one, where things start to drift - a missing --restart here, a volume mounted to a slightly different path there because you copied it from the wrong terminal tab. A month later nobody, including you, can say with certainty which flags a given container was actually started with. You'd have to reverse-engineer it with docker inspect.
Compose fixes this the boring way: the configuration lives in a file, not in shell history. We'd reach for it here not because docker run is a bad tool - it's fine for one container - but because it leaves nothing behind to look at later. docker-compose.yml is both the documentation for the stack and the one place that says what's supposed to be running. Bring everything up: docker compose up -d. Take it all down: docker compose down. Update one service without touching the rest: change a line in the same file. The file goes in git, diffs like code, reverts like code.
There's a second instinct worth naming: instead of mixing services on one box at all, put each one on its own VPS. Sometimes that's the right call - different isolation needs, wildly different load profiles. For a typical personal or small-team set of self-hosted tools, though - a password manager, a status dashboard, a couple of automations - that's over-engineering. Three plans instead of one, three sets of OS patches to keep current, three places where an SSH key can go stale. One VPS with Compose gets you the same outcome for one bill, right up until the services actually start competing for the same resources. More on that near the end.
What matters | Several docker run commands | One docker-compose.yml |
|---|---|---|
Where the configuration lives | Shell history or scattered scripts, easy to drift | One file, can be versioned in git |
Networking between services | Manual, by IP or a separate docker network create | Created automatically, name resolution out of the box |
Bring everything up or down together | No - one container at a time | docker compose up -d / down |
Update one service | Stop and recreate the container by hand | Edit a tag in the file, docker compose up -d |
For a concrete example, pick three services that make sense running together: Caddy as a reverse proxy with automatic HTTPS, Vaultwarden, a lightweight self-hosted replacement for the Bitwarden server side that stores passwords, and Uptime Kuma, a status dashboard that watches whether the other two are still answering. The combination isn't arbitrary: it gives Uptime Kuma an actual reason to reach Vaultwarden over the internal network rather than only from outside, which is exactly the point worth illustrating - a network between containers, not just a shared host.
Directories for the stack:
sudo mkdir -p /opt/stack/caddy /opt/stack/vaultwarden /opt/stack/kuma
cd /opt/stack
/opt/stack/Caddyfile:
{
email [email protected]
}
vault.example.com {
reverse_proxy vaultwarden:80
}
status.example.com {
reverse_proxy uptime-kuma:3001
}
email in the global block is the address Caddy hands to the certificate authority. Not strictly required, but that's where a notice about a domain or ACME problem will actually land.reverse_proxy vaultwarden:80 addresses the target by service name, not by an IP and not by localhost. This only works because Caddy and Vaultwarden sit on the same network, which Compose builds on its own - more on that in the next section.And /opt/stack/docker-compose.yml:
services:
caddy:
image: caddy:2.11.4
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
deploy:
resources:
limits:
cpus: "1.0"
memory: 256M
vaultwarden:
image: vaultwarden/server:1.37.3
container_name: vaultwarden
restart: unless-stopped
environment:
- DOMAIN=https://vault.example.com
- SIGNUPS_ALLOWED=false
volumes:
- ./vaultwarden:/data
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
uptime-kuma:
image: louislam/uptime-kuma:2.5.5
container_name: uptime-kuma
restart: unless-stopped
volumes:
- ./kuma:/app/data
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
volumes:
caddy_data:
caddy_config:
Neither Vaultwarden nor Uptime Kuma has a ports section at all. That's not a simplification for the example - that's the correct setup. Only Caddy talks to either of them, over the internal network, on the container's internal port, and there's no reason for that port to also exist on the host.
Bring it up:
cd /opt/stack
sudo docker compose up -d
sudo docker compose ps
All three services should show running in ps. Caddy's ports column shows 0.0.0.0:80->80/tcp and the same for 443; vaultwarden and uptime-kuma have an empty ports column, and that's expected - nothing was published for them.
When you bring the stack up, Compose creates a dedicated network for the project - a bridge network, a software network bridge between containers that doesn't exist physically anywhere but behaves like an ordinary local network from a container's point of view. Every service in the file joins it automatically. You didn't configure any of it.
Inside that network runs Docker's built-in DNS server. It resolves a service name - whatever sits on the left side of the colon in docker-compose.yml - to that container's current internal IP. That's the whole story behind reverse_proxy vaultwarden:80 in the Caddyfile: vaultwarden isn't a magic word, it's literally the service name, and Docker swaps in the right address, even after that container gets recreated with a fresh IP.
Which leads to the practical rule that matters most here: only publish a port with ports for a service that genuinely needs to be reached from outside the Docker network - from the host, or from the internet. Anything that's only ever called by another container in the same stack gets there over the internal network on its internal port, full stop. Fewer open ports means a smaller attack surface. That's not a hand-wavy hygiene claim, either. Docker has a specific quirk here that a stray ufw rule won't save you from: publishing a port makes Docker write its own iptables rules, and those get evaluated before ufw's. That's covered in detail, including how to check it on your own server, in the AnythingLLM on a VPS article.
What about two separate compose projects on the same VPS that need to see each other - a shared database for a couple of stacks, say? By default each project gets its own isolated network and doesn't see its neighbors at all, and that's deliberate, not an oversight. You can bridge them with docker network create and a shared external network, but as long as you're running one stack in one directory, that fork in the road doesn't apply to you yet.
A container's filesystem lasts exactly as long as the container does. docker compose down, an image update, an accidental docker compose rm - anything not explicitly moved elsewhere goes with it. Persistent data is whatever shows up in volumes.
Hold on - this is worth slowing down for, because mixing up the two ways of doing that breaks more backup plans than anything else in this article.
The example above uses both, and the difference is worth actually understanding. ./Caddyfile:/etc/caddy/Caddyfile and ./vaultwarden:/data are bind mounts: ordinary directories on the host disk, at a path you know ahead of time - here, /opt/stack/vaultwarden. caddy_data:/data is a named volume: Docker creates and manages it, typically somewhere under /var/lib/docker/volumes/, and you refer to it by name rather than by path.
For anything you're actually going to back up, a bind mount is the easier choice - the path is predictable, and you can look at it, copy it, archive it directly without docker volume inspect. That's exactly why Vaultwarden's and Uptime Kuma's data sit in plain directories, /opt/stack/vaultwarden and /opt/stack/kuma, rather than named volumes: backing them up is a tar of those two directories, or folding them into the general server backup plan covered in backing up your HIP VPS. Caddy's directories are left as named volumes on purpose - they hold certificate cache and Caddy's own internal state, and losing that isn't a real problem. Caddy just reissues the certificate the next time it starts.
Without limits, a container can take as much memory and CPU as it wants - up to everything the server has. On a VPS running several services, that means a memory leak in one container, or a load spike in another, can take the whole stack down together, including services that had nothing to do with the actual problem.
Fix this right in the compose file. No extra tooling, no separate monitoring stack, no cron job killing things on a schedule.
In the current Compose specification, this is deploy.resources.limits at the service level - and, importantly, a current docker compose up (the plugin, not the old hyphenated docker-compose binary) applies it directly, without Swarm and without the --compatibility flag older tooling used to need. Docker translates cpus and memory into Linux cgroup limits - a kernel mechanism that accounts for and caps resources for a group of processes. An older, flatter syntax exists too: top-level mem_limit and cpus fields, no deploy nesting. It still works, but deploy.resources.limits is the current way to write it under the Compose spec.
Here's the reasoning behind the numbers in the example. These are ranges from independent sources and community reports, not a measurement on actual HIP hardware - check your own load with docker stats.
Service | Typical usage | Limit in the example | Why |
|---|---|---|---|
Caddy | 50-128 MB at rest; some reported spikes to 400-500 MB under specific configs and load | 256 MB, 1 CPU | Room for a spike, but not unlimited - a clean failure with a clear cause beats hoarding the whole server's memory |
Vaultwarden | 10-30 MB idle up to roughly 100 MB for a small family instance | 256 MB, 0.5 CPU | The service itself is light; the limit here is insurance against unexpected behavior more than a real ceiling |
Uptime Kuma | roughly 80-120 MB idle, grows with monitor count and check history | 512 MB, 0.5 CPU | The heartbeat history accumulates in a database inside the container, and it needs a bit more memory a year in than it did on install day |
The payoff for a memory limit shows up the moment a container actually hits it: the kernel OOM-kills (Out Of Memory kill, forcibly terminating a process when memory runs out) that specific container, not some random process on the host and not the whole system. It restarts under restart: unless-stopped, and the rest of the stack doesn't notice. Without limits, the same scenario plays out differently: the entire server's memory runs out at once, and it's the global OOM killer that decides who dies - by its own rules, not necessarily the container that was actually at fault.
Updating a service means pulling a newer image and recreating the container against it. Two short commands:
cd /opt/stack
sudo docker compose pull
sudo docker compose up -d
pull fetches images for whatever tags the file currently points at, and changes nothing about the running containers.up -d compares what's on disk and in the file against what's actually running, and recreates only the services whose image or config changed. The other two services in the stack are left completely alone - that's the whole difference between updating everything at once and updating one thing in isolation.Check the new container's logs right after, rather than assuming success because the process started:
sudo docker compose logs -n 50 vaultwarden
This is where latest and a pinned tag stop being interchangeable, and it's worth the small amount of extra discipline to keep them apart. If the file has vaultwarden/server:latest, there's nothing to roll back to: for most projects latest is a moving pointer at whatever's newest, not a version, and pulling again just fetches the same newest thing. A pinned tag like 1.37.3 in the example above is a specific, unchanging version, and a rollback is one line edited to the previous tag and another docker compose up -d:
sudo docker compose up -d vaultwarden
One detail worth knowing: if the previous image is still cached locally, the rollback takes seconds - Docker just recreates the container from what's already there. If it's gone (say, after a docker image prune), Compose pulls it again from the registry by tag, which is slower but still works, as long as that tag hasn't been removed upstream.
Caddy also has a way to apply changes to the Caddyfile itself, separate from the image, without recreating the container at all:
sudo docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
According to Caddy's own documentation, this doesn't drop existing connections to other domains served by the same instance: the new configuration comes up alongside the old one, and only after it validates does the old one get torn down. Useful when you're tweaking a rule for one domain and don't want to so much as blink at the others behind the same Caddy.
sudo ss -ltnp | grep :80 and either stop that process or move Caddy to a different host port in the compose file if the two need to coexist.docker compose logs -n 50 caddy.DOMAIN is set and matches the real public https address - without it, functionality tied to the browser's secure-context requirement simply doesn't turn on.vaultwarden:80, for instance - not at localhost and not at the public domain. Inside a container, localhost is that container, not the host and not its neighbors.docker inspect <name> --format '{{.HostConfig.Memory}}' and actual usage with docker stats --no-stream - the limit might just be set too low for the real load, rather than the service misbehaving.Honestly, for most personal and small-team stacks this ceiling sits a lot further away than people assume - most readers of this article won't hit it in practice. It's still real, and worth knowing about ahead of time rather than after something breaks.
Situation | One VPS + Compose | Several VPS | Orchestration (Swarm/Kubernetes) |
|---|---|---|---|
Services consistently need more combined resources than one machine has | Doesn't fit | Fits - split across VPS instances | Fits, if you already have several servers |
Need kernel-level isolation between client projects, not just cgroups | Weak isolation | Full isolation via separate VPS | Depends on setup, usually separate nodes too |
Need to survive the total loss of one physical server with no downtime | Won't survive it - the whole stack is on one machine | Needs its own failover logic, doesn't come for free | Native to the design - this is what it's built for |
Just need to survive one container crashing and restarting | Already handled by a restart policy | Overkill for this | Overkill for this |
If your situation matches the bottom row rather than the top three, there's nothing to change. One VPS with Compose and sane per-service limits isn't a stopgap you'd be embarrassed to show a senior engineer - it's a legitimate architecture for the overwhelming majority of self-hosted stacks out there.
A single YAML file describes the whole stack - images, ports, volumes, environment variables, and how services relate to each other - instead of commands scattered across shell history. Compose brings the whole stack up or down with one command and builds a shared network with name-based discovery on its own.
Compose creates a dedicated bridge network for the project and attaches every service to it. Docker's built-in DNS resolves a service name from the file to its current internal IP, and that keeps working even after a container is recreated with a new address.
No. The ports directive is only needed for services reached from outside the Docker network - from the host or from the internet. Services that only another container in the same stack talks to get there over the internal network without any publishing at all.
Data without an explicit volume lives only in the container's filesystem and disappears when the container is recreated. Persistent data is whatever's mounted under volumes: a named volume (kept by Docker under /var/lib/docker/volumes/) or a bind mount, an ordinary directory on the host disk. Back up those directories.
With deploy.resources.limits and its cpus and memory fields at the service level - a current docker compose up applies this without Swarm. The values become Linux cgroup limits, and a container that hits its own memory ceiling gets OOM-killed on its own, without affecting its neighbors.
docker compose pull fetches new images for the tags in the file, and docker compose up -d recreates only the containers whose configuration actually changed. Check docker compose logs -n 50 <service> right after before calling it done - nothing happens to the other services in the meantime.
Only if the image tag was pinned to a specific version rather than left on latest - the latter has no earlier version sitting under the same name. With a pinned tag, rolling back means editing that tag in the file to the previous value and running docker compose up -d again; if the old image is still cached locally, it takes seconds.
docker run commands - it's the single place that says what's supposed to be running and how.ports only for services that need to be reached from outside the Docker network - nothing else needs it.deploy.resources.limits.cpus/memory per service works under plain docker compose up, no Swarm required - and it means a container that hangs only takes itself down.pull plus up -d; rolling back is switching the tag to the previous one. The second one only works if you didn't leave the service on latest.The stack in this article is a frame, not a finished recipe for a specific set of services. From here: