{"slug": "how-i-shrank-a-next-js-image-by-80-in-my-first-week-of-docker", "title": "How I Shrank a Next.js Image by 80% in My First Week of Docker", "summary": "A developer new to Docker reduced a Next.js container image from 1.37 GB to 269 MB—an 80% compression—during their first week of learning the platform. The optimization was achieved by enabling Next.js's standalone output mode, which traces the application and bundles only the runtime-necessary files, shrinking the `node_modules` and `.next` directories from 819 MB to 18 MB. The developer diagnosed the bloat using `docker history` and applied targeted fixes rather than relying on default configurations.", "body_md": "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.\n\nHere's what I learned, the mistakes I made, and the optimization story I didn't expect.\n\nDay 1 was installation and concepts. Day 2 was my first real container — a tiny Node.js HTTP server, containerized from scratch.\n\nThe 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.\n\nHere's the version that finally stuck for me:\n\nAn 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.\n\nThe other concepts that took a beat to land:\n\n**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.\n\n**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.\n\nBy end of Day 2 I had a 46 MB hello-world image pushed to Docker Hub. Small, but mine.\n\nThis is where Docker stopped being academic.\n\nThe 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`\n\n, picked the Alpine Node base. Built the image.\n\nIt came out to **1.37 GB.**\n\nThat'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`\n\nand looked at where the bytes lived.\n\n| Layer | Size |\n|---|---|\n`node_modules` (from deps stage) |\n493 MB |\n`.next` (full build output) |\n326 MB |\n| Node 20 Alpine base | 130 MB |\n| Everything else | tiny |\n\nTwo problem layers, 819 MB combined. The diagnosis was clear: even with `--omit=dev`\n\n, my `node_modules`\n\nwas 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.\n\nThe fix was a Next.js feature called **standalone output mode**. One line in `next.config.ts`\n\n:\n\n```\ntsoutput: \"standalone\"\n```\n\nWhat this does: Next.js traces the compiled application and bundles only the files genuinely needed at runtime into `.next/standalone/`\n\n. The standalone output for my project has 10 directories in its `node_modules`\n\n— React, ReactDOM, Next.js itself, the SWC compiler, sharp, and a few small utilities. That's it. Total standalone size: **18 MB.**\n\nI rewrote the Dockerfile to copy only `.next/standalone`\n\nand `.next/static`\n\ninstead of the whole `node_modules`\n\nand `.next`\n\ndirectories. One more change: `ENV HOSTNAME=0.0.0.0`\n\n, because standalone defaults to localhost and that means Docker port mapping doesn't work.\n\nRebuilt.\n\n| Version | Image Size | Compressed on Docker Hub |\n|---|---|---|\n`:1.0` |\n1.37 GB | — |\n`:1.1` |\n269 MB |\n64.6 MB |\n\n**80% reduction. 5x smaller. Same app, same Node base, same architecture.**\n\nThe 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.\n\nDay 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`\n\n.\n\nThe moment that made Compose click for me was this log line, right after I ran `docker compose up -d`\n\n:\n\n```\n✔ Container lab-03-compose-multi-container-db-1   Healthy   6.2s\n✔ Container lab-03-compose-multi-container-api-1  Started   6.5s\n```\n\nThe 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`\n\nwith `condition: service_healthy`\n\nwaiting for Postgres's `pg_isready`\n\nhealthcheck 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`\n\n, and crash.\n\nThat's *production-shape orchestration*: not just \"start everything in order\" but \"start everything in `readiness`\n\norder.\"\n\nThe other concept that landed: `service discovery by name`\n\n. Inside the API container, my Node code connects to Postgres using the hostname `db`\n\n. Not `localhost`\n\n. Not an IP. Just `db`\n\n. Compose creates a private network where the service name is the hostname, automatically. I tested it from inside the running container:\n\n```\n/app # ping db\nPING db (172.19.0.2): 56 data bytes\n64 bytes from 172.19.0.2: seq=0 ttl=64 time=2.5 ms\n```\n\nThat'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.\n\nOne more thing worth knowing: **volumes**. I tested it explicitly. `docker compose down`\n\nkeeps the named volume — my Postgres data survived the restart. `docker compose down -v`\n\nremoved the volume and the data was gone. That distinction matters for any stateful production service.\n\nDay 5 closed Week 1 with the one Docker concept I hadn't drilled by hand yet — networks.\n\nDocker has three network drivers worth knowing:\n\n**Bridge**: single-host private network. ~90% of what you'll ever use.\n\n**Host**: no network isolation at all; container shares the host's network stack directly. Performance-critical use cases only.\n\n**Overlay**: multi-host networking for Docker Swarm or distributed orchestrators. Conceptual predecessor to Kubernetes networking.\n\nThe gotcha that bites everyone is this: **the default** `bridge`\n\n**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.\n\nCustom bridge networks (the ones you create with `docker network create my-network`\n\n) do give you automatic DNS by name. Same driver, totally different behavior.\n\nThis is why every real-world Docker setup creates custom networks. It's also why `docker-compose.yml`\n\nautomatically creates a custom network for your stack — that's the entire reason Compose works the way it does.\n\nThe rule worth memorizing: **never use the default bridge in production. Always create a custom network.**\n\nI tested it with two unrelated containers — `web1`\n\nand `web2`\n\n— attached to a network I created manually. From inside `web1`\n\n:\n\n```\n/ # ping -c 3 web2\nPING web2 (172.19.0.3): 56 data bytes\n64 bytes from 172.19.0.3: seq=0 ttl=64 time=1.944 ms\n```\n\n`web2`\n\nresolved to `172.19.0.3`\n\nautomatically. No configuration, no IP hardcoding. The container name became the hostname.\n\n**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.\n\n**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.\n\n**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.\n\nWeek 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.\n\nEverything 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`\n\noutputs and Dockerfiles.\n\nThe deployed AI immigration assistant — now also available as a 64.6 MB Docker image on Docker Hub at `tyanick237/gemma-canada-assistant:1.1`\n\n— is at [gemma-canada-assistant.vercel.app](https://gemma-canada-assistant.vercel.app).\n\nWeek 1 down. Twenty-one to go.", "url": "https://wpnews.pro/news/how-i-shrank-a-next-js-image-by-80-in-my-first-week-of-docker", "canonical_source": "https://dev.to/tyanick/how-i-shrank-a-nextjs-image-by-80-in-my-first-week-of-docker-3ego", "published_at": "2026-06-04 09:33:10+00:00", "updated_at": "2026-06-04 09:41:58.374453+00:00", "lang": "en", "topics": ["ai-infrastructure"], "entities": ["Docker", "Docker Hub", "Node.js", "Yaoundé", "Canada"], "alternates": {"html": "https://wpnews.pro/news/how-i-shrank-a-next-js-image-by-80-in-my-first-week-of-docker", "markdown": "https://wpnews.pro/news/how-i-shrank-a-next-js-image-by-80-in-my-first-week-of-docker.md", "text": "https://wpnews.pro/news/how-i-shrank-a-next-js-image-by-80-in-my-first-week-of-docker.txt", "jsonld": "https://wpnews.pro/news/how-i-shrank-a-next-js-image-by-80-in-my-first-week-of-docker.jsonld"}}