on this page

You ran docker build, then docker run, then you edited a file inside the container to fix something, then you stopped it, started it back up, and your fix was gone. Now you’re staring at the terminal wondering whether Docker is broken, whether you’re broken, or both.

Neither. You just tripped over the single most common Docker confusion there is: the difference between a Dockerfile, an image, and a container. They sound like three names for the same thing. They are three completely different things, and almost every weird Docker moment a beginner hits comes from quietly mixing them up.

This guide untangles those three. If you’re fuzzy on what a container even is at the most basic level, go read Docker without the buzzwords first, since it covers the “it works on my machine” problem these tools exist to solve. This one zooms in on the three nouns and the two verbs that connect them, because once that clicks, the rest of Docker stops feeling like guesswork.

The one mental model that fixes this

Here’s the whole thing in one breath, and then we’ll slow down.

A Dockerfile is a written recipe. An image is the finished dish, cooked once and frozen solid. A container is the plate of that dish you actually sit down and eat, and when you make a mess on the plate, you’re not changing the freezer.

If you’ve written any object-oriented code, the other version lands harder: the Dockerfile is the source file, the image is the class, and a container is an object you instantiate from that class. One class, as many objects as you want, each with its own state, none of them reaching back to mutate the class.

Hold onto whichever of those sticks. Every confusing Docker error you’ll hit this month is really just a violation of that one picture.

A Dockerfile is instructions, not a thing that runs

A Dockerfile is a plain text file. It does nothing on its own. It just lists, step by step, how to assemble an environment: which base to start from, what to copy in, what to install, what command to run later.

# Dockerfile for a small Express API
# Start from the official Node 20 image, the slim Alpine Linux variant
FROM node:20-alpine

# Everything after this happens inside /app in the image
WORKDIR /app

# Copy only the dependency manifests first, so this layer caches well
COPY package*.json ./

# Install exactly what's pinned in package-lock.json
RUN npm ci

# Now copy the rest of your source
COPY . .

# Document the port your Express app listens on
EXPOSE 3000

# The command Docker runs when a container starts from this image
CMD ["node", "server.js"]

Read that top to bottom and notice what it is: a list of instructions. FROM, COPY, RUN, CMD. Nobody is running your app yet. You wrote down the recipe. The kitchen is still cold.

A couple of these instructions trip people up because they sound like they execute now. They don’t. EXPOSE 3000 does not open a port; it’s a note in the recipe saying “this app intends to use 3000.” CMD does not run anything during the build; it records the default command for later, when a container starts. Both are instructions about the future, written into the image, not actions happening as you build.

An image is the built result, frozen

When you run docker build, Docker reads the Dockerfile from top to bottom and produces an image: a read-only snapshot of a filesystem (your OS bits, your Node runtime, your code, your installed packages) plus some metadata like that default CMD.

# Build an image from the Dockerfile in the current directory (the ".")
# Tag it "myapi" with the version label "1.0"
docker build -t myapi:1.0 .

That -t flag (for “tag”) gives the image a name you can refer to later, myapi:1.0. The . at the end is the build context: the folder Docker hands to the build so COPY has something to copy from. Forget the dot and you’ll get an error that reads like Docker is mad at you for nothing; it just doesn’t know where your files are.

After that finishes, you have an image sitting in local storage. You can see it:

# List the images you've built or pulled
docker images
REPOSITORY   TAG    IMAGE ID       CREATED          SIZE
myapi        1.0    a1b2c3d4e5f6   12 seconds ago   180MB

Two things about an image that matter. First, it is read-only and frozen. Once built, it does not change. Building again doesn’t edit the old image; it produces a new one (often reusing cached layers, which is why the second build is faster). Second, an image is not running. It’s a snapshot sitting on disk, the cooked dish in the freezer, doing absolutely nothing until you ask for an instance of it.

That word “tag” is also where a classic mix-up lives. myapi:1.0 is a name for an image. It is not a container, and it is not “the running thing.” Tagging an image no more starts it than naming a recipe cooks dinner.

A container is a running instance

docker run is where motion finally happens. It takes an image and creates a container: a live process, running the image’s CMD, with its own thin writable layer stacked on top of the read-only image underneath.

# Create and start a container from the myapi:1.0 image
# Map your machine's port 3000 to the container's port 3000
docker run -p 3000:3000 myapi:1.0

Now your Express app is actually serving requests at http://localhost:3000. The -p 3000:3000 maps a port on your machine to a port inside the container, because containers are isolated by default and won’t expose anything unless you tell them to. (Ports get their own treatment in the buzzwords guide; the short version is host:container.)

The part people miss is that writable layer. The image underneath stays frozen and read-only. Anything the container writes (a log file, an uploaded image, a row of edits, a file you vim into existence while debugging) lands in that container’s private writable layer, sitting on top of the shared image. It belongs to that one container. It is not in the image, and it is not shared with any other container.

That single fact explains the bug from the top of this page. Edit a file inside a running container and you’ve written to the container’s writable layer, not the image. Throw the container away and you threw the edit away with it.

You can run as many containers from one image as you want, and each gets its own isolated writable layer:

# Three independent containers, all from the same frozen image
docker run -d -p 3001:3000 myapi:1.0
docker run -d -p 3002:3000 myapi:1.0
docker run -d -p 3003:3000 myapi:1.0

That -d runs each one detached (in the background) so it doesn’t hog your terminal. Three live processes, three separate writable layers, one image they all share underneath and none of them can alter. Class to objects, exactly.

The two verbs that connect everything

Here are the only two commands you need to keep the whole picture straight. Everything else is detail.

# RECIPE -> SNAPSHOT
# Read the Dockerfile, produce a frozen image named myapi:1.0
docker build -t myapi:1.0 .

# SNAPSHOT -> RUNNING INSTANCE
# Take that image, start a live container from it
docker run -p 3000:3000 myapi:1.0

docker build turns a Dockerfile into an image. docker run turns an image into a container. If you ever lose the thread, ask which arrow you’re on. Build goes recipe to snapshot. Run goes snapshot to instance. They are not interchangeable, and noticing which one a README is asking you to do is half of reading Docker instructions correctly.

Stopping a container is not deleting it

This is the second big surprise, and it’s the source of the “why do I have forty containers” moment everyone eventually has.

When you stop a container, it does not vanish. It stops being a running process, but the container, writable layer and all, sticks around in a stopped state.

# Gracefully stop a running container by name or ID
docker stop hungry_einstein

Run the normal listing and it looks gone:

# Lists only RUNNING containers
docker ps

But add -a (for “all”) and there it is, parked:

# Lists ALL containers, running and stopped
docker ps -a
CONTAINER ID   IMAGE       STATUS                     NAMES
9f8e7d6c5b4a   myapi:1.0   Exited (0) 2 minutes ago   hungry_einstein
3a2b1c0d9e8f   myapi:1.0   Exited (137) 1 hour ago    sad_napier

That STATUS column is genuinely useful when something crashed. Exited (0) means a clean exit. Any other number is the exit code your process died with, and 137 specifically usually means the container was killed for running out of memory. Stopped containers are a little black box of how the process ended, which is exactly why Docker keeps them around instead of deleting them out from under you.

To actually delete a stopped container, you rm it:

# Delete one stopped container (and its writable layer) for good
docker rm hungry_einstein

# Delete every stopped container at once when they pile up
docker container prune

docker rm removes the container and its writable layer. The image is untouched, sitting right where it was, ready to make more containers.

So the full lifecycle, with the right verb for each step:

  • docker run myapi:1.0 creates and starts a new container.
  • docker stop <name> halts it but keeps it around (visible in docker ps -a).
  • docker start <name> resumes that same stopped container, writable layer and all.
  • docker rm <name> deletes it permanently.
  • docker run myapi:1.0 again gives you a clean new container that knows nothing about the old one.

The comparison table to keep on your desk

DockerfileImageContainer
What it isA text file of build instructionsA frozen, read-only filesystem snapshot plus metadataA running instance with its own writable layer
How you make itYou write it by handdocker build -t name:tag .docker run name:tag
The analogyThe recipeThe cooked dish in the freezer (the class)The plate you eat from (the object)
Does it run?No, it’s just textNo, it’s a snapshot on diskYes, it’s a live process
Can it change?You edit the fileNo, rebuild for a new oneYes, but only its own writable layer

Classic traps

These are the ones that get nearly everyone at least once.

What you didWhat you expectedWhat actually happened
Edited a file inside a running containerThe image now has your changeThe change lives in that container’s writable layer; rebuild or docker rm and it’s gone
Ran docker run again after stoppingYour previous container, resumedA brand new container with an empty writable layer; you wanted docker start
Called the tag myapi:1.0 “the container”The thing that’s runningThat’s the image name; the container is the instance you got from docker run
Ran docker build to restart your appThe app comes back upYou only rebuilt the image; docker run is what starts it
Saw nothing in docker ps and assumed it was deletedStopped means goneStopped containers hide in docker ps -a until you rm them

Why this matters when you actually ship something

This isn’t trivia. The moment you push a Dockerized app to a host like Railway

Heads up. You're leaving raindev.fyi

This link heads to railway.app, an external site we don't control. Cool to keep going?

Continue
, Render

Heads up. You're leaving raindev.fyi

This link heads to render.com, an external site we don't control. Cool to keep going?

Continue
, or your own server, this exact distinction is the whole deployment.

The platform clones your repo, runs docker build to bake your Dockerfile into an image, and then runs docker run (often several times, behind a load balancer) to start containers from that one image. When you redeploy, it builds a new image and starts new containers; it does not log into the old ones and patch them. That’s why production servers are described as disposable and “stateless,” and why anything precious (your Postgres data, your Redis cache, uploaded files) has to live in a managed database or a volume, never inside a container’s writable layer.

Get the three nouns straight and a pile of production advice suddenly makes sense on its own. Build the image once, run it anywhere, keep state outside the container, and rebuild instead of patching. It all falls out of one picture: the recipe, the frozen dish, and the plate you’re allowed to make a mess on.