DEV Community

Cover image for How I Shrank a Next.js Image by 80% in My First Week of Docker
Tazoh Yanick
Tazoh Yanick

Posted on

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

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.

Days 1-2: Foundations and the image-vs-container confusion

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.

Day 3: The optimization story

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"
Enter fullscreen mode Exit fullscreen mode

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: Multi-container systems with Compose

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
Enter fullscreen mode Exit fullscreen mode

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 readinessorder."

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

Enter fullscreen mode Exit fullscreen mode

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: Networking deep dive (the gotcha that bites everyone)

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 bridgenetwork **, 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 — web1and 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
Enter fullscreen mode Exit fullscreen mode

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

Three things I'd tell another Docker beginner

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.

What's next

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. 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.

Week 1 down. Twenty-one to go.

Top comments (0)