Articles › Servers and DevOps

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.

Intermediate 5 min read October 16, 2026

What you will learn
  • 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
SettingMeaningWhy this number
shared_buffers=256MBthe database’s own cache, reserved permanentlyabout 1/8 of memory, leaving room for others
effective_cache_size=512MBtells the query planner roughly how much the OS is likely cachingnot reserved at all, just a hint for decisions
work_mem=8MBmemory per sort or hash operationkept small, because it multiplies (see below)
maintenance_work_mem=64MBused for building indexes and vacuumthese rarely run at the same time
max_connections=50the most connections allowedeach 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.

2,048 MB of memory (+ 2 GB swap as a net)PostgreSQL shared_buffers · 256 MB, always reservedwork_mem worst case · 50 × 2 × 8 MB = 800 MBRedis · capped at 128 MBthe rest: Go app · Caddy · OS · page cacheDocker logs: 10 MB × 3 files per service (once grew to 15 GB)
All the caps together must fit the machine, planned for the bad case, not the usual one

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.