Docker Images vs Containers Explained — How They Work and Why It Matters
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.
# ── 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"]
[+] 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
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.
#!/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
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
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.
#!/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
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
| Aspect | Docker Image | Docker Container |
|---|---|---|
| What it is | Read-only, layered filesystem snapshot | Running instance with a writable layer on top |
| State | Immutable — never changes after build | Mutable — writes go to the container layer |
| Lifetime | Persists until explicitly deleted | Ephemeral — writable layer is lost on removal |
| Storage on disk | Shared between all containers using it | Each 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 tags | Many containers from one image at once |
| Portability | Fully portable — push to any registry | Not portable — tied to the host it runs on |
| Analogy | Cake 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
latestas the image tag in production — Yourdocker pull my-app:latestsilently 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 likemy-app:1.4.2or a full image digest (my-app@sha256:a3f8...). Treatlatestas a development convenience, never as a deployment target. - ✕Mistake 2: Putting secrets in ENV instructions inside the Dockerfile — Running
docker history my-imageordocker inspectreveals 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-filewith 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 . .beforeRUN npm installmeans 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.
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.