Docker Compose for small dev environments: a practical walkthrough

Docker Compose is one of those tools that quietly does exactly what you expect. For a small multi-service project, it removes most of the friction from spinning up a local dev environment that mirrors production. Everything gets declared in one YAML file, containers talk to each other by name, and you don't have to install or administer a dozen services on your laptop.

Here's how it works in practice for a Ruby on Rails backend with an nginx proxy, a Go server for proxying SSH connections, and a Postgres database.

The dev environment problem

The production setup for this project looks like this:

  • an nginx proxy
  • a Rails server
  • a Go server (which handles some SSH connections via gotty)
  • a Postgres database

Getting Rails running locally without containers was straightforward — install Postgres and Ruby. But the need to route /proxy/* to the Go server and everything else to Rails introduced nginx into the picture, and installing nginx locally felt intrusive. Docker Compose made that unnecessary.

One YAML file to declare everything

Docker Compose manages multiple Docker containers that can talk to each other, all configured in a single docker-compose.yml file:

version: "3.3"
services:
  db:
    image: postgres
    volumes:
      - ./tmp/db:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: password # yes I set the password to 'password'
  go_server:
    # todo: use a smaller image at some point, we don't need all of ubuntu to run a static go binary
    image: ubuntu
    command: /app/go_proxy/server
    volumes:
      - .:/app
  rails_server:
    build: docker/rails
    command: bash -c "rm -f tmp/pids/server.pid && source secrets.sh && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/app
  web:
    build: docker/nginx
    ports:
      - "8777:80" # this exposes port 8777 on my laptop

Some services use an existing image unchanged (image: postgres and image: ubuntu); others need a custom build. The build: docker/rails directive tells Compose to use the Dockerfile at docker/rails/Dockerfile to create a custom container image.

For the Rails server's API keys, source secrets.sh populates environment variables. There may be a more sophisticated way to handle secrets, but for a single-developer project this is sufficient.

Startup and service discovery

Starting the environment is two commands:

docker-compose build

to build the containers, then:

docker-compose up

to run everything. The depends_on key gives finer control over startup order, but for this small set of services the order doesn't matter, so the default behavior is fine.

Inter-container networking is where Compose earns its keep. A Rails server running in a container named rails_server on port 3000 is reachable at http://rails_server:3000 from other containers. That name resolution is wired into the nginx config:

location ~ /proxy.* {
    proxy_pass http://go_server:8080;
}
location @app {
    proxy_pass http://rails_server:3000;
}

The same mechanism works for the Rails app's database connection, using the service name db as the host:

development:
  <<: *default
  database: myproject_development
  host: db # <-------- this "magically" resolves to the database container's IP address
  username: postgres
  password: password

Behind the scenes, Docker runs its own DNS server. Each container resolves to its own IP address:

$ dig +short @127.0.0.11 rails_server
172.18.0.2
$ dig +short @127.0.0.11 db
172.18.0.3
$ dig +short @127.0.0.11 web
172.18.0.4
$ dig +short @127.0.0.11 go_server
172.18.0.5

How the DNS magic works

Digging slightly into the networking, the mechanism becomes visible. The key insight is that the DNS server runs inside the container's network namespace, not as something you configure separately.

Finding the server process. Using ps aux | grep puma to find the Rails server's PID, then nsenter to run netstat -tulpn in that same network namespace, reveals a UDP server listening on port 59426, owned by dockerd:

$ sudo nsenter -n -t 1837916 netstat -tulpn
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 127.0.0.11:32847        0.0.0.0:*               LISTEN      1333/dockerd        
tcp        0      0 0.0.0.0:3000            0.0.0.0:*               LISTEN      1837916/puma 4.3.7  
udp        0      0 127.0.0.11:59426        0.0.0.0:*                           1333/dockerd  

Confirming it's DNS. A dig query sent directly to that port confirms it's answering DNS requests:

$ sudo nsenter -n -t 1837916 dig +short @127.0.0.11 59426 rails_server
172.18.0.2

The port translation. But the actual DNS queries go to port 53, not 59426. The answer lies in iptables. Running iptables-save inside the container's network namespace shows the rule that redirects port 53 traffic to 59426:

$ sudo nsenter -n -t 1837916 iptables-save
.... redacted a bunch of output ....
-A DOCKER_POSTROUTING -s 127.0.0.11/32 -p udp -m udp --sport 59426 -j SNAT --to-source :53
COMMIT

Managing data and interactive tools

The Postgres container's data directory is mounted at ./tmp/db, so the dev database lives in the same folder as the rest of the code. No local Postgres installation to maintain, no configuration files to juggle.

Ruby version management can be a source of endless trouble. With this setup, the Rails console — a REPL with all project code loaded — is a single command away:

$ docker-compose exec rails_server rails console
Running via Spring preloader in process 597
Loading development environment (Rails 6.0.3.4)
irb(main):001:0> 

One gotcha: console history

Restarting the container repeatedly means losing IRB history. The fix is simple: add a /root/.irbrc file to the container redirecting the history file to a location that persists between restarts:

IRB.conf[:HISTORY_FILE] = "/app/tmp/irb_history"

Production: promising but unproven here

Production for this project is currently a manually configured DigitalOcean droplet. Moving to Docker Compose for production is tempting — the service will likely have only a couple of concurrent users, and a minute of downtime during deploys is acceptable. A few notes from people who have tried it:

  • docker-compose up restarts only changed containers, making deploys faster.
  • The wait-for-it script makes one container wait for another service to become available.
  • Separate docker-compose.yaml for dev and docker-compose-prod.yaml for production can expose different nginx ports (8999 for dev, 80 for prod).
  • For a small site on a single machine, Docker Compose is generally considered acceptable.
  • For slightly larger setups, Docker Swarm was suggested as a middle ground — Kubernetes is an option but defeats the simplicity goal.

Docker also offers a feature to deploy Compose setups directly to ECS, which sounds appealing but hasn't been tested here.

Where it falls short

Docker Compose isn't a universal solution. Known pain points include:

  • Very large numbers of microservices — keep the setup simple.
  • Large database datasets — hundreds of gigabytes on every laptop isn't practical.
  • Mac performance — Docker runs in a VM, which can be noticeably slower than on Linux.

The alternative tried before this was a Vagrant VM provisioned with Puppet, which turned out to be slow to start and unpleasant to configure. Docker Compose, by contrast, did what it promised without much ceremony.