Docker itself is not the problem on a 1 GiB box — the daemon idles under 50 MB. The problem is that Compose will happily start five containers with no memory limits at all, and the kernel's OOM killer does not ask which one you meant to keep.
Install Docker Engine, not docker.io
Ubuntu's own repository ships an old, renamed build called docker.io. Skip it and add Docker's official apt repository instead, so you get current releases and the docker compose plugin as a first-class package, not the separate Python tool it used to be. The full steps also live in Docker's own Ubuntu install guide:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Add your user to the docker group so you stop typing sudo in front of every command, then start a new shell for it to take effect:
sudo usermod -aG docker "$USER"
newgrp docker
From here it is docker compose — no hyphen, a subcommand of the docker CLI — not the old standalone docker-compose binary. The compose file syntax is identical either way.
Add a swap file before you add containers
A 1 GiB Starter has exactly 1 GiB of RAM and nothing behind it. Without swap, the moment memory pressure crosses that line, the kernel picks a process to kill — sometimes sshd, not the container you meant to lose. A swap file does not make your workload faster; it turns a hard crash into a slow one you can watch coming with free -h:
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
Two gigabytes is a reasonable default on a 25 GB disk. Also lower how eagerly the kernel reaches for it — on a server you want swap as an emergency buffer, not an active memory tier:
echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl --system
Read this before you buy: the NAT IPv4 catch
This only matters for containers that need to be reached from outside — a web UI, a webhook, anything with a browser pointed at it. A background worker that only makes outbound calls does not care.
Cheap VPS plans commonly hand you NAT IPv4 — a shared address with a small set of forwarded ports — rather than an address of your own. A reverse proxy that gets a certificate and serves HTTPS needs to own ports 80 and 443 on a public address; a forwarded port like 41022 instead means plain https://app.example.com will not work, because you do not hold 443 on that IP.
Two honest ways through it:
- Get a dedicated IPv4. Then 80 and 443 are yours and Caddy below works exactly as written. On our plans that is available on request by e-mail, not as a self-service add-on.
- Skip the reverse proxy entirely for anything that does not need a browser hitting it. A sync agent, a backup job, an outbound n8n workflow, a Docker-based CLI tool — none of that needs an inbound port at all.
Read NAT IPv4 vs a dedicated IP and NAT IPv4, ports and forwarding before you order if you are not sure which situation you are in.
Give every service a memory limit
Compose files often show a deploy.resources.limits.memory block, documented in the Compose Specification's deploy section, and leave it at that. On a plain docker compose up, it is silently ignored — deploy is written for Swarm, and outside Swarm mode Compose only honors it when you add the --compatibility flag, which translates it into the same cgroup limit as the older mem_limit property:
docker compose --compatibility up -d
If you would rather not remember a flag on every invocation, use mem_limit directly. It is a legacy top-level property, still fully supported by the compose plugin, and it applies with a plain docker compose up. The Uptime Kuma tag below is an old 1.x release kept only to show the syntax, not a recommendation — check the project's GitHub releases page for whatever is actually current before you copy this in:
services:
uptime-kuma:
image: louislam/uptime-kuma:1.23.16
restart: unless-stopped
mem_limit: 200m
memswap_limit: 400m
volumes:
- kuma_data:/app/data
ports:
- "127.0.0.1:3001:3001"
volumes:
kuma_data:
mem_limit caps physical memory; memswap_limit caps memory plus swap, and doubling it lets the container spill a little into the swap file instead of getting OOM-killed the instant it touches the ceiling. Set both, on every service, before you decide the box "isn't big enough" — an unbounded container competing with three other unbounded containers is a resource-allocation problem, not a RAM problem.
Log rotation, or the disk fills quietly
Docker's default logging driver, json-file, keeps every line a container has ever printed to stdout, with no size cap out of the box. A chatty service left running for months can grow its log file into gigabytes on a 25 GB disk, and the symptom shows up as "the disk is full" days after the actual cause.
Set a default at the daemon level so nothing you forget to configure per-service falls through:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
Save that as /etc/docker/daemon.json and restart the daemon:
sudo systemctl restart docker
This only applies to containers created after the restart, so recreate anything already running (docker compose up -d --force-recreate) to pick it up. Override it per service in the compose file when a different limit makes sense:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Three files of ten megabytes each caps that service at 30 MB of logs, rotated automatically, forever.
Pin every image tag
latest is not a version, it is a promise that the maintainer will not break anything, and that promise gets broken eventually, usually on a night you were not planning to debug anything. Pin an explicit tag, the same way the Uptime Kuma line above does:
image: louislam/uptime-kuma:1.23.16
Bump it deliberately with docker compose pull && docker compose up -d after reading what changed, not automatically. For the strongest guarantee that a redeploy pulls the exact same bytes, pin the digest instead of the tag (image: name@sha256:...) — the right trade for anything holding data you care about.
HTTPS with Caddy, for the services that need it
For anything with a web UI you intend to open in a browser, Caddy is the shortest path to real HTTPS, because it requests and renews the certificate on its own — the mechanics are covered in Caddy's automatic HTTPS documentation:
app.example.com {
reverse_proxy 127.0.0.1:3001
}
That is the whole config for one service; add another block for each additional hostname. Bind the container's own port to 127.0.0.1, as in the Uptime Kuma example above, so the only thing reachable from the internet is Caddy itself, not every container's raw port.
Caddy's own footprint is small — comfortably under 50 MB idle — so it is not what pushes you over 1 GiB; the containers behind it are. Run one Caddy instance and proxy everything through it rather than giving each service its own.
Pruning: the cleanup that isn't automatic
Every docker compose pull and image rebuild leaves old layers behind. On a 25 GB disk that adds up fast. The safe, routine command is:
docker image prune -f
That removes only dangling images — layers with no tag pointing at them anymore — and never touches anything a running or stopped container still references. docker system prune -af --volumes is the aggressive version, and --volumes is the part to be careful with: it deletes any volume not currently attached to a container, including the data volume of anything you stopped but never removed. Run it by hand, read what it lists before confirming, and never put it in an unattended cron job.
What actually fits in 1 GiB, and what does not
With sane limits set, on a 1 GiB Starter:
Fits comfortably. Uptime Kuma monitoring a few dozen endpoints, idling under 100 MB. Vaultwarden, a lean Rust rewrite of the Bitwarden server, idling under 50 MB. A small n8n instance running a handful of workflows without heavy concurrent executions — see self-hosting n8n on a VPS for the full setup and its own memory notes. Any one or two of these together, each with a mem_limit set, leaves real headroom for the OS and Docker itself.
Does not fit, realistically. Nextcloud once you turn on preview generation — the PHP-FPM workers that render thumbnails spike hard under real usage and get OOM-killed on a 1 GiB box regardless of how carefully you tune it; self-hosting Nextcloud properly covers what it actually needs. Gitea plus CI runners — Gitea alone is light, but each CI job the runner picks up spins up its own container with its own memory demand stacked on top of everything already running. Anything built on Elasticsearch — its documented minimum heap alone is larger than this entire machine, before the OS or any other service gets a share.
The pattern across all three: it is not the base application that breaks 1 GiB, it is a specific feature — previews, CI jobs, a search index — that spikes far above idle. Read the feature list with that in mind, not just a "runs on a Raspberry Pi" marketing line.
On overnight.host
Full disclosure: this is what we sell. A 1 GiB Starter runs two or three small, memory-limited containers without drama; move up a tier the moment you want Nextcloud with previews, Gitea plus CI runners, or anything built on Elasticsearch.
Linux KVM VPS — EUR 4.99 to EUR 59.99 a month, on our own single-tenant bare metal in Dallas, TX and Charlotte, NC. Full hardware virtualisation (KVM), your own kernel, full root. Six tiers, vps-starter to vps-ultra. Starter is 1 vCPU, 1 GiB RAM, 25 GB disk.
You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.
Order vps-starter → · Linux KVM VPS overview
FAQ
Does deploy.resources.limits.memory actually do anything with plain docker compose up?
No, not by itself. Compose only applies the deploy section's resource limits when you add the --compatibility flag; without it, the block is parsed and silently ignored. Either add that flag to every invocation or use the simpler mem_limit / memswap_limit properties, which apply on a plain docker compose up with no flag required.
Do I actually need swap on a VPS, or is that a laptop thing?
You need it more on a small VPS than on a laptop with 16 GiB to spare. It is not extra performance — it is the difference between a container getting cleanly OOM-killed and logged versus the kernel picking an arbitrary process, possibly sshd, when memory runs out with no buffer at all.
How do I see what is actually using the memory?
docker stats shows live, per-container cgroup memory and CPU usage, which is what your mem_limit values are being checked against. Cross-check the host total with free -h — if docker stats looks fine but free -h shows swap in heavy use, something outside your containers, or a container with no limit set, is the culprit.
Can I run Nextcloud in 1 GiB if I just don't use previews?
You can get it running, but you are fighting the application's own defaults the whole time, and any visit to the Photos or Files app will try to generate a preview unless you disable that server-side too. It is a smaller, more fragile deployment than the guide it deserves; self-hosting Nextcloud properly is written for the tier that actually suits it.
Is a dedicated IPv4 required just to run Docker Compose?
No. Docker and Compose do not care about your networking situation at all. A dedicated IPv4 only becomes relevant the moment a service needs inbound HTTPS on a public hostname and your default NAT IPv4 does not forward 80 and 443 to you — and plenty of useful compose stacks never need that.
Written by the person who runs overnight.host: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at up.overnight.host.
Originally published at overnight.host — the canonical, kept-current version of this guide.
Top comments (0)