05 · Deployment (Docker)¶
Verification note
Docker isn't available in the environment these lessons were verified in, so the Dockerfiles and commands below were reviewed manually rather than actually built and run. The Ruby, Sinatra, and shell syntax follows the standard, well-documented patterns from Docker's own Ruby image documentation — treat this module's Ruby application code (which runs fine outside Docker) as verified, and the container-specific instructions as reviewed-not-executed.
A Docker image packages your Ruby app together with its exact runtime — the Ruby interpreter version, system libraries, and gems — into one artifact that runs identically on your laptop, in CI, and in production. "Works on my machine" stops being a category of bug once the "machine" is a container image everyone runs the same way.
A minimal Dockerfile for a Sinatra app¶
# Dockerfile
FROM ruby:3.3-slim
WORKDIR /app
# Install gems first, in their own layer, so Docker only re-runs
# `bundle install` when Gemfile/Gemfile.lock actually change —
# not on every code edit.
COPY Gemfile Gemfile.lock ./
RUN bundle install --without development test
COPY . .
EXPOSE 4567
CMD ["ruby", "app.rb", "-o", "0.0.0.0"]
-o 0.0.0.0 matters: Sinatra binds to localhost by default, which
inside a container means "only reachable from inside the container
itself" — 0.0.0.0 binds to all interfaces so the port mapped out to
the host (-p 4567:4567) actually reaches the process.
Layer caching — why COPY Gemfile* / RUN bundle install comes first¶
Docker builds an image in layers, each cached independently. If
COPY . . (copying the whole app) came before bundle install, then
editing any file — even a comment in a view template — would invalidate
the cache for the following bundle install layer, forcing a full
gem reinstall on every single build. Copying only Gemfile/Gemfile.lock
first means that layer's cache only invalidates when dependencies
actually change, which is the whole reason for the seemingly odd
two-step COPY in the Dockerfile above.
Multi-stage builds — smaller production images¶
Compiling native gem extensions (like bcrypt or sqlite3 in earlier
modules) needs build tools (gcc, headers) that bloat the final image
and aren't needed at runtime. A multi-stage build compiles in one stage
and copies only the result into a lean final image:
# Stage 1: build
FROM ruby:3.3-slim AS builder
WORKDIR /app
RUN apt-get update -qq && apt-get install -y build-essential libsqlite3-dev
COPY Gemfile Gemfile.lock ./
RUN bundle install --deployment --without development test
# Stage 2: runtime
FROM ruby:3.3-slim
WORKDIR /app
RUN apt-get update -qq && apt-get install -y libsqlite3-0 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/vendor/bundle /app/vendor/bundle
COPY . .
ENV BUNDLE_PATH=vendor/bundle
EXPOSE 4567
CMD ["ruby", "app.rb", "-o", "0.0.0.0"]
The final image never contains build-essential or the -dev headers —
only the compiled gem binaries needed to actually run, plus the
lightweight libsqlite3-0 runtime library instead of the full
libsqlite3-dev development package.
Environment variables and configuration¶
Following the security module's rule against hardcoded secrets, a
containerized app reads config from the environment, injected at
docker run time rather than baked into the image:
$ docker run -p 4567:4567 \
-e DATABASE_URL="postgres://user:pass@db-host/mydb" \
-e RACK_ENV="production" \
task-api
The same image runs in staging and production unchanged — only the
environment variables passed at docker run (or in your orchestrator's
config) differ, which is the whole point of building one image and
promoting it through environments rather than rebuilding per-environment.
docker-compose — app plus its database, together¶
A real app usually needs a database alongside it. docker-compose
describes both as one unit:
# docker-compose.yml
services:
web:
build: .
ports:
- "4567:4567"
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/task_api
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
depends_on starts db before web, but does not wait for
Postgres to actually finish initializing and accept connections — a
common gotcha covered below. db as the hostname in DATABASE_URL
works because Compose puts both services on the same private network,
where each service is reachable by its service name.
Docker-specific traps¶
depends_ononly orders container start, not service readiness. Postgres's container can be "running" for several seconds before it's actually accepting connections — a Rails/Sinatra app that connects immediately on boot can crash-loop against a database that technically "started" but isn't ready yet. Production setups add an explicit healthcheck or a retry-with-backoff on the app's database connection instead of assumingdepends_onmeans "ready."- Binding to
localhostinstead of0.0.0.0inside the container. The app appears to work when youdocker execinto the container and curl it locally, but is completely unreachable from outside — becauselocalhostinside a container refers only to that container's own loopback interface. - Baking secrets into the image with
ENVin the Dockerfile instead of passing them atdocker run/compose time. Anyone who can pull or inspect the image (docker history) can read a value baked in withENV SECRET_KEY=abc123directly in the Dockerfile. - Not pinning the base image tag.
FROM ruby:latestmeans the exact Ruby version running in production silently shifts wheneverlatestis rebuilt upstream — pin a specific version (ruby:3.3-slim, or even more preciselyruby:3.3.4-slim) so a build today and a build next month use the identical base. - Forgetting a
.dockerignore. Without one,COPY . .copies.git,log/,tmp/, and local.envfiles into the image — bloating it and potentially leaking local secrets that happened to be in an untracked.envfile into a shipped image.
How It Actually Works¶
A Docker image layer is a filesystem diff, and each instruction in your
Dockerfile (RUN, COPY) creates one — this is why ordering matters for
build caching: Docker hashes each instruction plus its inputs, and reuses a
cached layer only if every prior instruction's hash also matched, so
COPY Gemfile* / bundle install before COPY . . lets Docker reuse the
(often slow) bundle install layer whenever only application code changed,
not the Gemfile. Inside the running container, your Ruby process is just an
ordinary MRI process — the container gives it an isolated view of
processes, network, and filesystem via Linux namespaces and cgroups, but
MRI itself has no idea it's containerized; the GVL, GC, and thread
scheduler behave identically to running outside Docker. Multi-stage builds
matter because gems with native C extensions need build tools (a compiler,
headers) only during bundle install; copying just the compiled
vendor/bundle output into a slim final-stage image avoids shipping the
entire build toolchain in your production image, shrinking it substantially.
Cheat sheet¶
| Task | Command / directive |
|---|---|
| Base image | FROM ruby:3.3-slim |
| Set working directory | WORKDIR /app |
| Install gems (cached layer) | COPY Gemfile* ./ then RUN bundle install |
| Copy app code | COPY . . |
| Document the listening port | EXPOSE 4567 |
| Container entry point | CMD ["ruby", "app.rb", "-o", "0.0.0.0"] |
| Build an image | docker build -t name . |
| Run with a port mapped | docker run -p 4567:4567 name |
| Pass an env var at runtime | docker run -e KEY=value name |
| Multi-service local dev | docker-compose up |
| Ignore files from the build context | .dockerignore |
Exercise¶
- Write a
Dockerfilefor the Level 3 REST API capstone project (Sinatra + ActiveRecord + SQLite), including a.dockerignoreexcluding at least.git,spec/, and any local*.sqlite3file. - Convert it to a multi-stage build, moving any native-extension build
dependencies (SQLite's dev headers) into a separate
builderstage. - Write a
docker-compose.ymlrunning the API alongside aredisservice (even if unused by the app yet), demonstratingdepends_onand an environment variable pointing at the Redis service's hostname. - In a comment at the bottom of your
docker-compose.yml, explain what change you'd make to guarantee the app doesn't try to connect to Redis before it's actually ready to accept connections.