Running Several Services on One VPS: Docker Compose Instead of a Pile of docker run Commands

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.yml brings 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 own deploy.resources.limits ceiling on CPU and memory, so a container that leaks or spikes only takes itself down. Updating means docker compose pull then docker compose up -d; rolling back means switching the tag back and running up -d again, and that only works if you pinned a tag in the first place instead of leaving the service on latest.

Why Compose for several services on one VPS, instead of docker run or a separate server per service

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

What we're building: Caddy plus two services behind it

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.
  • Each domain gets its own block. One Caddy instance and one IP address can comfortably host any number of sites this way, each with its own certificate.

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.

Networking: how containers find each other by name

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.

Volumes: where your data actually lives

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.

Limits: keeping one service from starving the rest

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 and rolling back: pull, up -d, and a way back

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.

If it doesn't work

  • Bind for 0.0.0.0:80 failed: port is already allocated. Something else already has port 80 - often a pre-installed nginx or Apache. Check with 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.
  • Caddy can't get a certificate. First, confirm the domain's A record actually points at this server's IP and that ports 80 and 443 are reachable from outside - without port 80, the ACME challenge usually can't complete. Check logs with docker compose logs -n 50 caddy.
  • Vaultwarden comes up, but the app login or WebAuthn doesn't work. Check that 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.
  • Uptime Kuma can't see other services in the same stack. When you add a monitor, point it at the service name and internal port from the compose file - 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.
  • A container keeps getting OOM-killed. Check the limit with 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.

When one VPS with Compose stops being enough

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.

FAQ

Why use docker compose instead of several docker run commands?

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.

How do services in docker compose find each other by name?

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.

Do you need to publish every service's port to the host?

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.

Where does docker compose store container data, and how do you back it up?

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.

How do you limit memory and CPU for one container in compose?

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.

How do you update one service in docker compose without touching the rest?

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.

How do you roll back a container to a previous image version?

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.

Takeaways

  • Compose keeps the whole stack's configuration in one file instead of scattered docker run commands - it's the single place that says what's supposed to be running and how.
  • Compose builds its own network with name-based discovery. Publish a port with ports only for services that need to be reached from outside the Docker network - nothing else needs it.
  • Persistent data is only what's explicitly mounted as a volume. A bind mount to a plain host directory is easier to back up, because the path is known ahead of time.
  • 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.
  • Updating is 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.
  • One VPS with Compose isn't a stopgap. Move to several servers or an orchestrator when you're actually hitting the combined resource ceiling of one machine or need to survive losing that machine entirely - not before.

What's next

The stack in this article is a frame, not a finished recipe for a specific set of services. From here:

PUBLISHED
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU