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 uprestarts only changed containers, making deploys faster.- The
wait-for-itscript makes one container wait for another service to become available. - Separate
docker-compose.yamlfor dev anddocker-compose-prod.yamlfor 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.



