Docker Compose for Local Dev: Profiles, Healthchecks, and One-Command Environments

Docker Compose for Local Dev: Profiles, Healthchecks, and One-Command Environments

Most teams use Docker in production, then struggle locally with “works on my machine” problems: mismatched databases, forgotten env vars, slow boot order, and five separate terminal commands just to start the stack. This article shows a practical way to run a web app locally with docker compose using:

  • Profiles to switch optional services on/off (e.g., Redis, Mailhog) without editing files
  • Healthchecks + depends_on conditions so your app waits for the database correctly
  • Dev-friendly containers with fast rebuilds and live code mounts
  • One-command workflow via a tiny Makefile

The examples use a generic Node/Express app, but the same patterns work for Laravel, FastAPI, Rails, etc.

Project layout

Here’s a simple structure:

myapp/ docker/ app/ Dockerfile docker-entrypoint.sh src/ index.js package.json .env compose.yml Makefile 

Step 1: A dev-friendly Dockerfile

For local dev, you typically want:

  • Dependencies installed inside the image (so “it works” everywhere)
  • Your source code mounted as a volume (so edits reflect immediately)
  • A non-root user (optional but good hygiene)
# docker/app/Dockerfile FROM node:20-slim WORKDIR /app # Install deps first (better caching) COPY package.json package-lock.json* ./ RUN npm ci # Copy the rest (used for non-mounted environments) COPY . . # Helpful defaults for dev ENV NODE_ENV=development EXPOSE 3000 CMD ["npm", "run", "dev"] 

Why copy deps first? Docker caches layers. If you change application code often but dependencies rarely, the npm ci layer stays cached and rebuilds are much faster.

Step 2: Compose with Postgres + healthchecks

This compose.yml sets up a web container and Postgres. The key parts are:

  • healthcheck on the database
  • depends_on with condition: service_healthy so the app doesn’t start too early
  • Volume for Postgres data
# compose.yml name: myapp services: db: image: postgres:16 environment: POSTGRES_USER: myapp POSTGRES_PASSWORD: myapp POSTGRES_DB: myapp ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"] interval: 2s timeout: 2s retries: 20 web: build: context: . dockerfile: docker/app/Dockerfile environment: DATABASE_URL: postgres://myapp:myapp@db:5432/myapp PORT: "3000" ports: - "3000:3000" volumes: - ./:/app - /app/node_modules depends_on: db: condition: service_healthy volumes: pgdata: 

Two volume notes:

  • ./:/app mounts your source code so file edits update instantly.
  • /app/node_modules is an anonymous volume to avoid your host OS overwriting container-installed dependencies.

Step 3: Add optional services with profiles

Profiles let you enable extra services only when needed. Here’s Redis and a local mail catcher (Mailpit) behind profiles:

# compose.yml (add these under services:) redis: image: redis:7 ports: - "6379:6379" profiles: ["cache"] mail: image: axllent/mailpit:latest ports: - "8025:8025" # Web UI - "1025:1025" # SMTP profiles: ["mail"] 

Now you can run:

# core stack only docker compose up --build # include redis docker compose --profile cache up --build # include redis + mailpit docker compose --profile cache --profile mail up --build 

This keeps your default stack lean while still making “full environment” easy.

Step 4: A small entrypoint for migrations (optional, but common)

If your app needs to run database migrations on start, put that logic in a script. Example:

#!/usr/bin/env sh # docker/app/docker-entrypoint.sh set -e echo "Starting web container..." echo "DATABASE_URL=$DATABASE_URL" # Example migration command (replace with your framework/tooling) # npm run migrate exec "$@" 

Make it executable:

chmod +x docker/app/docker-entrypoint.sh 

Then wire it in your Dockerfile:

# docker/app/Dockerfile (add before CMD) COPY docker/app/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh ENTRYPOINT ["docker-entrypoint.sh"] CMD ["npm", "run", "dev"] 

If you’re using Prisma, Django, Laravel, etc., this is the right place to run migrations (or to run them via a separate one-off command—more on that below).

Step 5: Add one-off commands (migrate, seed, tests)

Compose makes “run a command in the web container” simple. Examples:

# run migrations (replace with your real command) docker compose run --rm web npm run migrate # seed data docker compose run --rm web npm run seed # run tests docker compose run --rm web npm test 

Tip: Use --rm so you don’t leave stopped containers around.

Step 6: A Makefile for a clean workflow

New team members should be able to start the whole stack without memorizing commands. A tiny Makefile is perfect:

# Makefile .PHONY: up down logs ps rebuild migrate seed test cache mail up: docker compose up --build down: docker compose down --remove-orphans logs: docker compose logs -f --tail=200 ps: docker compose ps rebuild: docker compose build --no-cache migrate: docker compose run --rm web npm run migrate seed: docker compose run --rm web npm run seed test: docker compose run --rm web npm test cache: docker compose --profile cache up --build mail: docker compose --profile mail up --build 

Now your docs can simply say:

  • make up to start
  • make migrate after pulling new changes
  • make logs when debugging

Step 7: A minimal app example to prove it works

Here’s a tiny Express server that reads DATABASE_URL and exposes a health endpoint. (It won’t actually query Postgres without a driver, but it shows the wiring.)

// src/index.js import express from "express"; const app = express(); const port = process.env.PORT || 3000; app.get("/health", (req, res) => { res.json({ ok: true, databaseUrlPresent: Boolean(process.env.DATABASE_URL), }); }); app.listen(port, () => { console.log(`Listening on http://0.0.0.0:${port}`); }); 

If your package.json uses a dev runner like nodemon:

{ "type": "module", "scripts": { "dev": "nodemon --legacy-watch src/index.js" }, "dependencies": { "express": "^4.19.2" }, "devDependencies": { "nodemon": "^3.1.0" } } 

Then run make up and open http://localhost:3000/health.

Common pitfalls and fixes

  • “My app starts before Postgres is ready.”
    Use a healthcheck on the DB and depends_on: condition: service_healthy (as shown). Avoid random sleep 5 hacks.

  • “File watching doesn’t work in Docker.”
    Some tools need polling or legacy watch mode when running inside containers, especially on macOS/Windows. For Node, nodemon --legacy-watch can help. For others, search for a “polling” option.

  • “My container can’t connect to localhost.”
    Inside Compose, services talk via service names. Use db:5432, not localhost:5432.

  • “Permissions issues on mounted volumes.”
    If you run into this, consider running as a user matching your host UID/GID, or keep writable build artifacts inside the container rather than on the mounted path.

A simple standard you can reuse across projects

If you adopt just these conventions, local environments get dramatically smoother:

  • compose.yml with core services always on
  • Profiles for optional tooling (cache, mail, observability)
  • DB healthcheck + depends_on condition
  • One-off tasks via docker compose run --rm
  • Makefile commands that read like documentation

Once you have this pattern in one repo, you can copy it to the next project, swap images (MySQL instead of Postgres, etc.), and keep the same developer experience—fast, repeatable, and easy for new teammates.


Leave a Reply

Your email address will not be published. Required fields are marked *