# How I Shrank a Next.js Image by 80% in My First Week of Docker

> Source: <https://dev.to/tyanick/how-i-shrank-a-nextjs-image-by-80-in-my-first-week-of-docker-3ego>
> Published: 2026-06-04 09:33:10+00:00

I'm a full-stack developer pivoting toward Site Reliability Engineering and Platform Engineering. I'm based in Yaoundé, planning to move to Canada later this year, and I've decided the next 5 months are about turning my full-stack background into infrastructure skills that hold up against AI automation. Week 1 was Docker. I'd never used it before.

Here's what I learned, the mistakes I made, and the optimization story I didn't expect.

Day 1 was installation and concepts. Day 2 was my first real container — a tiny Node.js HTTP server, containerized from scratch.

The conceptual flip everyone hits on Day 2 hit me too. When my mentor asked me what an image was versus a container, I answered confidently — and got it backwards. I said the image was the thing you share and the container was the read-only template. It's the opposite.

Here's the version that finally stuck for me:

An image is the read-only blueprint. A container is a running instance created from an image. Images get shared via Docker Hub. Containers run locally.

The other concepts that took a beat to land:

**The Docker daemon doesn't just relay commands, it does the actual work.** Your terminal (the client) is a waiter; the daemon is the kitchen. When you type docker run, the daemon builds, runs, pulls, and manages everything.

**Image layers exist for caching.** Change one line of source code and only the affected layer rebuilds. Push to Docker Hub and only changed layers go over the network.

By end of Day 2 I had a 46 MB hello-world image pushed to Docker Hub. Small, but mine.

This is where Docker stopped being academic.

The plan for Day 3 was to containerize my actual deployed project: an AI immigration assistant I built for the recent DEV Gemma 4 Challenge. Same Next.js code that's running in production on Vercel. I wrote a multi-stage Dockerfile, used `npm ci --omit=dev`

, picked the Alpine Node base. Built the image.

It came out to **1.37 GB.**

That's not a working Dockerfile — that's a broken one with a working app inside it. So I did what an SRE would actually do: I ran `docker history`

and looked at where the bytes lived.

| Layer | Size |
|---|---|
`node_modules` (from deps stage) |
493 MB |
`.next` (full build output) |
326 MB |
| Node 20 Alpine base | 130 MB |
| Everything else | tiny |

Two problem layers, 819 MB combined. The diagnosis was clear: even with `--omit=dev`

, my `node_modules`

was carrying packages npm classified as devDependencies but Next.js actually needs at runtime. And the full .next directory was carrying build caches, source maps, and intermediate compiler artifacts I'd never use.

The fix was a Next.js feature called **standalone output mode**. One line in `next.config.ts`

:

```
tsoutput: "standalone"
```

What this does: Next.js traces the compiled application and bundles only the files genuinely needed at runtime into `.next/standalone/`

. The standalone output for my project has 10 directories in its `node_modules`

— React, ReactDOM, Next.js itself, the SWC compiler, sharp, and a few small utilities. That's it. Total standalone size: **18 MB.**

I rewrote the Dockerfile to copy only `.next/standalone`

and `.next/static`

instead of the whole `node_modules`

and `.next`

directories. One more change: `ENV HOSTNAME=0.0.0.0`

, because standalone defaults to localhost and that means Docker port mapping doesn't work.

Rebuilt.

| Version | Image Size | Compressed on Docker Hub |
|---|---|---|
`:1.0` |
1.37 GB | — |
`:1.1` |
269 MB |
64.6 MB |

**80% reduction. 5x smaller. Same app, same Node base, same architecture.**

The lesson wasn't "Docker is hard." It was: capable defaults aren't always sufficient defaults. Real optimization comes from looking at *where the bytes are* and applying the right targeted fix.

Day 4 was about the leap from one container to systems of containers. I built a small Node API + Postgres stack, orchestrated with a `docker-compose.yml`

.

The moment that made Compose click for me was this log line, right after I ran `docker compose up -d`

:

```
✔ Container lab-03-compose-multi-container-db-1   Healthy   6.2s
✔ Container lab-03-compose-multi-container-api-1  Started   6.5s
```

The database was declared _healthy _at 6.2 seconds. The API started at 6.5 seconds. The 300 ms gap was Compose waiting — specifically, `depends_on`

with `condition: service_healthy`

waiting for Postgres's `pg_isready`

healthcheck to pass before letting the API container come up. Without that gate, the API would start instantly and try to connect to a Postgres that wasn't ready yet, get `ECONNREFUSED`

, and crash.

That's *production-shape orchestration*: not just "start everything in order" but "start everything in `readiness`

order."

The other concept that landed: `service discovery by name`

. Inside the API container, my Node code connects to Postgres using the hostname `db`

. Not `localhost`

. Not an IP. Just `db`

. Compose creates a private network where the service name is the hostname, automatically. I tested it from inside the running container:

```
/app # ping db
PING db (172.19.0.2): 56 data bytes
64 bytes from 172.19.0.2: seq=0 ttl=64 time=2.5 ms
```

That's the entire conceptual basis for how Kubernetes services work too, by the way. Day 4 taught me orchestration vocabulary I'll need in Week 12 when CKA prep starts.

One more thing worth knowing: **volumes**. I tested it explicitly. `docker compose down`

keeps the named volume — my Postgres data survived the restart. `docker compose down -v`

removed the volume and the data was gone. That distinction matters for any stateful production service.

Day 5 closed Week 1 with the one Docker concept I hadn't drilled by hand yet — networks.

Docker has three network drivers worth knowing:

**Bridge**: single-host private network. ~90% of what you'll ever use.

**Host**: no network isolation at all; container shares the host's network stack directly. Performance-critical use cases only.

**Overlay**: multi-host networking for Docker Swarm or distributed orchestrators. Conceptual predecessor to Kubernetes networking.

The gotcha that bites everyone is this: **the default** `bridge`

**network ****, the one that ships with Docker out of the box — does not give you DNS resolution by container name.** If two containers attach to the default bridge, they can only reach each other by IP address. And container IPs change every time you restart.

Custom bridge networks (the ones you create with `docker network create my-network`

) do give you automatic DNS by name. Same driver, totally different behavior.

This is why every real-world Docker setup creates custom networks. It's also why `docker-compose.yml`

automatically creates a custom network for your stack — that's the entire reason Compose works the way it does.

The rule worth memorizing: **never use the default bridge in production. Always create a custom network.**

I tested it with two unrelated containers — `web1`

and `web2`

— attached to a network I created manually. From inside `web1`

:

```
/ # ping -c 3 web2
PING web2 (172.19.0.3): 56 data bytes
64 bytes from 172.19.0.3: seq=0 ttl=64 time=1.944 ms
```

`web2`

resolved to `172.19.0.3`

automatically. No configuration, no IP hardcoding. The container name became the hostname.

**Verify everything you write to a file.** I corrupted a README this week by piping a heredoc that swallowed a special character mid-stream. The file ended mid-sentence, and I didn't notice until commit. After every cat > file << EOF, run cat file to confirm what landed. This is a real DevOps habit. Verify state, don't assume.

**Multi-stage builds aren't optional. They're how you avoid 1 GB images.** A single-stage Dockerfile for a typical Next.js project will produce an image somewhere between 800 MB and 1.5 GB. Separating build from runtime, copying only the runtime artifacts forward, is the basic discipline. Combine it with framework-specific tricks (like Next.js standalone), and you get 80% reductions for free.

**The default bridge network is a trap. Always create a custom network.** I covered this above but it bears repeating because it's the kind of thing people learn the hard way at 2 AM when their containers can't talk to each other on the default bridge. Custom networks come with automatic DNS resolution. Default doesn't. Just use custom.

Week 2 starts AWS Solutions Architect Associate prep — the next chapter in the SRE pivot. The plan: certification by end of June, then Terraform, networking deep-dive, Kubernetes, and CKA before the move to Canada.

Everything from this week lives on GitHub:[ github.com/t-yanick/sre-portfolio-2026](https://github.com/t-yanick/sre-portfolio-2026). Three labs, real READMEs, the full optimization story documented with `docker history`

outputs and Dockerfiles.

The deployed AI immigration assistant — now also available as a 64.6 MB Docker image on Docker Hub at `tyanick237/gemma-canada-assistant:1.1`

— is at [gemma-canada-assistant.vercel.app](https://gemma-canada-assistant.vercel.app).

Week 1 down. Twenty-one to go.
