DEV Community

Cover image for How I Shrunk My Docker Images by 98% (Go + Next.js)
Sumedhvats
Sumedhvats

Posted on

How I Shrunk My Docker Images by 98% (Go + Next.js)

I was building pasteCTL — a real-time collaborative paste/code sharing app (source on GitHub) — and at some point I opened lazydocker to check on things and saw this:

Initial docker image size on lazydocker

Combined, my two services were sitting at nearly 3.6GB. For a Go API and a Next.js frontend.

The app worked fine. But those numbers were going to slow down every deploy, eat registry storage, and make cold starts painful. So I fixed both of them. Here's the full process, step by step.


Why Docker Images Get So Big

When you write FROM golang:1.26 or FROM node, you're pulling an image designed for development — it includes the compiler, build tools, package managers, debug utilities, and a full OS userland. All of that gets baked into your final image even though none of it runs in production.

The Go compiler alone is ~600MB. The default node image (Debian-based) is over 1GB before you install a single dependency. By the time you run npm install or go mod download, you're already deep in the hole.

The fix is multi-stage builds: use one container to compile and build, then throw it away and copy only the output into a minimal runtime container.


Step 1 — Fix the Go Backend

Before

FROM golang:1.26
ENV FRONTEND_URL=http://paste.sumedh.app
ENV DATABASE_URL=https://notdburl.com
RUN apt-get install -y --no-install-recommends git ca-certificates tzdata
WORKDIR /backend
COPY . .
RUN go mod download
RUN go build -o main cmd/main.go
RUN chmod +x main
EXPOSE 8080
CMD ["./main"]
Enter fullscreen mode Exit fullscreen mode

This uses golang:1.26 — the full Debian-based image. It compiles the binary and then leaves the entire Go toolchain, Debian base, and all source code sitting inside the final image. Size: 1.60GB.

After

FROM golang:1.26-alpine AS builder

RUN apk add --no-cache git ca-certificates tzdata
RUN adduser -D -g '' appuser

WORKDIR /backend
COPY go.mod go.sum ./
RUN go mod download
COPY . .

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -trimpath -ldflags="-w -s -extldflags '-static'" -o /app/main ./cmd

FROM scratch AS runner

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /app/main /main

USER appuser
EXPOSE 8080
ENTRYPOINT ["/main"]
Enter fullscreen mode Exit fullscreen mode

Size: ~15MB.

Here's what changed and why each change matters:

Switch to golang:1.26-alpine for the build stage. Alpine is a minimal Linux distro. The Alpine-based Go image is a fraction of the Debian one — same compiler, none of the Debian bloat.

Use FROM scratch for the runner. This is the biggest backend win. scratch is a completely empty image — zero bytes, no OS, no shell, nothing. Since Go can compile to a fully static binary with no OS dependencies at all, you don't need an OS in the runner. The final image size is essentially just your binary.

Copy go.mod and go.sum before copying source. Docker caches each layer. If you COPY . . everything at once, any change to any source file invalidates the dependency download layer and re-downloads all your modules. Copying only the mod files first means that layer only busts when your dependencies actually change.

Add -trimpath and -extldflags '-static' to the build. -trimpath removes file system paths from the compiled binary, making it smaller and reproducible across machines. -extldflags '-static' guarantees no dynamic C libraries are linked — required for the binary to run in a scratch container.

Create the non-root user in the builder stage. Because scratch is empty, there's no adduser binary in the runner. You create the user in the builder and copy /etc/passwd across so the USER appuser directive has something to reference.

Copy certificates and timezone data from the builder. scratch has no filesystem at all, so anything your app needs at runtime must be explicitly copied. CA certificates are needed for outbound HTTPS calls, and zoneinfo is needed if your app does anything timezone-aware.


Step 2 — Fix the Next.js Frontend

Before

FROM node
ENV NEXT_PUBLIC_BACKEND_URL = http://paste.sumedh.app
ENV NEXT_PUBLIC_WS_URL = ws://paste.sumedh.app
WORKDIR /frontend
COPY . .
RUN npm install --legacy-peer-deps
RUN npm run build
EXPOSE 3000
CMD ["npm","run","start"]
Enter fullscreen mode Exit fullscreen mode

This uses FROM node — the full default Node image, over 1GB by itself. Then npm install pulls in all of node_modules including every dev dependency: TypeScript, ESLint, webpack, the works. Size: 1.98GB.

After

FROM node:22-alpine AS builder

RUN apk add --no-cache libc6-compat
WORKDIR /frontend

ARG NEXT_PUBLIC_BACKEND_URL
ARG NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL \
    NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL \
    NEXT_TELEMETRY_DISABLED=1

COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps

COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app

RUN apk add --no-cache libc6-compat

ENV NODE_ENV=production \
    NEXT_TELEMETRY_DISABLED=1 \
    PORT=3000 \
    HOSTNAME=0.0.0.0

RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

COPY --from=builder --chown=nextjs:nodejs /frontend/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /frontend/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /frontend/public ./public

USER nextjs
EXPOSE 3000

CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

Size: 190.53MB.

Here's what changed:

Switch from node to node:22-alpine. Same move as the backend — Alpine drops the Debian userland. node:22-alpine is around 130MB vs 1GB+ for the default.

Add libc6-compat to both stages. Next.js uses SWC (a Rust-based compiler) and image optimization libraries like Sharp that depend on glibc. Alpine uses musl libc instead, and without this compatibility shim, the build or runtime will crash. It needs to be in both the builder and runner.

Copy package.json and package-lock.json first. Same layer caching logic as the Go backend. Your node_modules only rebuilds when your dependency files change.

Use npm ci instead of npm install. npm ci installs exactly what's in package-lock.json, skips the resolution step, and is faster and fully reproducible.

Use ARG for public env variables. The original Dockerfile hardcoded the URLs. Using ARG lets you pass them in at build time so the same Dockerfile works across environments.

Copy only the standalone output in the runner. Next.js has an output: 'standalone' mode that produces a minimal self-contained server bundle under .next/standalone with only the production Node dependencies your app actually needs — not all of node_modules. You also copy .next/static and public. That's it.

Note: You need output: 'standalone' in your next.config.js for this to work. Without it, the .next/standalone directory won't be generated and the build will fail.


Step 3 — The .dockerignore Files

A frequently missed cause of slow builds and inflated images is sending unnecessary files to the Docker daemon when COPY . . runs. Every file in the build context gets sent over, even if it never ends up in the image. Add these to both directories.

./backend/.dockerignore

.git
.idea
.vscode
*.md
bin/
Enter fullscreen mode Exit fullscreen mode

./frontend/.dockerignore

.git
.next
node_modules
.env*.local
*.md
.vscode
.idea
npm-debug.log*
yarn-debug.log*
yarn-error.log*
Enter fullscreen mode Exit fullscreen mode

The node_modules entry is especially important for the frontend. Without it, your entire local node_modules folder gets sent to the daemon and partially shadows what's installed inside the container, which causes subtle and confusing bugs.


Step 4 — Tighten the Docker Compose

With the images sorted, there were a couple of things worth fixing in the compose file too.

Container logs grow indefinitely by default. On a long-running server, uncapped JSON logs will quietly eat your disk. Adding a log driver config caps each service at 30MB total (3 files × 10MB).

The Postgres volume mount had a bug. The original used /var/lib/postgresql as the mount target. Postgres actually stores data in /var/lib/postgresql/data — mounting the parent can cause initialization to fail if that directory already has files in it.

name: pasteCTL_web

x-logging: &default-logging
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: pastectl_backend
    ports:
      - "8080:8080"
    environment:
      - FRONTEND_URL=http://localhost:3000
      - DATABASE_URL=postgres://user:password@db:5432/pastectl
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/health || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    restart: unless-stopped
    logging: *default-logging

  db:
    image: postgres:18-alpine
    container_name: pastectl_db
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: pastectl
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d pastectl"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    logging: *default-logging

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      args:
        - NEXT_PUBLIC_BACKEND_URL=http://localhost:8080
        - NEXT_PUBLIC_WS_URL=ws://localhost:8080
    container_name: pastectl_frontend
    ports:
      - "3000:3000"
    depends_on:
      backend:
        condition: service_healthy
    restart: unless-stopped
    logging: *default-logging

volumes:
  pgdata:
Enter fullscreen mode Exit fullscreen mode

The Final Numbers

Final docker image size on lazydocker

3.58GB down to ~205MB. Both services, same functionality, no changes to application code.


What You Could Still Improve

These Dockerfiles are solid for production, but there are a few things worth exploring if you want to go further:

Distroless runner for the frontend. Google's gcr.io/distroless/nodejs22-debian12 image is more locked-down than Alpine — no shell, no package manager, no utilities. Harder to debug but a smaller attack surface.

docker scout or Trivy for CVE scanning. Smaller images have fewer vulnerabilities, but Alpine and even scratch aren't immune. Running a scanner in CI catches issues before they reach production.

BuildKit cache mounts for Go modules. Instead of the COPY go.mod trick, BuildKit's --mount=type=cache keeps the module cache between builds on the same machine, making repeated local builds significantly faster.

Pin base image digests. golang:1.26-alpine is a mutable tag — it can change under you. For reproducible builds, pin to the SHA digest: FROM golang:1.26-alpine@sha256:....

Build the images in CI and push to a registry. Right now the images are built on the host. Moving the build to GitHub Actions and pushing to GHCR or Docker Hub means your production server only pulls, never builds.


The full source for pasteCTL is on GitHub if you want to look at the actual Dockerfiles in context.

Top comments (0)