Database, cache, app and HTTPS on a 2 GB box: cap everything that can grow
Small servers fail when something grows without a limit, not for being small. Real PostgreSQL, Redis, Docker log and swap settings for a 2 GB box, with reasons.
- A small server fails because something grows without a cap, not because it is small. Cap everything that can grow
- PostgreSQL memory is not just shared_buffers; work_mem multiplies with concurrent work
- Docker logs have no limit by default and will quietly fill the disk
Our trading journal runs everything on one machine with 2 GB of memory and 2 CPU cores: a TimescaleDB database, Redis, a Go app and Caddy for HTTPS, all from one docker compose file. A small machine like this can survive, but you need to understand how it fails. This article goes through the real settings one part at a time, with the reasons.
Tested on Ubuntu 24.04, Docker Compose v2, PostgreSQL 16 (TimescaleDB) and Redis 7.
How small machines fail
A small machine does not fail for being small. It fails because something grows without a cap and squeezes everything else:
- The database takes several heavy queries at once, memory spikes, and the kernel kills a process (the OOM killer), usually the biggest one: the database itself
- A cache keeps storing until memory runs out
- Logs keep writing until the disk is full; then the database cannot write and everything stops
Principle: everything that can grow needs a cap, and all the caps together must fit the machine.
PostgreSQL: set its limits
db:
command:
- postgres
- -c
- shared_buffers=256MB
- -c
- effective_cache_size=512MB
- -c
- work_mem=8MB
- -c
- maintenance_work_mem=64MB
- -c
- max_connections=50
| Setting | Meaning | Why this number |
|---|---|---|
shared_buffers=256MB | the database’s own cache, reserved permanently | about 1/8 of memory, leaving room for others |
effective_cache_size=512MB | tells the query planner roughly how much the OS is likely caching | not reserved at all, just a hint for decisions |
work_mem=8MB | memory per sort or hash operation | kept small, because it multiplies (see below) |
maintenance_work_mem=64MB | used for building indexes and vacuum | these rarely run at the same time |
max_connections=50 | the most connections allowed | each connection uses memory of its own |
The trap: work_mem is not used once
People read work_mem=8MB as “the database uses 8 MB”. It actually means 8 MB per sort or hash operation. One query can have several, and many connections can run at once.
Take a bad case:
50 connections × 2 operations per query × 8 MB = 800 MB
Add 256 MB of shared_buffers and you are past half the machine. Set work_mem to 64 MB, as many articles written for big machines suggest, and that figure becomes 6.4 GB on a 2 GB box. In practice our app uses only a few connections through a pool, but caps must be planned for the bad case, not the usual one.
Redis: if it is a cache, configure it as one
redis:
image: redis:7-alpine
command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru",
"--save", "", "--appendonly", "no"]
- A 128 MB cap, and when full, evict the least recently used key (
allkeys-lru) instead of growing - No disk persistence. Everything in our Redis can be rebuilt; the real data is in the database. Persisting would cost I/O, and when saving, Redis forks a process that temporarily needs more memory
Logs: the cap people forget most
Docker stores every container’s logs in files with the json-file driver, and by default there is no limit. We learned this the real way: Caddy’s access log reached 15 GB before anyone noticed.
Now every service shares one log setting:
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
db:
logging: *logging
redis:
logging: *logging
Each service keeps at most 3 files of 10 MB, 30 MB in all: enough to investigate a recent problem, and impossible to turn into a full disk. &logging and *logging are a YAML feature for writing a block once and reusing it.
Swap: a net, not more memory
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf
2 GB of swap means a temporary memory spike slows the system down instead of getting a process killed. swappiness=10 says use swap only when really needed, rather than moving things out of memory while it is still free.
If swap is heavily used all the time, the caps add up to more than the machine; cut them or grow the machine.
Expose only what must be exposed
A small machine usually does everything, so be careful about what faces the outside:
db:
ports:
- "127.0.0.1:5432:5432" # local only, for psql during maintenance
app:
expose:
- "8080" # not published outside; reachable only through Caddy
caddy:
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
- The database binds to 127.0.0.1. Write just
"5432:5432"and Docker publishes the port on every interface, often bypassing a firewall like ufw entirely, because Docker writes its own iptables rules - The app publishes no port. It talks to Caddy over Docker’s internal network, so every request goes through HTTPS
- Allow SSH in the firewall first. When enabling ufw for the first time, always allow port 22 first, or you lock yourself out
Start in order, recover on failure
db:
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fxlog -d fxlog"]
interval: 5s
app:
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
The app starts when the database really accepts connections, not merely when its container has started, and every service recovers if it falls over, including after a reboot.
See what is being used now
free -m # memory and swap for the whole machine
docker stats --no-stream # memory and CPU per container
df -h / # disk
Look regularly after adding features, not once the system is already down.
Common mistakes
- Copying PostgreSQL settings from an article written for a 16 GB machine
- No cap on Docker logs
- Publishing the database with
"5432:5432"and trusting ufw to block it - Using Redis as a cache with disk persistence still on
- Thinking swap makes running out of memory harmless
- No healthcheck, so the app starts before the database is ready and fails from the start
FAQ
Is 2 GB enough for production?
Enough for a system with few users, and fine to start with. We still plan to grow ours to 4 GB before real load. When we do, the capped settings scale up easily, because we already know what each part uses.
With swap, I do not need to worry about running out of memory, right?
No. Swap is a safety net, not extra memory. If the system leans on swap all the time, everything slows to a crawl; treat it as a sign to cut usage or add memory.
Commands and settings were tested on sample systems. Try them on a test machine before production.