Docker Done Right For Busy DevOps Teams
Practical habits, safer defaults, and fewer 2 a.m. surprises
Why Docker Still Earns Its Place
We’ve all heard the grumbles: “containers are old news”, “Kubernetes ate everything”, “just use serverless and go home”. And yet, most teams we work with still rely on docker every single day because it solves a very ordinary, very valuable problem: making software behave the same way from laptop to CI to production. That consistency is not glamorous, but it pays the bills.
Docker gives us a clean boundary around an app and its runtime. We package the code, dependencies, and startup command into an image, then run that image anywhere a container runtime exists. That means fewer “works on my machine” moments and less time chasing missing libraries across environments. It also means onboarding gets easier. A new teammate can pull an image or run a compose file instead of rebuilding the universe from a wiki page last updated during a coffee shortage.
The real win, though, is operational discipline. Containers nudge us toward smaller units, clearer dependencies, and repeatable deployments. They’re not magic. If we cram bad habits into an image, docker will faithfully deliver those bad habits at speed. But used well, it gives us a tidy packaging format and a predictable execution model that supports better engineering.
If we’re building modern services, it’s worth remembering that docker is part of a larger supply chain story too. Image provenance, signing, scanning, and registry controls matter as much as startup speed. Tools and standards from the Docker docs, the Open Container Initiative, and guidance from NIST’s container security work all point in the same direction: containers are useful, but only when we run them with intent.
Start With Small, Boring Images
If we want fewer vulnerabilities, faster pulls, and less head-scratching, we should start by keeping images small and unsurprising. “Small” here doesn’t mean we turn every image into a hand-crafted puzzle box. It means we only include what the app actually needs.
A common mistake is building from a giant base image because it feels convenient. That convenience gets expensive later. Bigger images take longer to move through CI, consume more registry storage, and often include packages we never use but still have to patch. We prefer minimal official images when possible, and we pay attention to whether we need a full distro, a slim variant, or a distroless runtime. The answer depends on the app and debugging needs, not on internet bragging rights.
Multi-stage builds help a lot. We compile in one stage with all the build tools present, then copy only the runtime artifacts into a final image. That keeps the production image cleaner and reduces attack surface without heroic effort.
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app ./cmd/api
# Runtime stage
FROM alpine:3.20
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /src/app /app/app
USER app
EXPOSE 8080
ENTRYPOINT ["/app/app"]
We should also pin versions instead of using vague tags like latest, and we should review image layers with tools such as Docker Scout, Trivy, or registry-native scanners. A smaller image won’t fix everything, but it gives us less baggage to carry around. That’s a decent trade, and unlike many “best practices”, it doesn’t require chanting at a dashboard.
Write Dockerfiles Future Us Can Read
A dockerfile is not just a build recipe. It’s a living operational document. If it’s messy, production won’t become more elegant out of politeness. So we write dockerfiles that are obvious, stable, and easy to maintain.
The first rule is to optimise for readability before tiny gains. Group related instructions, use comments where intent isn’t obvious, and order layers to maximise build cache usefulness. Dependency manifests should be copied before application code when possible, so changes to source files don’t invalidate expensive install steps. We also avoid stuffing shell logic into one giant RUN line that looks like it was assembled during a fire drill.
A few defaults save a lot of pain. We set WORKDIR explicitly. We use exec-form ENTRYPOINT or CMD so signals behave properly. We avoid running as root unless there is a real reason. We keep environment variables predictable and avoid baking secrets into image layers. That last one is still more common than we’d like, which is impressive in the same way stepping on a rake is impressive.
It also helps to use a .dockerignore file. Sending the entire repo, local caches, and random test artifacts into the build context slows everything down and can leak things we never meant to package.
# .dockerignore
.git
node_modules
dist
coverage
.env
*.log
tmp
Dockerfile*
docker-compose*
For teams with several services, we like standardising dockerfile patterns across repos. Not to create bureaucracy, just to reduce surprises. A familiar layout makes reviews faster and incident response calmer. The best practices guide from Docker is still worth bookmarking, and build improvements from BuildKit are worth adopting once the basics are clean. Fancy tricks are fine. Predictable builds are better.
Security Is Mostly About Defaults
Container security often gets framed as an advanced speciality, but most of the gains come from very plain habits. We don’t need a dramatic “zero trust moonshot” to improve docker security. We need safer defaults, better checks, and fewer exceptions.
First, we treat images as supply chain artifacts, not just convenient tarballs with branding. That means using trusted base images, pinning versions, scanning dependencies, and rebuilding regularly to pick up patched packages. If an image hasn’t been rebuilt in months, it’s probably collecting vulnerabilities like a shed collects spiders.
At runtime, we tighten privileges. Many containers do not need root, extra Linux capabilities, writable filesystems, or access to the host network. Removing those defaults reduces blast radius when something goes wrong. We should also think about secrets handling. Environment variables are common, but secret managers, mounted files, and orchestrator-native secret stores are often safer than burying credentials in image layers or compose files.
Here’s a straightforward docker run example with stricter settings:
docker run -d \
--name web \
--read-only \
--cap-drop all \
--security-opt no-new-privileges:true \
--pids-limit 200 \
--memory 256m \
--cpus 1 \
-p 8080:8080 \
myorg/web:1.4.2
This won’t make an insecure app magically secure, but it narrows the room for mistakes. We should add signing and provenance where our tooling supports it, and enforce checks in CI so insecure images don’t drift toward production out of sheer momentum. The CIS Docker Benchmark, Snyk’s container guidance, and Docker’s own hardening advice are useful references. Security isn’t a separate phase after docker. It’s baked into the choices we make before the first container starts.
Docker Compose Makes Local Development Sane
For local development, docker compose remains one of the most useful bits of kit around. It gives us a simple way to define multi-container applications without turning every laptop into a tiny platform engineering project. When an app depends on a database, cache, message broker, and maybe one awkward legacy service that only speaks in riddles, compose keeps that setup manageable.
The beauty is in describing the whole stack as code. We can version it, review it, and share it across the team. New developers don’t need a 37-step setup guide with a note saying “step 19 may fail, just try again”. They run one command and get a consistent environment. That’s not just nice for onboarding; it also helps us reproduce bugs that only appear when services interact.
A basic example might look like this:
services:
app:
build: .
ports:
- "8080:8080"
environment:
APP_ENV: development
DB_HOST: db
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: app
POSTGRES_PASSWORD: changeme
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
We should still be deliberate. Compose files can sprawl if we overload them with production concerns, debug hacks, and one-off overrides nobody understands. We prefer a clean base compose file plus environment-specific overrides when needed. Health checks, named volumes, and explicit networks are worth using where they add clarity.
Compose is documented well in the official manual, and it plays nicely with tools like Dev Containers for editor-based workflows. For small teams especially, compose remains a very practical bridge between local development and CI. No drama, no giant control plane, just reproducible service wiring that saves us time.
Observability Starts Inside The Container
When a container fails, docker itself is often the least interesting part of the problem. The app crashed, the dependency timed out, the process ignored signals, or the logs vanished into the void. That’s why observability needs attention from the start.
We begin with logs. Applications should log to stdout and stderr rather than writing to local files inside the container. Docker already knows how to collect stream output, and every orchestrated environment expects this pattern. File-based logs inside ephemeral containers are mostly a creative way to lose information. Structured logs help too, especially when several services are chatting at once and all of them insist they’re fine.
Metrics and health checks matter just as much. A container being “up” does not mean the service is useful. We add health endpoints where appropriate and make sure readiness checks reflect actual dependency state, not wishful thinking. If startup takes time, we account for that instead of creating a restart loop and calling it resilience.
Signal handling is another quiet trouble spot. If PID 1 inside the container doesn’t forward signals correctly, graceful shutdown gets messy. That can lead to dropped requests, half-written jobs, or slow deployments. Using exec-form commands and lightweight init support where needed helps.
For teams operating at scale, we connect container workloads to broader telemetry systems: logs to a central store, metrics to dashboards, traces to a tracing backend. Standards from OpenTelemetry make this less painful than it used to be. We also keep basic docker troubleshooting skills sharp: docker logs, docker inspect, docker stats, and docker exec still solve a shocking number of mysteries. Not all of them, of course. Some bugs remain committed to performance art. But good observability means we spend less time guessing and more time fixing.
CI/CD With Docker Should Be Predictable
Docker in CI/CD should make delivery more repeatable, not more theatrical. If our pipelines are slow, flaky, or depend on odd runner state, the container format won’t save us. We need builds that are deterministic, testable, and easy to trace from commit to deployment.
A good starting point is to build images in a consistent way across environments. That means using the same dockerfile, enabling caching where practical, and tagging images with immutable identifiers such as commit SHAs. Human-friendly tags like staging or prod are useful pointers, but they shouldn’t be the only breadcrumb we leave behind. When something breaks, we want to know exactly what image was shipped.
Pipelines should include image scanning, unit and integration tests, and some policy checks before pushing artifacts forward. We also prefer failing fast. There’s no glory in waiting ten minutes to discover the base image couldn’t be pulled. Build provenance and signing are becoming more important too, especially for regulated environments or larger supply chains.
A lightweight GitHub Actions example keeps the idea concrete:
name: build
on: [push]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/acme/api:${{ github.sha }}
We can build from here with scanning, SBOM generation, and promotion workflows. The GitHub Actions docker examples, Buildx documentation, and supply-chain projects under the Sigstore umbrella are all worth a look. Our aim is not a flashy pipeline. It’s a boring one that works every time, which in operations is about as close to romance as we get.
Common Docker Mistakes We Can Easily Avoid
Most docker trouble comes from a handful of repeat offences. The good news is that they’re very fixable once we notice the pattern.
Running everything as root is still one of the biggest issues. It’s easy, it usually works, and it quietly increases risk. The next common mistake is overloading one container with multiple unrelated processes. Yes, we can jam a process manager into the image and host a whole tiny civilisation in there, but separating concerns usually makes lifecycle management cleaner. Another classic is treating containers like pets by shelling into them, tweaking files live, and hoping nobody asks how production differs from source control. If we do that, we’ve reinvented configuration drift in a smaller box.
Resource limits are often forgotten too. Without CPU and memory constraints, one noisy service can make a host miserable. Startup dependencies cause trouble as well. depends_on is not a magic guarantee that an app is ready to serve traffic, and sleeping for 20 seconds is not orchestration, no matter how confidently we script it.
Then there’s storage. Containers are ephemeral by design, so data that matters belongs in volumes or external services. We should be explicit about what persists and what doesn’t. Networking deserves similar clarity: expose only the ports we need, and don’t assume localhost inside a container means the same thing it means on the host.
The final mistake is inconsistency. Different repos, different patterns, different tagging schemes, different runtime flags. That breeds confusion. We get better results when we agree on a few standards, document them simply, and enforce them with tooling where possible. Docker doesn’t need to be complicated. It just punishes casual shortcuts with remarkable creativity.



