Blog
ENPL

Docker Compose in a homelab — 6 traps that cost me time

Docker compose is my main service orchestration in the homelab. Showing 6 concrete traps I hit over the last year and how to avoid them.

·4 min read
Docker Compose in a homelab — 6 traps that cost me time

I have ~25 docker-composes. Each represents a service. After a year of regular use I collected a list of traps I hit. Each cost me at least an hour. Listed here so you can save those hours.

Trap #1: Python rebuild vs restart

The loudest one. I edit a .py file in the container, run docker compose restart, code is still the old one.

# WRONG - restart doesn't load .py changes
docker compose restart backend
 
# RIGHT - rebuild forces a new image
docker compose up -d --build backend

Reason: Python compiles to bytecode at build time, lives in the image. Container restart = old bytecode. Rebuild = new bytecode.

Same for: TypeScript after tsc, Java after mvn package, Go after go build, anything pre-compiled in the image.

Trap #2: bind mount + per-inode reload

Caddy. I edit Caddyfile on host, run docker exec caddy caddy reload. Nothing.

Reason: most editors (vim, VSCode) save via "write to temp + rename". Rename changes the inode. Bind mount in Docker remembers the original inode. Docker sees the old file.

# Workaround:
docker restart caddy

Or edit via docker exec caddy vi (poor ergonomics). Or use tmpfs instead of bind (rename works but you lose persistence).

Trap #3: NO_PROXY and internal DNS

I have SOCKS5 proxy in some projects. One day all internal hostnames in docker compose started returning "name not resolved".

Reason: SOCKS5 proxy tries to resolve externally. Internal ones (e.g., bh-backend, hostname inside Docker network) don't exist there.

# .bashrc or /etc/environment
export NO_PROXY="172.16.0.0/12,localhost,127.0.0.1,*.local,bh-*,*.kamilkaletka.dev"

For every new project with a Docker network I add the hostname pattern to NO_PROXY.

Trap #4: depends_on vs wait-for-ready

depends_on: [postgres] tells Docker "start postgres first". It does NOT say "wait until postgres is ready for connections".

App starts 2 seconds after postgres, postgres needs 5 seconds to initialize → app crashes on "connection refused".

services:
  postgres:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10
 
  app:
    depends_on:
      postgres:
        condition: service_healthy

Healthcheck + condition: service_healthy solves. Longer boot, but no race conditions.

Trap #5: volumes vs bind mount for data

First version:

volumes:
  - ./data:/app/data

A year later I needed a backup. tar ./data, weighed 80GB. OK. Restore on a second machine. Permissions all broken.

Reason: bind mount inherits host permissions. Postgres (UID 999 inside container) wrote as UID 999, on a second host that user may not exist.

Better:

volumes:
  postgres-data:
    driver: local
 
services:
  postgres:
    volumes:
      - postgres-data:/var/lib/postgresql/data

Named volume. Docker manages permissions. Backup via docker run --rm -v postgres-data:/data alpine tar czf - /data | gzip > backup.tar.gz.

Trap #6: networks and the default bridge

Two composes. One runs service-a, the other service-b. I want them to see each other.

By default each compose creates its own network. service-a can't see service-b.

# compose-a.yml
services:
  service-a:
    networks:
      - shared-net
 
networks:
  shared-net:
    external: true
# compose-b.yml — same
services:
  service-b:
    networks:
      - shared-net
 
networks:
  shared-net:
    external: true

Network shared-net I create manually:

docker network create shared-net

After that both composes use the same network, hostnames resolve between them.

Bonus: ports and firewall

By default ports: ["3000:3000"] exposes on ALL host interfaces. In a homelab you usually don't want that.

# Localhost only — safer
ports:
  - "127.0.0.1:3000:3000"
 
# LAN only, no public WAN
ports:
  - "192.168.1.100:3000:3000"

If you have a Cloudflare tunnel as your only exposure, use 127.0.0.1: so the host firewall isn't a single point of failure either.

Anti-pattern: latest tag

image: postgres:latest    # WRONG
image: postgres:15.4      # RIGHT

:latest means "whatever Docker has cached right now". In a homelab this sometimes drops in postgres 16 instead of 15 and the migration breaks. Pin major.minor minimum.


Docker compose is great at homelab scale. But each of those 6 traps cost me an hour. ~6 hours of life lost total. If I save you even 2 hours, the post earns its place.