</>CodeWithKarani

Docker, Finally: A Practical On-Ramp for Developers Who Kept Putting It Off

Karani GeoffreyKarani Geoffrey7 min read

You are not avoiding Docker, you are avoiding the vocabulary

Almost everyone I have coached through Docker was not scared of containers. They were tired of tutorials that open with cgroups and namespaces and union filesystems, none of which you need to ship anything. So let me skip all of it and give you the mental model that actually matters, in four sentences:

  • An image is a frozen filesystem plus a default command. Think of a zip file of a whole Linux install, with a note saying which program to run.
  • A container is one running process using that filesystem. Not a virtual machine. A process.
  • A Dockerfile is the recipe that builds an image. It is a shell script with caching.
  • A compose file describes several containers that need to talk to each other, so you do not type twelve flags.

That is the whole conceptual model. The thesis of this article: Docker is worth learning not because it is elegant, but because it makes "works on my machine" a checkable claim. On a small team where one person owns the server and the other has a different laptop and a different Postgres version, that is the entire value proposition.

The four commands you will actually type

# Build an image from the Dockerfile in this directory, name it
docker build -t myapp:dev .

# Run it, map port 8000 on your machine to 8000 in the container
docker run --rm -p 8000:8000 myapp:dev

# What is running?
docker ps

# Get a shell inside a running container to poke around
docker exec -it myapp-api-1 sh

The flag people miss is --rm, which deletes the container when it exits. Without it, every run leaves a dead container on disk, and in six months you wonder where 40GB went. When that day comes:

docker system df          # what is using space
docker system prune -a    # reclaim it (removes unused images too)

A Dockerfile that is not a toy

Here is a real multi-stage Node build. Read the comments, they are the lesson:

# syntax=docker/dockerfile:1
# The syntax line opts you into current BuildKit features (cache mounts, heredocs).

# ---- build stage ----
FROM node:22-bookworm-slim AS build
WORKDIR /app

# Copy ONLY the manifests first. This layer is cached until your
# dependencies change, so editing source code does not reinstall node_modules.
COPY package.json package-lock.json ./

# A cache mount keeps the npm cache between builds without
# baking it into the image.
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build

# ---- runtime stage ----
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app

RUN --mount=type=cache,target=/root/.npm \
    --mount=type=bind,source=package.json,target=package.json \
    --mount=type=bind,source=package-lock.json,target=package-lock.json \
    npm ci --omit=dev

COPY --from=build /app/dist ./dist

# node:* images ship a non-root "node" user. Use it.
USER node
EXPOSE 8000
CMD ["node", "dist/server.js"]

Three things in there are the difference between a 90-second build and a 12-second one:

  1. Copy manifests before source. Docker caches each instruction. If you write COPY . . before npm ci, then changing one line of CSS invalidates the dependency install. This is the single most common Dockerfile mistake and it costs teams hours per week.
  2. Multi-stage build. Your compiler, dev dependencies and build tooling stay in the build stage. The shipped image contains only the runtime and the output. Smaller image, smaller attack surface, faster deploys over a slow uplink.
  3. Cache mounts. The npm cache persists across builds on the same machine but never lands in the final image.

The Python equivalent, since half of you are here for that:

# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app

COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

COPY . .

RUN useradd --create-home --uid 10001 appuser
USER appuser

CMD ["gunicorn", "-b", "0.0.0.0:8000", "app.wsgi:application"]

PYTHONUNBUFFERED=1 is not optional. Without it, Python buffers stdout, your logs appear minutes late or not at all when the container dies, and you will debug a phantom.

.dockerignore is not optional either

Every build sends your working directory to the Docker daemon. If node_modules and .git are in there, you are shipping hundreds of megabytes on every single build, and worse, you may be baking your .env into the image.

.git
.gitignore
node_modules
__pycache__
*.pyc
.venv
.env
.env.*
dist
build
coverage
*.log
.DS_Store
README.md

I have seen a production image with a committed .env containing live database credentials, because the team had a .gitignore and assumed Docker read it. It does not. Different file, different mechanism.

Compose: the file that makes it click

Most people "get" Docker at the moment they run one command and get an API, a database and a cache, all wired together. Note that the version: key at the top is obsolete in Compose v2 and will just print a warning. Delete it.

name: myapp

services:
  api:
    build:
      context: .
      target: runtime
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
      REDIS_URL: redis://cache:6379/0
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: package.json

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

  cache:
    image: redis:7-alpine

volumes:
  pgdata:

Then:

docker compose up -d           # start everything in the background
docker compose logs -f api     # tail one service
docker compose exec db psql -U app app
docker compose ps              # status and health
docker compose down            # stop and remove (volumes survive)
docker compose down -v         # ... and nuke the data too
docker compose watch           # live sync source into the container

Note it is docker compose, two words. The hyphenated docker-compose is the retired Python v1 tool. If a tutorial uses the hyphen throughout, it predates 2022 and other details in it are probably stale too.

The depends_on with condition: service_healthy is the piece that stops the classic race where your API starts, tries to connect to Postgres, and dies because Postgres is still initialising. Compose v1 only waited for the container to start, not to be ready. This is fixed now, but only if you write a healthcheck.

The five things that will confuse you

SymptomCauseFix
App cannot reach the database at localhostInside a container, localhost is the container itselfUse the service name as the hostname: db, cache
Server starts but browser cannot connectApp bound to 127.0.0.1 inside the containerBind to 0.0.0.0
Files created in a mounted volume are owned by rootContainer runs as root by default on LinuxCreate and USER a non-root user with a matching UID
Deploy pulled a different app than you testedYou used the latest tagTag with the git SHA and deploy that exact tag
Every build takes eight minutesCOPY . . before the dependency installCopy manifests first, then install, then copy source

What Docker will not fix

Being honest is more useful than being enthusiastic. Docker does not make your app faster - there is a small overhead, mostly on filesystem-heavy work and especially on macOS where bind mounts go through a virtualisation layer. It does not give you high availability. It does not secure anything by itself; a container running as root with the Docker socket mounted is effectively root on the host. And it adds a build step to your feedback loop, which is why docker compose watch exists.

What it does give you is a single artifact that runs identically on your laptop, your teammate's laptop, CI, and the VPS. On a two-person team, that removes an entire genre of bug from your life.

How to actually adopt it this week

Do not containerise everything at once. Do it in this order:

  1. Day one: dependencies only. Put Postgres and Redis in a compose file. Keep running your app natively, pointed at those containers. You get the biggest win (no more "which Postgres version do you have") with zero risk.
  2. Day two or three: containerise the app for local dev. Get compose up working end to end. Fight the volume permission issues now, on your laptop, not on the server.
  3. Later: use the same image in CI. Build it in your pipeline and run the test suite inside it. If it passes there, it passes on the server.
  4. Last: deploy it. By this point the image is proven and deployment is boring, which is the goal.

If you never make it past step one, you have still deleted a recurring source of wasted afternoons. That is a fine place to stop.

#docker#devops#compose#developer-experience#engineering-craft
Keep reading

Related articles