Skip to content
Home DevOps Docker Images and Containers

Docker Images and Containers

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Docker → Topic 6 of 18
Docker images and containers explained — how layers work, building images with Dockerfile, running containers, volumes, networking basics, and essential commands.
⚙️ Intermediate — basic DevOps knowledge assumed
In this tutorial, you'll learn
Docker images and containers explained — how layers work, building images with Dockerfile, running containers, volumes, networking basics, and essential commands.
  • Images are immutable layers; containers add a thin writable layer on top — changes do not affect the image.
  • Layer caching: copy requirements.txt and install before copying source code for faster builds.
  • EXPOSE documents a port but does not publish it — use -p HOST:CONTAINER to publish.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • Image: read-only layers + metadata (CMD, ENV, EXPOSE)
  • Container: image + writable layer + process
  • Registry: stores and distributes images (Docker Hub, ECR)
  • Dockerfile: recipe that produces an image
🚨 START HERE
Docker Container Triage Cheat Sheet
First-response commands when a container issue is reported.
🟡Container crashed or restarting in a loop.
Immediate ActionCheck logs and exit code.
Commands
docker logs --tail 50 <container>
docker inspect <container> --format='{{.State.ExitCode}} {{.State.OOMKilled}}'
Fix NowExit code 0 = CMD completed (wrong CMD). Exit code 1 = app error (check logs). Exit code 137 = OOM killed (--memory too low). Exit code 139 = segfault (base image mismatch).
🟡Container running but not responding to requests.
Immediate ActionVerify port mapping and process inside container.
Commands
docker port <container>
docker exec <container> ps aux
Fix NowIf process missing, CMD failed silently. If process running but port wrong, check EXPOSE vs -p mapping. If binding to 127.0.0.1, change to 0.0.0.0.
🟡docker build fails with 'no space left on device'.
Immediate ActionCheck and clean Docker disk usage.
Commands
docker system df
docker system prune -a --volumes
Fix NowWarning: prune -a removes ALL unused images. Use docker image prune for selective cleanup. Check /var/lib/docker/overlay2 for bloat.
🟠Image build is very slow, reinstalls dependencies every time.
Immediate ActionCheck Dockerfile layer ordering.
Commands
docker history <image>
docker build --progress=plain -t test . 2>&1 | grep CACHED
Fix NowMove COPY requirements.txt and RUN pip install before COPY . . Add .dockerignore to reduce build context size.
🟡Container works locally but fails in CI or on another machine.
Immediate ActionCompare base image and architecture.
Commands
docker inspect --format='{{.Config.Image}}' <container>
docker inspect --format='{{.Architecture}}' <image>
Fix NowPin exact version in FROM. Add --platform linux/amd64. Check for missing .env file or volume mounts.
Production IncidentProduction Outage from Unpinned Base Image — python:3.12-slim Changed OS UnderneathA fintech API deployed with FROM python:3.12-slim silently upgraded from Debian 11 to Debian 12 between builds, breaking a C extension compiled against libssl1.1. The service crashed on startup during a peak trading window.
SymptomContainer exits with ImportError: libssl.so.1.1: cannot open shared object file. No application code changed in 3 weeks. Previous deployment worked. CI build logs show a different base image digest than the last successful build.
AssumptionTeam assumed a corrupted Docker layer cache on the CI runner. They retried the build 4 times with --no-cache. Each build produced the same error. Second assumption: a dependency in requirements.txt had a breaking update. They pinned every Python package — the error persisted.
Root causeThe Dockerfile used FROM python:3.12-slim without pinning the OS codename. Between builds, the official Python image updated the slim variant from Debian 11 (bullseye) to Debian 12 (bookworm). Debian 12 ships libssl3, not libssl1.1. The psycopg2-binary package compiled against libssl1.1 could not load. The CI runner had no warm cache, so it pulled the latest python:3.12-slim on every build.
Fix1. Pinned to FROM python:3.12.3-slim-bookworm — exact version, exact OS codename. 2. Added --platform linux/amd64 to FROM to prevent ARM/AMD64 mismatches. 3. Added a CI step that extracts and logs the base image digest, failing if it changes unexpectedly. 4. Added hadolint to CI pipeline to enforce version pinning rules. 5. Documented: every FROM must pin to exact version and OS codename.
Key Lesson
Unpinned tags are time bombs. python:3.12-slim is not a fixed target — it moves.Pin both the version AND the OS codename: python:3.12.3-slim-bookworm.CI runners without warm caches pull the latest image on every build, exposing you to silent upstream changes.A 20-character change in a FROM line prevents hours of incident response.Image digest pinning (FROM python:3.12.3-slim-bookworm@sha256:abc123...) is the strongest guarantee.
Production Debug GuideFrom failed container to root cause — systematic debugging paths.
Container exits immediately after start.Check logs: docker logs <container>. If empty, the CMD failed before writing output. Run interactively: docker run -it <image> sh and execute the CMD manually to see the error.
Container runs but application is unreachable.Verify port mapping: docker port <container>. Check that the application binds to 0.0.0.0, not 127.0.0.1 (localhost inside the container is the container itself, not the host).
Image build is extremely slow (>5 minutes for a simple app).Check layer caching: docker history <image>. If every build reinstalls dependencies, ensure COPY requirements.txt comes before COPY . . Add .dockerignore to exclude .git, node_modules, __pycache__.
Container works on one machine but fails on another.Compare base image digests: docker inspect --format='{{.Image}}' <container>. Check for architecture mismatches (ARM vs AMD64). Check for missing environment variables or volume mounts.
Disk space exhausted on Docker host.Check Docker disk usage: docker system df. Clean up: docker system prune -a removes unused images and stopped containers. For selective cleanup: docker image prune, docker container prune.
Container uses too much memory, gets OOM-killed.Check with docker stats <container>. Set memory limits: docker run --memory=512m. If the application has a memory leak, the limit prevents host-wide impact. Check exit code 137 = OOM killed.

Building an Image with Dockerfile

A Dockerfile is a recipe for building an image. Each instruction creates a new layer — a filesystem diff on top of the previous layer. Docker caches layers and only rebuilds from the first changed instruction downward. Understanding this is the single most important optimization for build speed.

The layer caching principle: if a layer has not changed and all preceding layers are cached, Docker reuses the cached layer instantly. Dependencies change rarely. Code changes frequently. Put rare changes first.

The .dockerignore file controls what gets sent to the Docker Daemon as build context. Without it, your entire project directory — including .git (often 100MB+), node_modules, __pycache__, and .env files with secrets — is sent on every build. This slows builds and risks secret exposure.

Dockerfile · DOCKERFILE
123456789101112131415161718192021
# Best practices Dockerfile for a Python FastAPI app
FROM python:3.12-slim  # slim variant: much smaller than full python image

WORKDIR /app

# Copy requirements FIRST for layer caching
# If code changes but requirements don't, this layer is cached
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Now copy application code
COPY . .

# Run as non-root for security
RUN useradd -m appuser
USER appuser

# Document the port (does not actually publish it)
EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
▶ Output
# docker build -t myapp:1.0 .
Mental Model
Docker Layers as a Stack of Transparent Sheets
Why does the order of Dockerfile instructions matter for build speed?
  • Docker caches layers top-to-bottom. A changed instruction invalidates all subsequent layers.
  • Dependencies change rarely. Code changes frequently. Put rare changes first.
  • COPY requirements.txt before COPY . . ensures pip install is cached on code-only changes.
  • Each RUN creates a layer. Combining with && reduces layer count and image size.
📊 Production Insight
The layer caching insight is the highest-impact optimization for CI/CD pipelines. In a typical Python project, dependencies change once per sprint but code changes every commit. Without the requirements.txt-first pattern, every commit triggers a full pip install — adding 30-120 seconds to every build. With it, code-only changes rebuild in 2-5 seconds. Across 100 builds per day, that is 1-3 hours of CI time saved daily.
🎯 Key Takeaway
Dockerfile layer ordering is a build speed optimization, not a style choice. Copy dependencies before code. Combine RUN commands. Use .dockerignore. These three changes turn 5-minute builds into 30-second builds. In CI/CD, that compounds into hours saved per week.
Dockerfile Optimization Decisions
IfBuild takes >2 minutes and dependencies rarely change
UseMove COPY requirements.txt and RUN pip install before COPY . . — cache the dependency layer
IfImage is >1GB and includes build tools (gcc, make)
UseUse multi-stage builds — copy only runtime artifacts to the final image
IfBuild context upload is slow (>10 seconds)
UseAdd .dockerignore to exclude .git, node_modules, __pycache__, .env
IfImage contains secrets (API keys in ENV or ARG)
UseUse BuildKit secrets: --mount=type=secret. Never bake secrets into layers.

Running Containers

A container is a running instance of an image with an additional thin writable layer. Multiple containers can run from the same image — each with its own writable layer, environment variables, and port mappings. The image itself never changes.

The container lifecycle: create (allocate resources), start (run the CMD process), stop (send SIGTERM, wait, then SIGKILL), remove (delete the writable layer). The --rm flag automates removal on stop.

Port mapping confusion: -p HOST:CONTAINER maps the host port to the container port. Your application inside the container must bind to 0.0.0.0 (all interfaces), not 127.0.0.1 (localhost). Binding to localhost inside the container means the application only accepts connections from within the container — the host port mapping becomes useless.

The difference between stop and kill: docker stop sends SIGTERM (graceful shutdown, 10-second default timeout), then SIGKILL if the process does not exit. docker kill sends SIGKILL immediately. Always use stop in production — it gives your application time to flush logs, close database connections, and complete in-flight requests.

container-lifecycle.sh · BASH
123456789101112131415161718192021222324252627
# Run a container
docker run -p 8000:8000 myapp:1.0

# Flags:
# -p HOST_PORT:CONTAINER_PORT   — publish port
# -d                            — detached (background)
# -e DATABASE_URL=postgres://.. — environment variable
# -v /host/path:/container/path — bind mount volume
# --name my-container           — give it a name
# --rm                          — remove when stopped

docker run -d \
  --name api-server \
  -p 8000:8000 \
  -e DATABASE_URL=postgres://localhost/mydb \
  --rm \
  myapp:1.0

# Container lifecycle
docker ps          # running containers
docker ps -a       # all containers (including stopped)
docker stop api-server     # graceful stop (SIGTERM)
docker kill api-server     # force stop (SIGKILL)
docker rm api-server       # remove stopped container
docker logs api-server     # view logs
docker logs -f api-server  # follow logs
docker exec -it api-server bash  # shell into running container
▶ Output
# Container is running and accessible on port 8000
Mental Model
Container as a Process with a Mask
Why does binding to 127.0.0.1 inside a container break port mapping?
  • 127.0.0.1 inside the container is the container's own loopback, not the host's.
  • Port mapping (-p 8000:8000) forwards traffic from the host to the container's network interface.
  • If the app binds to 127.0.0.1, it only accepts connections from inside the container.
  • Binding to 0.0.0.0 accepts connections from all interfaces, including the mapped port.
📊 Production Insight
The stop-vs-kill distinction matters for zero-downtime deployments. When Kubernetes or a load balancer removes a pod, it sends SIGTERM first. If your application does not handle SIGTERM gracefully (flush buffers, close connections, drain requests), you get connection resets and data loss. Always implement a SIGTERM handler. The default 10-second stop timeout may be too short for applications with long-running requests — increase it with --stop-timeout or terminationGracePeriodSeconds in Kubernetes.
🎯 Key Takeaway
Containers are processes with isolation masks, not mini-VMs. Bind to 0.0.0.0, not 127.0.0.1. Use docker stop (graceful) in production, not docker kill (forced). Implement SIGTERM handlers for clean shutdown. The --rm flag prevents container accumulation during development.
Container Lifecycle Decisions
IfDevelopment — quick iteration, container should auto-cleanup
UseUse docker run --rm — removes container on stop, no manual cleanup
IfProduction — container should restart on crash
UseUse restart: unless-stopped or restart: always. Implement health checks.
IfNeed to inspect a running container
UseUse docker exec -it <container> sh — shell into the container without SSH
IfContainer is stuck and not responding to stop
UseUse docker kill <container> — sends SIGKILL immediately. Check for zombie processes.
IfNeed to copy files into/out of a running container
UseUse docker cp <host-path> <container>:<path> — no need to rebuild the image

Volumes and Networking

Containers are ephemeral — when a container stops, its writable layer is discarded. For state that must survive container restarts (databases, file uploads, logs), you need volumes.

Named volumes: Managed by Docker. The storage location is controlled by the Docker Daemon (typically /var/lib/docker/volumes/). Survives docker compose down. Destroyed only by docker compose down -v or docker volume rm. Best for databases.

Bind mounts: Mount a host directory into the container. Changes on either side are reflected immediately. Great for development (hot reload). Not recommended for production — ties the container to a specific host path and breaks portability.

Networking: Containers on the same Docker network can reach each other by container name. Docker's embedded DNS server (127.0.0.11) resolves container names to internal IPs. The host-mapped port (left side of -p) is for external access. Container-to-container communication uses the container port directly — never the host port.

Volume lifecycle gotcha: Named volumes persist data even after the container is removed. This is a feature for databases but a trap for test environments — stale data from previous test runs can cause non-deterministic test failures. Use docker compose down -v in CI to ensure clean state.

volumes-and-networking.sh · BASH
12345678910111213141516171819202122
# Named volumes: data persists after container is removed
docker volume create postgres-data
docker run -d \
  -v postgres-data:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret \
  postgres:16

# Container networking: containers on same network can reach each other by name
docker network create myapp-network

docker run -d --name db --network myapp-network postgres:16
docker run -d \
  --name api \
  --network myapp-network \
  -e DATABASE_URL=postgres://db/myapp \
  myapp:1.0
# 'api' container reaches 'db' by hostname 'db'

# Image management
docker image ls          # list images
docker image rm myapp:1.0
docker system prune -a   # remove all unused images, containers, networks
▶ Output
# Volume data persists; containers communicate by name
Mental Model
Volumes as External Hard Drives
Why should you use named volumes over bind mounts in production?
  • Named volumes are portable — Docker manages the storage location, not a host path.
  • Bind mounts couple the container to a specific host — breaks multi-machine deployments.
  • Named volumes survive docker compose down. Bind mounts depend on the host directory existing.
  • Named volumes use Docker's optimized storage driver (overlay2). Bind mounts go through the host filesystem.
📊 Production Insight
The bind-mount-in-production anti-pattern is common in teams that graduate from development to production without changing their Compose files. A bind mount like -v /data/postgres:/var/lib/postgresql/data ties the database to a specific server. When the server is replaced during an infrastructure migration, the data is left behind. Named volumes are managed by Docker and can be backed up, migrated, and restored independently of the host filesystem.
🎯 Key Takeaway
Named volumes for production persistence. Bind mounts for development convenience. tmpfs for sensitive temporary data. Containers on the same network communicate by name — never use localhost or host-mapped ports for container-to-container traffic. Always use docker compose down -v in CI for clean test state.
Volume Type Selection
IfDatabase or persistent state in production
UseUse named volumes: docker volume create. Back up with docker run --rm -v vol:/data -v $(pwd):/backup alpine tar czf /backup/backup.tar.gz /data
IfDevelopment — live code reloading
UseUse bind mounts: -v $(pwd):/app. Fast iteration, no rebuild needed.
IfSensitive temporary data (session tokens, encryption keys)
UseUse tmpfs mounts: --tmpfs /tmp/secrets:size=10m. Data never touches disk.
IfCI test runs — need clean state every time
UseUse docker compose down -v to destroy named volumes between runs.
🗂 Image vs Container vs Volume
The three core Docker objects and their characteristics.
CharacteristicImageContainerVolume
What it isImmutable template (read-only layers)Running instance of an imagePersistent storage outside container lifecycle
MutabilityImmutable — never changes after buildWritable layer on top of imageFully writable, persists independently
Created bydocker build (from Dockerfile)docker run (from image)docker volume create or Compose
Survives removalYes (until explicitly deleted)No (writable layer discarded)Yes (until explicitly deleted)
Shared betweenMultiple containersSingle container instanceMultiple containers
Storage locationDocker Daemon storage (/var/lib/docker)Docker Daemon storage (writable layer)Docker-managed or host path (bind mount)
Size impactDetermined by layers and base imageImage size + writable layer deltaIndependent of image/container size
Use casePackage application + dependenciesRun the applicationPersist database, uploads, logs

🎯 Key Takeaways

  • Images are immutable layers; containers add a thin writable layer on top — changes do not affect the image.
  • Layer caching: copy requirements.txt and install before copying source code for faster builds.
  • EXPOSE documents a port but does not publish it — use -p HOST:CONTAINER to publish.
  • Named volumes persist data after container removal; bind mounts share host directories.
  • Containers on the same Docker network reach each other by container name.
  • Pin exact versions in FROM — unversioned tags are silent time bombs in production.
  • Bind to 0.0.0.0 inside the container, not 127.0.0.1 — localhost inside a container is the container itself.

⚠ Common Mistakes to Avoid

    Using unversioned or :latest tags in FROM
    Symptom

    builds break silently when the base image updates; deployments that worked yesterday fail today with no code changes —

    Fix

    pin exact version and OS codename: python:3.12.3-slim-bookworm, not python:3.12-slim. Consider digest pinning for maximum safety: FROM python:3.12.3-slim-bookworm@sha256:abc123...

    Binding application to 127.0.0.1 inside the container
    Symptom

    container is running, port is mapped with -p 8000:8000, but curl http://localhost:8000 on the host gets connection refused —

    Fix

    bind to 0.0.0.0 in your application, not 127.0.0.1. localhost inside the container is the container's loopback, not the host.

    Not using .dockerignore
    Symptom

    docker build is slow (>2 minutes for a simple app); .env secrets appear in built image layers; .git directory (100MB+) is sent to the Daemon on every build —

    Fix

    create .dockerignore excluding .git, node_modules, __pycache__, .env, tests, docs.

    Using docker kill instead of docker stop in production
    Symptom

    database connections are not closed gracefully; in-flight requests get connection resets; logs are truncated —

    Fix

    use docker stop (SIGTERM) which gives the application time to shut down. Increase stop timeout with --stop-timeout 30 if your app needs more than 10 seconds.

    Running as root inside the container
    Symptom

    container escape vulnerability gives attacker root access to the host; Kubernetes PodSecurityStandard rejects the pod —

    Fix

    add RUN useradd -m appuser and USER appuser to your Dockerfile. Set file permissions with chown.

    Storing secrets in ENV or ARG in the Dockerfile
    Symptom

    secrets appear in docker history and docker inspect output; anyone with image pull access can extract credentials —

    Fix

    use BuildKit --mount=type=secret for build-time secrets. Use Docker secrets or a secrets manager for runtime secrets. Never put secrets in ENV, ARG, or COPY into the image.

Interview Questions on This Topic

  • QWhat is the difference between a Docker image and a container?
  • QWhy should you copy requirements.txt before copying your source code in a Dockerfile?
  • QHow do containers on the same Docker network communicate?
  • QWhat is the difference between docker stop and docker kill? When would you use each?
  • QA container is running but you cannot reach it from the host on the mapped port. What are the possible causes?
  • QWhat is the difference between a named volume, a bind mount, and a tmpfs mount? When would you use each?
  • QYour docker build takes 5 minutes for a simple Python app. Walk me through how you would optimize it.
  • QWhat does exit code 137 mean for a Docker container? How do you debug it?

Frequently Asked Questions

What is the difference between CMD and ENTRYPOINT in a Dockerfile?

ENTRYPOINT sets the executable that always runs — it cannot be overridden by docker run arguments. CMD provides default arguments to ENTRYPOINT or a default command if no ENTRYPOINT is set. Together: ENTRYPOINT is the binary, CMD is the default arguments. Use ENTRYPOINT for the main process of a container, CMD for configurable defaults.

How do you keep Docker images small?

Use slim or alpine base images (python:3.12-slim vs python:3.12). Use multi-stage builds to separate the build environment from the runtime image. Combine RUN commands to reduce layers: RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*. Copy only what the container needs — use .dockerignore.

What does exit code 137 mean for a Docker container?

Exit code 137 means the container was killed by signal 9 (SIGKILL), typically by the Linux OOM killer. The container exceeded its memory limit or the host ran out of memory. Debug with docker inspect to check OOMKilled status, then either increase the memory limit (--memory flag) or fix the memory leak in your application.

Does EXPOSE in a Dockerfile actually publish the port?

No. EXPOSE is documentation — it signals which port the application listens on but does not publish it. To actually make the port accessible from the host, use -p HOST:CONTAINER when running the container. EXPOSE is useful for documentation and for tools that auto-detect ports, but it has no runtime effect.

Can I modify a running Docker image?

No. Images are immutable. You can modify a running container's writable layer (add files, change config), but those changes are lost when the container is removed. To persist changes, either use volumes for data or create a new image with docker commit (not recommended for production — use a Dockerfile instead).

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousDocker Architecture ExplainedNext →Dockerfile Explained
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged