# From 354 MB to 16 MB: Docker under the hood, multi-stage builds and a Vercel deploy from the terminal

> What an image is, what a container is, how Dockerfile order drives the cache, why a multi-stage build left my Go server at 16 MB, why containers don't step on each other and why data is lost without a volume. Finished off with a Vercel deploy from the terminal.

- Author: Rubén Rangel (https://rubenrangel.org)
- Published: 2026-10-03
- Updated: 2026-10-03
- Language: en
- Canonical: https://rubenrangel.org/en/blog/from-354-mb-to-16-mb-docker-under-the-hood-multi-stage-and-vercel-deploy

*What an image really is, what a container is, why the order of your Dockerfile decides how long you wait on every build, and how I ended up shipping a Go server to Vercel with a single command.*

---

I wrote a twenty-line HTTP server in Go. The first image that packaged it weighed **354 MB**. The final version, which does exactly the same thing, weighs **16 MB**. I didn't change a single line of Go. I only changed how the Dockerfile was written.

This article explains why, starting with the basics: what an image is and what a container is. If you already have those down, skip to the caching section.

## Image and container: the recipe and the dish

An **image** is a read-only package with everything an application needs to start: a minimal filesystem, libraries, the binary and the instruction for what to run. It doesn't run, doesn't change, has no state. It's a template.

A **container** is a running image. Docker takes the image, puts a thin writable layer on top of it, gives it its own process and network space, and starts the command. Whatever the program writes goes to that layer, never to the image.

If the image is the recipe, the container is the dish being served. You can serve ten dishes from the same recipe, and if you salt one of them, the recipe doesn't change and neither do the other nine.

## How a Dockerfile becomes layers

An image is not a single block. It's a **stack of layers**, and almost every instruction in the Dockerfile produces one. Each layer stores only what changed compared to the previous one.

![Each Dockerfile instruction becomes a layer of the image](https://rubenrangel.org/images/blog/docker-capas-en.svg)

This is the final Dockerfile of my project:

```dockerfile
FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY . .
RUN go build -o /server main.go

FROM alpine:3.20 AS runner
COPY --from=builder /server /server
CMD [ "/server" ]
```

And this is what Docker reports for the final image with `docker history`:

```
CMD ["/server"]                         0B
COPY /server /server                    8.15MB
CMD ["/bin/sh"]                         0B
ADD alpine-minirootfs-3.20.10 ...       7.81MB
```

Two layers with weight: the Alpine base system (7.8 MB) and my binary (8.1 MB). Instructions like `CMD` or `WORKDIR` only store metadata and weigh 0 bytes. There are the 16 MB, not one more.

## The cache: step order matters

Because each layer depends on the one before it, Docker can reuse them. On a rebuild it walks the Dockerfile top to bottom and, as long as the instruction and its input files haven't changed, it uses the layer it already has stored. **As soon as one layer changes, every layer after it gets rebuilt.**

That turns ordering into a design decision. The rule: **what rarely changes goes on top, what changes often goes at the bottom.**

In a Go project with dependencies, the difference looks like this:

```dockerfile
# Bad: any code change invalidates the dependency download
COPY . .
RUN go mod download
RUN go build -o /server .
```

```dockerfile
# Good: dependencies are only downloaded if go.mod or go.sum change
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /server .
```

In the bad version, editing a comment in `main.go` changes the context of `COPY . .`, and that forces every dependency to be downloaded again. In the good one, `go.mod` and `go.sum` didn't change, so the dependency layer comes from cache and only the code is recompiled.

The benefits are concrete:

1. **Faster builds.** The expensive work (downloading dependencies, installing system packages) is done once and reused.
2. **Less network and disk.** Shared layers are stored only once, even if many images use them.
3. **Lighter deploys.** When pushing or pulling an image, only the layers the destination doesn't have travel.

One detail that completes the rule: a `.dockerignore` file that excludes `.git`, `node_modules` or local binaries keeps irrelevant changes from invalidating `COPY . .`.

## Multi-stage: build in one image, run in another

Here's the difference between 354 MB and 16 MB.

To compile Go you need the compiler, the standard library and the build tools. To **run** the resulting binary you need none of that. A single-stage Dockerfile puts everything into the final image:

```dockerfile
# The bad: a single stage. The final image drags the compiler along.
FROM golang:1.24-alpine
WORKDIR /src
COPY . .
RUN go build -o /server main.go
CMD [ "/server" ]
```

A **multi-stage build** separates responsibilities. Each `FROM` starts a new stage with its own base:

1. **`builder`** starts from `golang:1.24-alpine`, compiles and produces the binary. It's a throwaway stage.
2. **`runner`** starts from `alpine:3.20`, which is clean, and with `COPY --from=builder` takes **only** the binary.

Everything that was in `builder` (the compiler, the source code, the build cache) stays out of the final image. Measured on my machine:

| Image | Size | Contains the compiler? | Contains the source code? |
|---|---|---|---|
| `builder` stage (same as a single stage) | **354 MB** | Yes | Yes |
| `runner` stage (final image) | **16 MB** | No | No |

95% smaller. And it's not just size: fewer things inside means fewer packages with vulnerabilities, nothing useful for an attacker who gets into the container, and faster startups because there's less to download.

Naming the stages also helps with debugging. If something fails at compile time, you can build just that stage:

```bash
docker build -f Dockerfile.vercel --target builder -t go-vercel:builder .
```

## Many containers, one image, zero interference

Because the image is read-only, nobody can modify it while it runs. Each container gets **its own writable layer** on top of the same shared layers. That's what lets you start ten containers from the same image without them stepping on each other.

![Several containers share the image layers and each one has its own writable layer](https://rubenrangel.org/images/blog/docker-contenedores-en.svg)

I tested it with two containers from the same Alpine image:

```bash
docker run -d --name a alpine:3.20 sleep 300
docker run -d --name b alpine:3.20 sleep 300

docker exec a sh -c 'echo hello > /note.txt'

docker exec a cat /note.txt   # hello
docker exec b cat /note.txt   # No such file or directory
```

The file exists in `a` and not in `b`. They wrote on top of the same image, but each one in its own layer.

## Why data is lost without a volume

The writable layer lives and dies with the container. If you remove the container, its layer goes with it. Continuing the example:

```bash
docker rm -f a
docker run -d --name a alpine:3.20 sleep 300
docker exec a cat /note.txt   # No such file or directory
```

Same name, same image, but it's a new container with a new, empty layer. The note is gone.

And that's not a bug, it's the design: containers are **ephemeral** on purpose, so you can destroy, replace and scale them without fear. A version upgrade, for example, is exactly that: remove the old container and create a new one from the new image.

Whatever has to survive (a database, files uploaded by users) goes in a **volume**: storage that Docker manages outside the container and mounts at a path inside it.

```bash
docker volume create notes
docker run --rm -v notes:/data alpine:3.20 sh -c 'echo hello > /data/note.txt'
docker run --rm -v notes:/data alpine:3.20 cat /data/note.txt   # hello
```

The first container wrote and was destroyed (`--rm`). The second, brand new, found the file. The data lived in the volume, not in the container.

The practical rule: **the image is the code, the container is the process, the volume is the state.**

## The finale: from the terminal to Vercel

With the image optimized, all that was left was shipping it. Vercel can run containers: if the project has a `Dockerfile.vercel`, it uses it to build the image, starts the final stage and injects the `PORT` variable. That's why the server reads it from the environment instead of hardcoding it:

```go
port := os.Getenv("PORT")
if port == "" {
    port = "80"
}
```

The whole deploy was done from the terminal, with no connected repository and no pipeline:

```bash
vercel link    # links the folder to a project
vercel --prod  # builds the image and publishes it to production
```

Vercel detected the project with the **Container** preset, built both stages, and 34 seconds later the server was responding:

```bash
curl https://05-vercel-bice.vercel.app
# Hello from a container on Vercel 👋
```

## What I take away

1. An image is a read-only template made of layers; a container is that template running, with its own layer to write to.
2. The order of the Dockerfile decides what gets reused from cache: stable things on top, changing things at the bottom.
3. Multi-stage separates the workshop from the shop window: you build with everything, you ship only what's needed. In this case, from 354 MB to 16 MB.
4. Containers from the same image don't affect each other because each one writes to its own layer.
5. Whatever isn't in a volume is lost when the container is removed, and that's an advantage if you design for it.

---

*The code is at [github.com/rubenerangel/go-docker-vercel](https://github.com/rubenerangel/go-docker-vercel) and the live demo at [05-vercel-bice.vercel.app](https://05-vercel-bice.vercel.app). Tools: Go 1.24, Docker with BuildKit, Alpine 3.20 and the Vercel CLI.*

