How To Create Containerizing Microservices With Docker?

Relia Software

Relia Software

Learn how to containerize microservices with Docker using practical Dockerfile patterns, multi-stage builds, Compose, and production-ready best practices.

How To Create Containerizing Microservices With Docker?

Once you've split a monolith into microservices, the next challenge is shipping each service so it runs the same way on your laptop, your teammate's laptop, staging, and production.

DevOps has never been my primary specialty as I spend most of my time on backend work in Go and TypeScript. But in practice, containerization has become table stakes for backend engineers. Anyone who cannot explain a Dockerfile will struggle in interviews and on real teams.

So this post is not a from-scratch Docker tutorial; the internet already has plenty of those. Instead, I want to focus on how to containerize a single service inside a larger microservices system using Docker, with practical choices around Dockerfiles, multi-stage builds, image size, configuration, and production pitfalls I have hit along the way.

>> Read more: Docker Networking Fundamentals: Types, Working and Usage

What is Microservices Containerization?

Containerizing a microservice means packaging the code, runtime, dependencies, and configuration of a single service into one immutable container image. That image then runs identically on any host.

A well-built microservice container should satisfy a handful of principles:

  • One container, one concern. Don't stuff the database, cache, and API server into the same image.
  • Stateless. Push state out to a database, object store, or message broker. The container should be disposable at any moment.
  • Immutable. Once built, the image doesn't change. Configuration is injected through environment variables at runtime.
  • As small as possible. Smaller images deploy faster and have a smaller attack surface.
  • Graceful shutdown. On SIGTERM, close connections cleanly rather than dropping in-flight requests on the floor.

These rules sound abstract on their own. Let's walk through a concrete example.

Start With a Basic Dockerfile for One Go Service

Suppose we have a Go microservice called order-service with the following structure:

docker
order-service/
├── cmd/
│   └── main.go
├── internal/
│   ├── handler/
│   └── repository/
├── go.mod
└── go.sum

A minimal Dockerfile for this service might look like this:

docker
dockerfile
FROM golang:1.22

WORKDIR /app
COPY . .
RUN go build -o order-service ./cmd

EXPOSE 8080
CMD ["./order-service"]

In the snippet above, I use golang:1.22 as the base image, copy the entire source tree in, and build. It runs, but the resulting image is close to 900MB. That's nearly a gigabyte to ship a single Go binary, which is unreasonable for anything you plan to deploy frequently.

This is where multi-stage builds come in.

>> Read more:

Use Multi-Stage Builds to Keep Images Small

The idea behind a multi-stage build is straightforward: use one "heavy" stage to compile, then copy only the binary into a much lighter stage that actually runs. The toolchain, source code, and build cache are left behind in the build stage.

The pattern looks like this:

docker
dockerfile
# Stage 1: builder
FROM golang:1.22-alpine AS builder

WORKDIR /app

# Cache dependencies first
COPY go.mod go.sum ./
RUN go mod download

# Then copy source and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -o /out/order-service ./cmd

# Stage 2: runtime
FROM alpine:3.19

RUN apk add --no-cache ca-certificates tzdata \
    && adduser -D -u 10001 app

WORKDIR /app
COPY --from=builder /out/order-service .

USER app
EXPOSE 8080
ENTRYPOINT ["./order-service"]

In the code above, the builder stage compiles the binary, and the runtime stage copies only that binary into a lean Alpine image. Two details matter here:

  • First, I separate go mod download from COPY . . so that Docker's layer cache can reuse dependencies whenever only the source code changes, on an unchanged go.mod, rebuilds become near-instant.
  • Second, CGO_ENABLED=0 with -ldflags="-w -s" produces a static, stripped binary that runs cleanly on a minimal base.

The result: the image drops from roughly 900MB to 15-20MB. That's the kind of change that pays back every single deployment.

Dockerfile Best Practices That Matter in Production

Anyone can write a Dockerfile. Writing one that holds up in production is a different story. A sloppy Dockerfile is still code, and bad code comes back to bite you.

Layer caching

Bad Practice:

docker
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

In the snippet above, any one-line code change busts the cache and forces npm install to run from scratch. On a large project, that's minutes of wasted build time per iteration.

Good Practice:

docker
FROM node:20-alpine
WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .
CMD ["node", "server.js"]

In the code above, I copy the manifest files and install dependencies before copying the rest of the source.

When dependencies don't change, Docker reuses the cached layer and the build is nearly instant. This matters because build time directly affects CI feedback loops, which in turn affect delivery pace, a technical choice with a management consequence.

Run as a non-root user

Bad Practice:

docker
FROM alpine:3.19
COPY order-service /app/
CMD ["/app/order-service"]

By default the container runs as root. If anyone compromises your service, they inherit root inside the container, which dramatically widens what they can do next.

Good Practice:

docker
FROM alpine:3.19

RUN adduser -D -u 10001 app
COPY --chown=app:app order-service /app/

USER app
CMD ["/app/order-service"]

In the code above, I create an unprivileged app user with a fixed UID and switch to it before running the service. One line of change, materially better security posture.

Inject config via environment, don't hardcode

Bad Practice:

docker
ENV DB_HOST=prod-db.internal
ENV DB_PASSWORD=supersecret123

Embedding a password in a Dockerfile is equivalent to writing it on a sticky note. Once the image is pushed to a registry, anyone with pull access can read it.

Good Practice:

docker
ENV DB_HOST=""
ENV DB_PORT=5432
ENV APP_ENV=production

In the code above, I only bake in non-sensitive defaults. Secrets like passwords, API keys, tokens are injected at runtime by the orchestrator (Kubernetes Secrets, Docker Secrets, AWS Parameter Store, or similar).

Docker Compose for Local Development

A microservice rarely lives alone. order-service needs to talk to payment-service, plus Postgres and Redis to run end to end. Forcing every developer to run four or five docker run commands each morning is a waste of time and an easy way to introduce environment drift.

Docker Compose solves this cleanly. Here's a representative docker-compose.yml:

yaml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: orders
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s

  order-service:
    build: ./order-service
    environment:
      DB_HOST: postgres
      DB_PORT: 5432
      REDIS_HOST: redis
      APP_ENV: local
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "8080:8080"

volumes:
  pgdata:

One point worth noting here: depends_on with condition: service_healthy ensures order-service only starts after Postgres is actually ready to accept connections, not merely after its container has started. Those two states are not the same.

In my experience, this distinction has cost teams real debugging hours — the service boots faster than the database, retry logic isn't in place yet, and the service crashes on startup with a misleading error. Running docker compose up brings the full stack online in seconds, and the healthcheck wiring keeps startup deterministic.

Production Pitfalls to Avoid When Containerizing Microservices

A few traps I've walked into personally, so you don't have to:

  • Not setting GOMAXPROCS (or equivalent) when the container has a CPU limit. The Go runtime reads the number of cores from the host, not from cgroups. The result is a service spawning far more scheduling goroutines than it has CPU for. Use the automaxprocs library or set GOMAXPROCS explicitly to match the container's allocation.
  • Writing logs to files inside the container. Logs should go to stdout and stderr so the container runtime can ship them to a log aggregator (Loki, ELK, CloudWatch). Writing to a file eventually fills the disk, the container crashes, and nobody knows why.
  • Using a healthcheck that depends on curl inside an Alpine image that doesn't ship curl. Either install it explicitly, or implement the healthcheck in the service's own binary (for example, ./order-service healthcheck).
  • Pinning to latest in production. Today's latest is 1.2, tomorrow's is 1.3 — and rollbacks become guesswork. Pin to an explicit semver or image digest.
  • Building images on a developer laptop and pushing directly. Production builds belong in CI, with an SBOM and a vulnerability scan (Trivy, Grype, or similar). I'll cover CI/CD for microservices in a separate post.

Conclusion

Containerizing microservices with Docker is more than writing a Dockerfile and running docker build. It's a deliberate set of decisions about what the service needs to run, how small and secure it should be, and how it fits into a larger system alongside other services.

Nail multi-stage builds to keep images lean, structure your layers so the cache actually works for you, drop privileges by running as a non-root user, and keep secrets out of the image, get those four right from day one and the path to production gets dramatically shorter.

>>> Follow and Contact Relia Software for more information!

  • coding
  • development
  • Designing an application