Home DevOps Docker Images vs Containers Explained — How They Work and Why It Matters

Docker Images vs Containers Explained — How They Work and Why It Matters

In Plain English 🔥
Think of a Docker image like a cake recipe — it lists every ingredient and every step, but it's just paper. A container is what you get when you actually bake that cake. You can bake the same recipe a hundred times and get a hundred identical cakes. Each cake can be eaten (or destroyed) without touching the recipe. That's the whole image-versus-container relationship in a nutshell.
⚡ Quick Answer
Think of a Docker image like a cake recipe — it lists every ingredient and every step, but it's just paper. A container is what you get when you actually bake that cake. You can bake the same recipe a hundred times and get a hundred identical cakes. Each cake can be eaten (or destroyed) without touching the recipe. That's the whole image-versus-container relationship in a nutshell.

Every time a developer says 'but it works on my machine,' a DevOps engineer loses a year of their life. That phrase — once a punchline, now a war cry — exists because software behaves differently depending on the operating system, installed libraries, environment variables, and a dozen other invisible factors. Docker was built specifically to kill that problem. It wraps your application and everything it needs into a single, portable unit that runs identically everywhere: your laptop, a CI pipeline, a cloud VM at 3am.

The engine behind that portability is the image-and-container model. An image is a frozen, read-only snapshot of a filesystem and the instructions to run it. A container is a live, running instance of that image — isolated from the host and from every other container, but sharing the host OS kernel for speed. Images solve the 'what do I ship?' problem. Containers solve the 'how do I run it consistently?' problem. Understanding exactly where one ends and the other begins is the difference between debugging for ten minutes and debugging for ten hours.

By the end of this article you'll know how Docker images are built in layers and why that matters for build speed, how containers are created and managed from those images, how to write a clean multi-stage Dockerfile for a real Node.js service, and the exact mental model that will answer 90% of Docker interview questions before they're even finished asking.

Docker Images — Layered Snapshots and Why Layers Exist

A Docker image isn't one big file. It's a stack of read-only layers, each representing a single instruction in a Dockerfile. When you run FROM node:20-alpine, Docker pulls a base layer. When you run COPY package.json ., it adds another layer on top. This layered architecture isn't aesthetic — it's a caching strategy.

Here's the payoff: if you change only your application code but not your dependencies, Docker reuses every cached layer up to the COPY command for your source files. A build that would take 90 seconds rebuilds in 4 seconds. This is why instruction order in a Dockerfile is not cosmetic — it's a performance decision.

Each layer is content-addressed by a SHA256 hash. If the content of an instruction's output hasn't changed, the hash matches, the cache hits, and Docker skips the rebuild. When you push an image to a registry like Docker Hub or ECR, only the layers that don't already exist in the registry are uploaded. This is also why two images that share a base layer don't duplicate storage — they reference the same layer on disk.

Understanding layers also explains image size. Every RUN apt-get install that isn't cleaned up in the same layer adds permanent weight to the image, even if you delete the files in a later layer — because that deletion is just another layer on top, not an erasure of the original.

Dockerfile.node-api · DOCKERFILE
12345678910111213141516171819202122232425262728293031323334353637383940414243
# ── Stage 1: Install dependencies ────────────────────────────────────────────
# We use a specific version tag (not 'latest') so builds are reproducible.
# Alpine keeps the base image small (~5MB vs ~900MB for full Debian node image).
FROM node:20-alpine AS dependency-installer

WORKDIR /app

# COPY package files BEFORE source code — this is intentional.
# Docker caches this layer. If package.json hasn't changed, npm install
# is skipped on the next build even if your source code changed.
COPY package.json package-lock.json ./

# Install only production dependencies to keep the layer lean.
RUN npm ci --omit=dev

# ── Stage 2: Production image ─────────────────────────────────────────────────
# We start fresh from the same base. Only the artifacts we need come with us.
# The node_modules from Stage 1, the compiled source — nothing else.
FROM node:20-alpine AS production

WORKDIR /app

# Copy the pre-installed node_modules from the first stage.
# This means no build tools (npm, git, etc.) exist in the final image.
COPY --from=dependency-installer /app/node_modules ./node_modules

# Now copy the actual application source code.
# Because this COPY comes AFTER the node_modules copy, changing source code
# only invalidates the cache from this line forward — not the npm install.
COPY src/ ./src
COPY package.json ./

# Run as a non-root user. This is a security baseline, not optional.
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# Document which port the app listens on. EXPOSE is metadata — it doesn't
# actually publish the port. You do that with -p at runtime.
EXPOSE 3000

# CMD is the default process. Using array form avoids shell interpretation
# issues and ensures signals (like SIGTERM from 'docker stop') reach the process.
CMD ["node", "src/server.js"]
▶ Output
$ docker build -t my-node-api:1.0 .
[+] Building 18.3s (12/12) FINISHED
=> [dependency-installer 1/4] FROM node:20-alpine 3.2s
=> [dependency-installer 2/4] WORKDIR /app 0.1s
=> [dependency-installer 3/4] COPY package.json package-lock.json 0.1s
=> [dependency-installer 4/4] RUN npm ci --omit=dev 12.4s
=> [production 1/5] FROM node:20-alpine (cached) 0.0s
=> [production 2/5] COPY --from=dependency-installer /app/node_mod 0.3s
=> [production 3/5] COPY src/ ./src 0.1s
=> [production 4/5] RUN addgroup -S appgroup && adduser... 0.9s
=> exporting to image 1.2s

# Change only src/server.js and rebuild — notice the cache hits:
$ docker build -t my-node-api:1.1 .
[+] Building 1.8s (12/12) FINISHED
=> CACHED [dependency-installer 4/4] RUN npm ci --omit=dev 0.0s
=> [production 3/5] COPY src/ ./src 0.1s
⚠️
Pro Tip: Layer Order Is a Cache StrategyAlways COPY dependency manifests (package.json, requirements.txt, go.mod) before COPY-ing source code. This one habit cuts average CI build times by 60-80% because the expensive install step is cached as long as dependencies don't change.

Containers — Running Instances, Lifecycle, and What Isolation Actually Means

When you run docker run my-node-api:1.0, Docker takes the read-only image and adds a thin, writable layer on top called the container layer. Everything the process writes at runtime — log files, temp files, uploaded data — goes into that writable layer. The image itself is never modified. This is why you can spin up fifty containers from the same image simultaneously and they don't interfere with each other.

A container is just a process (or a group of processes) on the host machine, isolated using two Linux kernel features: namespaces and cgroups. Namespaces give the container its own view of the filesystem, network interfaces, process IDs, and hostname. Cgroups enforce resource limits — CPU, RAM, I/O. There's no hypervisor, no guest OS, no hardware emulation. That's why containers start in milliseconds instead of the minutes a VM needs.

The container lifecycle has five states: created, running, paused, stopped, and removed. Understanding this matters because a stopped container still exists on disk — its writable layer is preserved. You can restart it and it picks up exactly where it left off. A removed container is gone. Any data written only to the container layer is lost unless you've mounted a volume.

This ephemerality is intentional. Treat containers as disposable compute units, not as servers. If your app writes state to the container filesystem and you're surprised when it vanishes — that's a design problem, not a Docker bug.

container-lifecycle-demo.sh · BASH
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
#!/bin/bash
# This script walks through the full container lifecycle with a real example.
# We'll use the image we built in the previous section.

# ── 1. CREATE a container without starting it ─────────────────────────────────
# Useful when you want to inspect or configure before the process starts.
docker create \
  --name api-server-v1 \
  --publish 3000:3000 \
  my-node-api:1.0

# ── 2. START the created container ───────────────────────────────────────────
docker start api-server-v1

# Most of the time you combine create + start with 'docker run':
# docker run -d --name api-server-v1 -p 3000:3000 my-node-api:1.0
# -d means detached (runs in background). Without it, the terminal is attached
# to the container process and Ctrl+C kills the container.

# ── 3. INSPECT what is running ────────────────────────────────────────────────
# Shows PID, port bindings, mounted volumes, environment vars, and more.
docker inspect api-server-v1 --format '{{ .State.Status }}'
# Output: running

docker inspect api-server-v1 --format '{{ .NetworkSettings.IPAddress }}'
# Output: 172.17.0.2

# ── 4. CHECK live resource usage ─────────────────────────────────────────────
# Like 'top' but for containers. Shows CPU %, memory usage, and network I/O.
docker stats api-server-v1 --no-stream

# ── 5. STOP the container gracefully ─────────────────────────────────────────
# Docker sends SIGTERM to PID 1 inside the container, waits 10 seconds,
# then sends SIGKILL if the process hasn't exited. Respect the grace period
# in your app's signal handler for clean shutdowns.
docker stop api-server-v1

# ── 6. VERIFY it is stopped but still exists on disk ─────────────────────────
docker ps -a --filter name=api-server-v1
# The -a flag shows ALL containers, including stopped ones.

# ── 7. REMOVE the container entirely ─────────────────────────────────────────
# The writable layer is deleted. The image is untouched.
docker rm api-server-v1

# Shortcut to stop AND remove in one command:
# docker rm -f api-server-v1
▶ Output
$ docker create --name api-server-v1 --publish 3000:3000 my-node-api:1.0
a3f81c2d9e4b7f0156a2c8d3e9f4b7a0156a2c8d

$ docker start api-server-v1
api-server-v1

$ docker inspect api-server-v1 --format '{{ .State.Status }}'
running

$ docker stats api-server-v1 --no-stream
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM %
a3f81c2d9e4b api-server-v1 0.01% 18.3MiB / 7.67GiB 0.23%

$ docker stop api-server-v1
api-server-v1

$ docker ps -a --filter name=api-server-v1
CONTAINER ID IMAGE COMMAND STATUS
a3f81c2d9e4b my-node-api:1.0 "node src/server.js" Exited (0) 3 seconds ago

$ docker rm api-server-v1
api-server-v1
⚠️
Watch Out: Stopped ≠ RemovedRunning `docker stop` does NOT free disk space. Stopped containers accumulate fast in CI environments. Run `docker ps -a | wc -l` on a busy CI server and you might find hundreds. Use `docker container prune` to bulk-remove stopped containers, or always run with `--rm` flag so containers self-delete on exit.

Volumes and Environment Variables — Making Containers Actually Useful in Production

A container with no persistent storage and hardcoded credentials isn't production software — it's a toy. Two mechanisms make containers production-grade: volumes for persistence and environment variables for configuration.

Volumes are directories managed by Docker that exist outside the container's writable layer. When the container is removed, the volume survives. Mount a volume at /app/uploads and files written there outlive every container restart, redeploy, and rollback. This is the only correct way to handle uploaded files, database data, or any state that must survive a deployment.

Environment variables separate configuration from code, following the twelve-factor app methodology. You build one image and promote it through dev, staging, and production — the only thing that changes is the environment variables injected at runtime. Database URLs, API keys, feature flags — all injected at docker run, not baked into the image.

Never bake secrets into a Dockerfile with ENV SECRET_KEY=abc123. That value is embedded in the image layer and visible to anyone who can pull the image. Use --env-file to load variables from a file that never gets committed to Git, or use a secrets manager like AWS Secrets Manager or Vault in production.

production-run-pattern.sh · BASH
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
#!/bin/bash
# Demonstrates a production-grade docker run command with volumes,
# environment config, resource limits, and restart policy.

# ── Create a named volume for the database data directory ─────────────────────
# Named volumes are managed by Docker and survive container removal.
# Bind mounts (host path:container path) are better for local dev but
# depend on the host filesystem layout, which is fragile in production.
docker volume create postgres-data

# ── Run a PostgreSQL container the right way ──────────────────────────────────
docker run \
  --detach \
  --name production-postgres \
  \
  # Mount the named volume to the data directory postgres uses internally.
  # This ensures database files persist across container replacements.
  --volume postgres-data:/var/lib/postgresql/data \
  \
  # Load sensitive credentials from a file that lives outside version control.
  # Never use --env POSTGRES_PASSWORD=secret inline — it shows in 'docker inspect'
  # and in shell history.
  --env-file ./config/postgres.env \
  \
  # Limit the container to 512MB RAM. Without this, a runaway query can
  # consume all host memory and crash other containers.
  --memory 512m \
  \
  # Restart unless we explicitly stop it. This handles server reboots and
  # OOM kills without needing a separate process supervisor.
  --restart unless-stopped \
  \
  # Only expose PostgreSQL to localhost — never to 0.0.0.0 in production.
  --publish 127.0.0.1:5432:5432 \
  \
  postgres:16-alpine

# ── Verify the volume is mounted correctly ────────────────────────────────────
docker inspect production-postgres \
  --format '{{ range .Mounts }}{{ .Name }}: {{ .Destination }}{{ end }}'

# ── Check the container is healthy ───────────────────────────────────────────
docker exec production-postgres pg_isready -U postgres

# ── Contents of config/postgres.env (NEVER commit this file) ─────────────────
# POSTGRES_USER=appuser
# POSTGRES_PASSWORD=a-very-long-random-secret-generated-by-a-password-manager
# POSTGRES_DB=appdb
▶ Output
$ docker volume create postgres-data
postgres-data

$ docker run --detach --name production-postgres \
--volume postgres-data:/var/lib/postgresql/data \
--env-file ./config/postgres.env \
--memory 512m \
--restart unless-stopped \
--publish 127.0.0.1:5432:5432 \
postgres:16-alpine
7b3e9d1f4a2c8b6e0f5d3a1c9e7b4f2a0c6e8d3

$ docker inspect production-postgres \
--format '{{ range .Mounts }}{{ .Name }}: {{ .Destination }}{{ end }}'
postgres-data: /var/lib/postgresql/data

$ docker exec production-postgres pg_isready -U postgres
/var/run/postgresql:5432 - accepting connections
🔥
Interview Gold: Why Not Store Data in the Container Layer?Data in the writable container layer uses a copy-on-write storage driver (overlay2). Every write is slower than writing to a native volume because it must first copy the file from the lower image layer. For a database, this is both a performance problem and a persistence problem. Volumes bypass copy-on-write entirely.
AspectDocker ImageDocker Container
What it isRead-only, layered filesystem snapshotRunning instance with a writable layer on top
StateImmutable — never changes after buildMutable — writes go to the container layer
LifetimePersists until explicitly deletedEphemeral — writable layer is lost on removal
Storage on diskShared between all containers using itEach container has its own writable layer
Created with`docker build` or `docker pull``docker run` or `docker create`
Can there be multiples?One image, multiple versions via tagsMany containers from one image at once
PortabilityFully portable — push to any registryNot portable — tied to the host it runs on
AnalogyCake recipe (blueprint)The actual baked cake (live instance)

🎯 Key Takeaways

  • An image is immutable and layered — every Dockerfile instruction creates a cached layer, so instruction order directly controls build speed. Dependency installs always go before source code copies.
  • A container adds a writable layer on top of an image — stop it and the layer survives, remove it and the layer is gone forever. Data that matters must live in a volume, not the container filesystem.
  • Containers are not VMs — they're isolated Linux processes sharing the host kernel via namespaces and cgroups. This is why they start in milliseconds and why a kernel-level exploit in one container is a host-level problem.
  • Configuration and secrets must never be baked into an image. Build once, deploy to many environments by injecting environment variables at runtime using --env-file — this is the single most important production Docker habit.

⚠ Common Mistakes to Avoid

  • Mistake 1: Using latest as the image tag in production — Your docker pull my-app:latest silently pulls a different image after a teammate pushes a breaking change, and suddenly prod is down with no obvious cause. Fix it by pinning to a specific, immutable tag like my-app:1.4.2 or a full image digest (my-app@sha256:a3f8...). Treat latest as a development convenience, never as a deployment target.
  • Mistake 2: Putting secrets in ENV instructions inside the Dockerfile — Running docker history my-image or docker inspect reveals every ENV value baked into an image layer, including passwords and API keys. Anyone with pull access owns your credentials. Fix it by passing secrets at runtime using --env-file with a file excluded from version control via .gitignore, or use Docker secrets / a dedicated secrets manager for orchestrated environments.
  • Mistake 3: Invalidating the layer cache by copying everything before installing dependencies — A single COPY . . before RUN npm install means every source code change triggers a full dependency reinstall, turning a 3-second rebuild into a 90-second one. Fix it by always copying only the dependency manifest files first (COPY package.json package-lock.json ./), running the install, then copying the rest of the source. This keeps the expensive install layer cached as long as dependencies don't change.

Interview Questions on This Topic

  • QWhat is the difference between a Docker image and a Docker container, and what happens at the filesystem level when you start a container from an image?
  • QIf you run ten containers from the same image simultaneously, how does Docker manage storage efficiently? What is the copy-on-write mechanism and where does it break down for performance?
  • QA colleague says 'I deleted all my containers but my disk is still full' — what's the most likely cause, and what commands would you run to diagnose and fix it?

Frequently Asked Questions

Can a Docker container run without an image?

No. Every container is created from an image — the image provides the read-only filesystem layers that form the base of the container. Even the most minimal container uses a base image like scratch (an empty image) or alpine. There is no way to create a container without an underlying image.

Does deleting a Docker container delete the image too?

No. Deleting a container (docker rm) only removes that container's thin writable layer. The image it was created from remains on disk and can be used to create new containers at any time. To delete the image itself, you use docker rmi image-name. You can't delete an image while a container (even a stopped one) still references it.

If I write a file inside a running container, where does it go and what happens to it when the container stops?

Files written inside a running container go to the container's writable layer, which sits on top of the read-only image layers using a copy-on-write storage driver (overlay2 on most Linux hosts). If you stop the container, the writable layer persists — you can restart the container and the file is still there. If you remove the container with docker rm, the writable layer is permanently deleted. To keep data beyond the container's lifetime, write it to a mounted volume instead.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousIntroduction to DockerNext →Dockerfile Explained
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged