Containerization with Docker on Linux
Docker revolutionized software deployment by packaging applications and their dependencies into lightweight, portable containers. Unlike virtual machines, containers share the host operating system kernel, making them faster to start and far more memory-efficient. This article walks through building a containerized Java application with Docker and orchestrating multi-service setups with Docker Compose.
What Is a Docker Container?
A container is a runtime instance of a Docker image. The image is a read-only template containing the application code, runtime, libraries, and configuration. Docker images are built in layers, where each instruction in the Dockerfile adds a new layer. Layers are cached, so rebuilding after a source change only re-adds the layers that changed. This makes Docker builds both fast and reproducible.
Writing a Dockerfile
The Dockerfile is a recipe that tells Docker how to build your image. Every Dockerfile starts with a FROM instruction that specifies a base image. Choosing a minimal base like Alpine Linux keeps images small — the Eclipse Temurin JDK 21 Alpine image is under 200 MB compared to over 400 MB for the full Ubuntu-based one.
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
COPY target/app.jar .
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Each line in this Dockerfile has a specific purpose. WORKDIR /app sets the working directory inside the container to /app. COPY target/app.jar . copies the compiled JAR file from the host’s target/ directory into the container’s working directory. EXPOSE 8080 is documentation — it tells anyone running the container that the application listens on port 8080 but does not actually publish the port. ENTRYPOINT defines the command that runs when the container starts. Build the image with docker build -t myapp . and run it with docker run -p 8080:8080 myapp, which maps the host’s port 8080 to the container’s port 8080.
Multi-Stage Builds
For compiled languages like Java or Go, you can use multi-stage builds to keep the final image small. One stage compiles the code using a full SDK image, and a second stage copies only the compiled artifact into a minimal runtime image. This way, build tools like Maven or Gradle are not part of the final image.
# Stage 1: Build
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests
# Stage 2: Runtime
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
COPY --from=build /app/target/app.jar .
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
The final image contains only the JRE (or JDK) and the JAR — not Maven, not the source code, and not the Maven cache. This reduces the image from over 1 GB to around 180 MB.
Docker Compose for Multi-Service Applications
Most real-world applications involve multiple services: a web server, a database, a cache, and perhaps a message queue. Docker Compose lets you define all services in a single compose.yaml file and start them with one command: docker compose up.
services:
web:
build: .
ports:
- "8080:8080"
depends_on:
- db
environment:
- DATABASE_URL=jdbc:postgresql://db:5432/mydb
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: mydb
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
volumes:
pgdata:
The depends_on field ensures the database service starts before the web service. Services communicate over an internal Docker network using their service names as hostnames — so the web app connects to db:5432 instead of localhost:5432. The volumes section creates a named volume pgdata that persists the database data across container restarts, preventing data loss when the container is recreated.
Useful Docker Commands
# List running containers
docker ps
# View logs from a container
docker logs -f myapp
# Execute a command inside a running container
docker exec -it myapp sh
# Clean up unused resources
docker system prune -af
# Inspect image layers
docker history myapp:latest
Docker containers are ephemeral by design — treat them as disposable. Store state in volumes or external services. With this approach, you can deploy, scale, and update applications reliably across any Linux server, from your laptop to a production Kubernetes cluster.
Docker Networking and Security
Docker networking has three built-in drivers: bridge (default, isolated network per container group), host (container uses host network stack), and overlay (multi-host networking for Docker Swarm). For security, run containers as non-root users, drop Linux capabilities, use read-only root filesystems, and enable Content Trust to verify image signatures. Use Docker Bench Security to audit container configurations. Multi-stage builds separate build dependencies from runtime dependencies—the final image only contains the compiled binary and minimal runtime libraries, reducing both attack surface and deployment time.
Docker Compose and Development Workflows
Docker Compose defines multi-container applications in a docker-compose.yml file, enabling one-command startup of the entire development environment (web server, database, cache, message queue). Compose features include: dependency-based startup order (depends_on with health checks), environment variable files (.env), named volumes for persistent data, network configuration for service discovery, and health checks for container readiness. The compose watch feature (Docker Compose 2.23+) automatically syncs file changes and rebuilds containers, enabling hot-reloading development workflows. For testing, Compose can spin up test infrastructure (test databases, mock services) alongside test suites, then tear everything down with docker compose down. Profiles in Compose allow starting different service subsets for development vs. production-like testing.
