Docker Intermediate
Multi-stage builds, health checks, service-healthy depends_on, volume backups, and .dockerignore — shrink your image and make your stack reliable.
Why Your Image Is Too Big
You've built a working Dockerfile. It installs Composer, copies source files, and starts PHP-FPM. It runs. But here's the problem: your final image probably contains Composer itself, git, and a bunch of build-time tools that have absolutely no business being in production.
Check the size of a naïve PHP image built with Composer installed directly:
docker images | grep myapp
# myapp latest ... 420MBA production PHP-FPM image should be around 80–120MB. The rest is dead weight that enlarges your registry storage, slows your CI pipeline, and expands your attack surface.
Multi-stage builds fix this.
Learning Objectives
- Write a two-stage Dockerfile that separates
buildfromruntime - Add
HEALTHCHECKso Docker knows when a service is actually ready - Use
condition: service_healthyin Compose so containers wait for each other properly - Understand Docker's network model at the DNS level
- Back up a named volume without stopping any containers
- Write a
.dockerignorethat keeps your images clean
Multi-Stage Builds
A multi-stage build uses multiple FROM blocks in one Dockerfile. Each FROM starts a fresh layer chain. You can COPY --from=<stage> to pull files from a previous stage into the current one. The final image is built from only the last stage.
Here's the pattern for DALT.PHP:
# ---- Stage 1: builder ----
# Uses composer:2, which has PHP + Composer pre-installed
FROM composer:2 AS builder
WORKDIR /app
# Copy only what Composer needs first — layer caching wins here
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --optimize-autoloader
# ---- Stage 2: runtime ----
# Starts fresh from a minimal PHP-FPM image — no Composer here
FROM php:8.2-fpm-alpine AS runtime
WORKDIR /var/www/html
# Install the OS packages and PHP extensions the app needs
RUN apk add --no-cache curl \
&& docker-php-ext-install pdo pdo_pgsql
# Pull ONLY the installed vendor directory from the builder stage
# Composer itself stays in the builder — it never reaches the runtime image
COPY --from=builder /app/vendor ./vendor
# Copy application source
COPY . .
EXPOSE 9000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD php-fpm -t || exit 1
CMD ["php-fpm"]What changed and why
| Single-stage | Multi-stage |
|---|---|
| Composer binary in final image | Composer stays in builder |
composer install in runtime layer | vendor/ copied as a static directory |
| One thick layer chain | Thin runtime layer chain |
| ~400MB image | ~90MB image |
The runtime image never sees composer, git, or any build tooling. If someone gets RCE inside the container, they can't use Composer to pull more code.
Layer caching with multi-stage
The caching strategy still applies inside each stage. Copy composer.json and composer.lock before the application source so that a code change doesn't re-run composer install:
# In builder stage:
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction # ← cached unless composer files change
COPY . . # ← app source, changes every commitHEALTHCHECK
HEALTHCHECK lets Docker periodically test whether a service inside a container is actually working, not just running. A container can be "running" (process alive) but the service inside it can be broken.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD php-fpm -t || exit 1Flags:
--interval=30s— run the check every 30 seconds--timeout=5s— if the check takes longer than 5 seconds, treat it as a failure--start-period=10s— don't count failures during the first 10 seconds (let the service boot)--retries=3— mark unhealthy only after 3 consecutive failures
For a Postgres service in Compose, use pg_isready:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10sCheck health status at any time:
docker inspect --format='{{.State.Health.Status}}' <container-id>
# healthydepends_on with condition: service_healthy
depends_on: db (without a condition) starts the app container as soon as the db container's process starts. Postgres takes a few seconds to initialize its data directory. Your PHP app will try to connect during that window and fail.
The fix: combine HEALTHCHECK on the db service with condition: service_healthy in the app service:
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
app:
build: .
depends_on:
db:
condition: service_healthy # ← waits for pg_isready to pass
env_file:
- .envNow app only starts once pg_isready returns healthy. No more connection-refused errors on first boot.
Docker Networking in Compose
When you run docker compose up, Compose creates a private bridge network and attaches all services to it. Each service gets a DNS name equal to its service name.
So from inside the app container:
# These are the same:
ping db # resolves to the db container's IP
ping nginx # resolves to the nginx container's IPThis is why your .env has DB_HOST=db instead of DB_HOST=localhost or an IP address. The service name is the hostname.
Inspect networks:
docker network ls
docker network inspect daltphp_defaultYou'll see a list of containers, their IPs, and their DNS aliases on the network.
Volume Backups Without Downtime
Named volumes persist data between docker compose down runs. But they're not automatically backed up. Here's how to snapshot a running Postgres volume without stopping anything:
docker run --rm \
-v pgdata:/data \
-v $(pwd):/backup \
busybox \
tar czf /backup/pgdata-$(date +%Y%m%d).tar.gz /dataWhat this does:
- Spins up a throwaway
busyboxcontainer - Mounts the
pgdatavolume at/data - Mounts the current directory at
/backup - Runs
tarto compress/datainto/backup/pgdata-20240101.tar.gz
To restore:
docker run --rm \
-v pgdata:/data \
-v $(pwd):/backup \
busybox \
tar xzf /backup/pgdata-20240101.tar.gz -C /Run this while the db container is stopped if you need a clean restore.
.dockerignore
Just as .gitignore keeps files out of git, .dockerignore keeps files out of the build context. Without it, every COPY . . sends your entire working directory — including vendor/, .git/, and log files — to the Docker daemon. That slows the build and inflates the image.
Create .dockerignore at the project root:
# Dependencies — rebuilt by Composer in the builder stage
vendor/
# Git history — never needed in an image
.git/
.gitignore
# Logs and caches
storage/logs/*.log
*.log
# SQLite databases used locally but not in Docker
database/*.sqlite
# Development-only config
.env.local
.env.testing
# Node.js tooling (not used at runtime)
node_modules/
npm-debug.log
# IDE config
.vscode/
.idea/After adding .dockerignore, run:
docker build -t myapp . --no-cacheWatch how much faster the build context upload step is.
Summary
| Concept | What it gives you |
|---|---|
| Multi-stage build | Smaller, cleaner runtime image with no build tools |
HEALTHCHECK | Docker tracks real service health, not just process state |
condition: service_healthy | App waits for Postgres to actually be ready |
| Service DNS in Compose | Containers talk to each other by name, not IP |
| Volume backup with busybox | Snapshot a live volume without downtime |
.dockerignore | Smaller build context, faster builds, no secrets in image |
Your Task
Load the broken Dockerfile:
php artisan challenge:start docker-missing-multistageThe Dockerfile is single-stage and installs Composer directly into the runtime image. Convert it to a multi-stage build:
- Add a
builderstage usingFROM composer:2 AS builder - Run
composer installin the builder stage - In the runtime stage, use
COPY --from=builder /app/vendor ./vendorinstead of running Composer - Add a
HEALTHCHECKinstruction to the runtime stage - Remove the
COPY --from=composer:2 /usr/bin/composer /usr/bin/composerline — it belongs in the builder, not runtime
Verify:
php artisan challenge:verifyNext Steps
- Challenge: docker-missing-multistage — convert a single-stage Dockerfile to multi-stage with a HEALTHCHECK
- Lesson 13: PostgreSQL Advanced — window functions, CTEs, JSONB, and full-text search