top of page

Docker in Practice: Containers Beyond Hello World

  • ShiftQuality Contributor
  • Mar 31
  • 5 min read

The previous posts in this path covered what DevOps is and Git basics. This post covers the tool that changed how we think about deploying software: Docker — specifically, the practical knowledge you need when moving from tutorial examples to real applications.

The Docker tutorial has you pull an image, run a container, and see a web page. That takes five minutes and teaches you the vocabulary. What it does not teach you is how to write a Dockerfile that does not produce a 2GB image, how to structure a compose file for local development, or why your container works on your machine but breaks in CI. This post covers the practical knowledge that sits between "hello world" and production.

Writing Dockerfiles That Actually Work

A Dockerfile is a recipe for building a container image. The tutorial version is simple: start from a base image, copy your code in, install dependencies, run the application. The production version needs to be smaller, faster to build, and more secure.

Multi-stage builds are the most important optimization. Instead of one Dockerfile that installs build tools, compiles your application, and runs it — leaving all the build tools in the final image — you use multiple stages. Stage one installs build tools and compiles. Stage two copies only the compiled output into a minimal runtime image. The result: your build stage might be 1.5GB, but your runtime image is 150MB.

The practical difference matters. A 1.5GB image takes minutes to push and pull. A 150MB image takes seconds. In a CI pipeline that builds and deploys dozens of times a day, those minutes compound into hours.

Layer caching is the second optimization. Docker caches each layer of the build. If a layer has not changed, Docker reuses it instead of rebuilding. The trick: order your Dockerfile so that frequently changing layers come last. Copy your dependency manifest (package.json, requirements.txt, .csproj) and install dependencies before copying your application code. Dependencies change rarely — that layer gets cached. Application code changes frequently — only that layer rebuilds.

The anti-pattern: COPY . . followed by RUN npm install. Every code change invalidates the copy layer, which invalidates the install layer, which reinstalls all dependencies from scratch. Instead: COPY package.json . then RUN npm install then COPY . .. Now a code change only invalidates the final copy layer.

Run as non-root. The default Docker user is root. If an attacker exploits your application inside the container, they have root access to the container filesystem and any mounted volumes. Add a non-root user and switch to it: RUN adduser --disabled-password appuser then USER appuser. This is a small change that significantly reduces the attack surface.

Docker Compose for Local Development

Docker Compose lets you define multi-container applications in a YAML file. Your application needs a web server, a database, and a cache? Define all three in docker-compose.yml and start them with a single command.

The local development compose file is different from the production configuration. Locally, you want hot reloading (mount your source code as a volume so changes appear instantly), exposed ports for debugging, and relaxed security settings. In production, you want none of these things.

The practical setup: your compose file defines the database and cache as services with fixed ports. Your application service mounts the source directory as a volume and runs in development mode. A single docker compose up starts everything. A single docker compose down tears it down. No installing PostgreSQL on your machine. No conflicting versions. No "works on my machine" because everyone uses the same container definitions.

Environment variables handle the differences between environments. Your compose file references variables for database passwords, API keys, and feature flags. Locally, these come from an .env file (which is gitignored). In CI, they come from secrets management. In production, they come from the orchestrator. Same containers, different configuration.

Volumes and Data Persistence

Containers are ephemeral — when a container stops, its filesystem is gone. This is a feature, not a bug. It means containers are disposable and reproducible. But it also means your database data disappears when the container restarts, which is a problem.

Volumes solve this. A Docker volume is storage that persists independently of the container lifecycle. Your database container writes to a volume. When the container restarts, it reconnects to the same volume and all data is intact.

Named volumes (defined in compose) are the cleanest approach for services like databases. Bind mounts (mapping a host directory into the container) are the right approach for development — mounting your source code so changes are reflected immediately.

The mistake: storing application state inside the container filesystem instead of on a volume. Everything works until the container restarts and the state is gone. Treat the container filesystem as read-only for application data. Anything that needs to survive a restart goes on a volume.

Networking Between Containers

Containers in the same Docker network can communicate using service names as hostnames. If your compose file defines services named web, db, and cache, the web container can reach the database at db:5432 and the cache at cache:6379. No IP addresses. No host networking. Docker's internal DNS handles the resolution.

This is simpler than it sounds, but the networking mistakes are common. Port mapping (ports: "8080:80") exposes a container port to the host. Service-to-service communication does not need port mapping — it uses the internal network. Only expose ports that need to be accessed from outside the Docker network (your web server, your debugging ports).

The compose network is isolated by default. Containers can reach each other but are not accessible from the host or other networks unless explicitly exposed. This isolation is good security practice — your database is reachable from your application but not from the outside world.

Common Mistakes and How to Avoid Them

Ignoring .dockerignore. Without a .dockerignore file, COPY . . copies everything — including node_modules, .git, test fixtures, and your IDE configuration. This bloats the image and slows builds. Create a .dockerignore that excludes everything the build does not need.

Using latest tags. FROM node:latest means your build uses whatever version of Node happens to be current when the image is pulled. This introduces non-determinism — your build might work today and fail tomorrow because the base image changed. Pin your base images: FROM node:20.11-slim.

Not handling signals properly. When Docker stops a container, it sends a SIGTERM signal. If your application does not handle SIGTERM, Docker waits 10 seconds and sends SIGKILL, which terminates the process immediately without cleanup. This means in-flight requests are dropped and database connections are not closed gracefully. Handle SIGTERM in your application to shut down cleanly.

Running database migrations in the Dockerfile. Migrations should run at deployment time, not build time. If migrations are baked into the image build, they run against whatever database the build environment can reach — which is probably not the right one.

The Takeaway

Docker in practice is about small decisions that compound: multi-stage builds for small images, layer ordering for fast builds, compose files for reproducible local environments, volumes for persistent data, and disciplined practices around tags, signals, and security.

The gap between the Docker tutorial and production Docker is not technical complexity — it is experience with the patterns that work and the mistakes that waste time. These patterns are learnable, and they make containerized development genuinely faster and more reliable than the alternative.

Next in the "DevOps Foundations" learning path: We'll cover cloud fundamentals — understanding the services, pricing models, and architectural patterns that make cloud deployment practical.

Comments


bottom of page