Docker on a VPS: what actually changes
Docker on a VPS is the same engine with less room: RAM runs out, published ports skip UFW, containers stay down after a reboot, and disk fills up.
What changes when you run Docker on a VPS
Docker on a VPS runs the same engine and the same images as Docker on your laptop, so every command you already know still works. What changes is the room around it. A laptop has spare memory, a firewall nobody is scanning, and a disk big enough that you never look at it. A rented server has a fixed memory ceiling, a public IP address that gets scanned within minutes of booting, and a root filesystem that Docker will fill without asking.
Four differences cause most of the trouble on a small box:
- Memory is finite, and the kernel settles a shortage by killing a process.
- A published port goes straight through UFW (uncomplicated firewall), because Docker writes its own firewall rules.
- Containers do not come back after a reboot unless you asked for that in advance.
- Images, containers, volumes and build cache grow until the disk is full.
Each section below names the failure, the string you will actually see, and the guide that fixes it in depth. If you have not written a compose file yet, read Docker Compose basics on a VPS first and come back. This page assumes you can already bring a stack up.
How much RAM does a Docker container use?
Less than most people expect. A container is a process in a cgroup (control group), not a virtual machine, so there is no guest kernel and no fixed allocation. The cost is whatever the process inside touches. That is why a full stack fits in 2 GB when the same stack built from virtual machines would not.
The figures below are typical idle numbers for stock images on Ubuntu 24.04 with default configuration, read from docker stats a few minutes after start. They are a starting point for planning, not a benchmark of your workload. Run docker stats --no-stream on your own box before you trust any figure, including these.
The data behind this chart
[
{
"label": "nginx",
"idle_mb": 8,
"budget_mb": 64
},
{
"label": "Redis 7",
"idle_mb": 12,
"budget_mb": 128
},
{
"label": "Traefik v3",
"idle_mb": 40,
"budget_mb": 128
},
{
"label": "PostgreSQL 16",
"idle_mb": 45,
"budget_mb": 512
},
{
"label": "Uptime Kuma",
"idle_mb": 95,
"budget_mb": 256
},
{
"label": "MariaDB 11",
"idle_mb": 190,
"budget_mb": 512
},
{
"label": "Nextcloud (Apache)",
"idle_mb": 210,
"budget_mb": 768
}
]The two columns do different jobs. idle_mb is what the container uses while doing nothing. budget_mb is what to reserve when you plan, because real use is not idle. PostgreSQL idles near 45 MB and wants 512 MB once connections, sorts and cache are live. Plan with the budget column. Debug with the idle column.
Note the shape of those 7 rows. nginx idles at 8 MB and Nextcloud at 210 MB. The proxy in front of your applications is close to free. The database and the PHP application are what you size the box for.
One warning about docker stats: the memory figure includes page cache that the container's own file reads pulled in, so it climbs for a while after start and then settles. Watch it for an hour before you decide something is leaking.
Sizing a VPS: what fits in 2 GB, 4 GB and 8 GB
Take the host's share off the top first. The kernel, systemd, journald, sshd and the Docker daemon all live in the same RAM your containers do, and dockerd with containerd holds around 100 MB of it. You also need free memory for page cache, and for the spike when an image build or a database dump runs.
The data behind this chart
[
{
"plan": "2 GB VPS",
"total_mb": 2048,
"host_reserve_mb": 768,
"container_mb": 1280
},
{
"plan": "4 GB VPS",
"total_mb": 4096,
"host_reserve_mb": 1024,
"container_mb": 3072
},
{
"plan": "8 GB VPS",
"total_mb": 8192,
"host_reserve_mb": 1536,
"container_mb": 6656
}
]host_reserve_mb covers the operating system, the Docker daemon and the headroom that keeps the box responsive under load. What is left is container_mb, and that is the only number you get to spend. The reserve grows with the plan, from 768 MB on the smallest box to 1536 MB on the largest, because a bigger box runs more containers, writes more logs and wants more page cache.
A 2 GB plan leaves 1280 MB for containers. Spend 512 MB of that on PostgreSQL and 128 MB on Traefik, and half of it is already gone. The rest is two small applications at roughly 256 MB each. That is a real and useful server. It is not room for Nextcloud and a search cluster on top.
A 4 GB plan leaves 3072 MB, which is where a database, a reverse proxy, three applications and a monitoring container all fit at once. This is the smallest size worth using for anything you care about, because the spare memory is what absorbs a bad deploy.
An 8 GB plan leaves 6656 MB of its 8192 MB, and the limit usually moves from memory to CPU or to disk throughput. If the arithmetic says your stack does not fit, buy the bigger plan instead of tuning around it: what a VPS actually costs sets out what the extra gigabytes are worth per month.
Two rules keep the arithmetic honest. Put a memory limit on every service, so one runaway process cannot take the whole box with it. And leave the top of the budget unspent, because docker compose build and pg_dump both want memory at the worst moment. Memory limits in Docker Compose has the syntax and the traps.
Why does my container exit with code 137?
Because the kernel killed it. 137 is 128 plus 9, and signal 9 is SIGKILL. The container asked for more memory than it was allowed, and the out of memory (OOM) killer ended it.
CONTAINER ID IMAGE STATUS
9f2c1a4d7b3e postgres:16 Exited (137) 4 minutes agoConfirm the cause instead of guessing:
docker inspect my-db | grep -i oomkilled
sudo journalctl -k | grep -i "out of memory""OOMKilled": true means the container hit its own cgroup limit, and the kernel log names the process it chose:
Memory cgroup out of memory: Killed process 2417 (postgres) total-vm:1284416kBThat is the good case, because the damage stopped at one container. The bad case is a container with no limit at all. Without a limit its ceiling is the whole machine, so a leak in one service starves the host, and the kernel then picks a victim by size across the entire system. The log line loses the Memory cgroup prefix and reads Out of memory: Killed process 2417 (postgres). The process it picks is often your database, while the container that leaked keeps running. That is why a limit on every service matters more than the exact value of any single limit.
Swap changes the timing, not the arithmetic. Most VPS images ship with no swap. Check with swapon --show, which prints nothing at all when there is none. A swap file gives the kernel somewhere to put cold pages, which buys you minutes to notice a problem.
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -hfree -h should now show a non-zero total in the Swap row. Swap does not add RAM. A box under constant memory pressure gets slow enough that you cannot SSH in to fix it, so treat swap as an alarm buffer and fix the sizing.
Why does UFW not block my published Docker port?
Because the traffic never reaches the chain UFW guards. When you publish a port with -p 5432:5432 or a compose ports: entry, the daemon writes a DNAT (destination network address translation) rule into the nat table and an accept rule into its own DOCKER chain. A packet aimed at a container is forwarded to that container rather than delivered to the host, so it is handled in the FORWARD path and never passes the INPUT rules UFW writes.
You can watch this happen on the server:
sudo ufw status | grep 5432
sudo iptables -t nat -L DOCKER -n | grep 5432UFW can print 5432 DENY IN Anywhere while the nat table holds a DNAT tcp ... to:172.18.0.2:5432 rule for the same port. From another machine, nc -vz your.server.ip 5432 still connects. The database is on the public internet and the firewall says it is not.
The fix is to publish less. Containers in one compose project share a network and reach each other by service name, so a database that only serves the application beside it needs no ports: entry at all. When you do want local access, bind the publish to loopback:
services:
db:
image: postgres:16
ports:
- "127.0.0.1:5432:5432"After docker compose up -d, nc -vz your.server.ip 5432 from outside fails, and psql -h 127.0.0.1 -p 5432 on the box still works. In a healthy small stack only the reverse proxy publishes anything, on 80 and 443. Why Docker published ports bypass UFW covers the DOCKER-USER chain for the cases where you must publish a port and still filter it, and UFW firewall basics covers the host rules underneath.
Why are my containers gone after a reboot?
Because nothing told them to come back. A container is created with restart policy no unless you set one, so a reboot leaves it stopped and the daemon does not care. Reboots are not rare on a VPS: kernel updates from unattended upgrades, provider maintenance, and the OOM sequence above all end in one.
Two things have to be true. The daemon must start at boot:
systemctl is-enabled dockerThat prints enabled on a stock Ubuntu install. Then each service needs a policy:
services:
app:
image: ghcr.io/example/app:1.4
restart: unless-stoppedunless-stopped brings the container back after a reboot and respects a container you stopped on purpose. always also restarts the ones you stopped deliberately whenever the daemon restarts, which is a surprise in the middle of debugging. Editing the file is not enough on its own, because the restart policy is set when the container is created. Run docker compose up -d so it is recreated, then check the live value:
docker inspect my-app | grep -A3 RestartPolicyThen reboot the box on purpose and run docker compose ps in the project directory. A stack that survives a planned reboot survives an unplanned one. If your stack needs an ordering guarantee or a one-shot job at boot, a systemd unit is the better tool: starting Docker Compose on boot has the unit file. To know whether a container that came back is really serving, add Compose healthchecks.
Why is my VPS disk full?
Because Docker keeps everything until you tell it not to. Every image tag you ever pulled, every stopped container, every anonymous volume left behind by a recreate, and every layer of build cache stays on disk. On a root filesystem of 40 GB or 80 GB, which is normal at these plan sizes, that turns into an outage in months rather than years.
A full disk does not look like a crash. You get no space left on device from a container, from apt, from journald and from docker pull in the same hour. PostgreSQL stops accepting writes. The box is still up, which makes it harder to notice than a reboot loop.
Look before you delete:
docker system df
df -h /docker system df splits the total into images, containers, local volumes and build cache, with a RECLAIMABLE column beside each. On a box that builds its own images, build cache is usually the largest line.
docker image prune -a
docker builder prune
docker system dfdocker image prune -a removes every image no container is using. docker builder prune clears the build cache. Both are safe while services run, because anything in use is skipped. What is not safe is docker system prune --volumes, which deletes every volume no container references right now. A stack you stopped for the weekend has exactly that shape, and its database volume goes with it. Read bind mounts versus named volumes before you type that flag, and take a backup first.
Container logs are the quieter growth. The default json-file driver has no size limit, so one chatty container writes gigabytes into /var/lib/docker/containers. Cap it for every container in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}Apply it with sudo systemctl restart docker, which restarts your containers, so pick the moment. The cap applies to containers created after the change, so recreate the running ones with docker compose up -d --force-recreate and confirm:
docker inspect my-app | grep -A5 LogConfig
sudo du -sh /var/lib/docker/containersThe inspect output should show max-size set. If it is empty, that container predates the change and is still writing without a limit.
Habits that keep a small Docker box healthy
None of this needs a dashboard or a tool you have to learn.
- Run
docker system dfanddf -h /on the first of the month. Two commands, thirty seconds, and you see the trend long before it becomes an outage. - Give every service a memory limit, including the ones you are sure are small. The limit turns a host-wide outage into one restarted container.
- Watch the box from somewhere else, so you hear about memory or disk pressure before the kernel acts. Uptime Kuma runs in a container and idles at about 95 MB.
- Back up volumes, not containers. The container is disposable and the volume is not. restic backups on a VPS covers a schedule and a restore test.
- Pin image tags in the compose file and update them on a day you choose. With
latest, the version you get from the nextdocker compose pullis whatever shipped that morning.
A small VPS running Docker stays healthy for years when four numbers stay in range: the memory budget, the list of published ports, the restart policy on each service, and free disk. Everything else is the same Docker you already run at home.
FAQ
How much RAM do I need to run Docker on a VPS?
Docker itself is cheap. The daemon and containerd together sit near 100 MB, and the rest of the requirement is your containers. Budget the host's share first: 768 MB on a 2048 MB box for the operating system, the daemon and headroom, which leaves 1280 MB to spend. A database at 512 MB, a reverse proxy at 128 MB and two small applications fit inside that. Measure your own stack with docker stats --no-stream rather than trusting any published figure.
Can I run Docker on a 1 GB VPS?
Yes, for one or two light containers, and add a swap file before you start. Roughly half of a 1 GB box is gone once the operating system and the Docker daemon are running, which leaves room for a small application and a reverse proxy, but not for a database under real load. Building images on a box that size will fail or kill something else, so build elsewhere and pull the finished image.
Does UFW protect a Docker container?
Not for ports you publish. Docker writes its own DNAT and forward rules, so a packet aimed at a published container port is forwarded to the container instead of being delivered to the host, and the INPUT rules UFW manages never see it. ufw deny 5432 can be active while that port answers from the internet. Publish to loopback with 127.0.0.1:5432:5432, leave internal services unpublished, or filter in the DOCKER-USER chain.
Will my containers restart after a VPS reboot?
Only if they were created with a restart policy. Set restart: unless-stopped on each service, run docker compose up -d so the containers are recreated with it, and confirm systemctl is-enabled docker prints enabled. Then reboot on purpose and check docker compose ps. A restart policy you have never tested is not a restart policy.
How often should I prune Docker images?
Monthly is enough for most small servers, or whenever docker system df reports reclaimable space you would miss. docker image prune -a and docker builder prune are both safe while services run, because images and cache in use are skipped. Avoid docker system prune --volumes unless you know exactly which volumes are unreferenced, because it deletes the data of any stack that happens to be stopped.