Skip to Content
sudocs

Multistage docker build for go/next.js

setting up production docker with multistage builds for the go/next.js turborepo, mistakes included

why multistage

single stage builds ship everything — compiler, dev dependencies, source files. multistage lets you build in one stage and copy only the output to a lean runtime image. backend went from what would've been 800mb+ to 56mb. frontend from 1.5gb to 247mb.

project structure

this is a turborepo monorepo:

apps/
  backend/   # go/gin
  web/       # next.js 16
packages/    # shared types, eslint config, ts config
docker-compose.yml
docker-compose.dev.yml
.env

backend dockerfile

FROM golang:1.26.1-alpine AS builder

WORKDIR /app

# install migrate binary in builder so we can copy it to runtime
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

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

COPY . .

# compile to a single binary
RUN go build -o main ./cmd/server/main.go

FROM alpine:latest AS runtime

WORKDIR /app

# copy only what we need — binary, migrations, migrate cli
COPY --from=builder /app/main .
COPY --from=builder /app/migrations ./migrations
COPY --from=builder /go/bin/migrate /usr/local/bin/migrate

EXPOSE 5000

# run migrations first then start the server
CMD ["sh", "-c", "migrate -path ./migrations -database $DATABASE_URL up && ./main"]

things to note:

  • golang:1.26.1-alpine is the builder, alpine:latest is the runtime — no go toolchain in prod
  • migrate binary lives at /go/bin/migrate inside the golang alpine image, not /root/go/bin/migrate (tripped on this)
  • migrations run on every startup but migrate up is idempotent — only pending ones run
  • entrypoint path is ./cmd/server/main.go — adjust to wherever your main is

web dockerfile

next.js needs standalone output mode to avoid shipping all of node_modules:

in apps/web/next.config.ts:

const nextConfig = {
  output: "standalone",
};
FROM oven/bun:1.3.9-alpine AS builder

WORKDIR /app

RUN apk add --no-cache libc6-compat

# copy workspace manifests first for layer caching
COPY bun.lock package.json turbo.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/ ./packages/

RUN bun install

COPY apps/web ./apps/web

# NEXT_PUBLIC_* vars are baked at build time, not runtime
# must be passed as build args
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL

RUN bun run build --filter=web

FROM oven/bun:1.3.9-alpine AS runtime

WORKDIR /app

RUN apk add --no-cache libc6-compat

# standalone output includes only what's needed — no node_modules copy
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public

EXPOSE 3000

# standalone mode generates a server.js, run with node not bun run start
CMD ["node", "apps/web/server.js"]

why node and not bun run start:

  • standalone mode outputs a server.js that is meant to be run directly
  • bun run start runs next start which expects the full next.js install
  • standalone has next.js bundled in, so just node server.js is enough

why only 3 copy lines:

  • .next/standalone already contains a self-contained node_modules with only what next.js needs at runtime — traced at build time
  • no need to copy root node_modules, apps/web/node_modules, or package.json separately
  • before standalone we tried copying node_modules manually which ballooned the image to 1.5gb

compose file

name: go-nextjs-prod

services:
  api:
    build:
      context: ./apps/backend
      dockerfile: Dockerfile
    restart: unless-stopped
    env_file:
      - ./apps/backend/.env.production
      - .env
    ports:
      - "${SERVER_PORT}:5000"
    networks:
      - app-network

  web:
    build:
      context: .
      dockerfile: ./apps/web/Dockerfile
      args:
        NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
    restart: unless-stopped
    depends_on:
      - api
    env_file:
      - ./apps/web/.env.production
    ports:
      - "${CLIENT_PORT}:3000"
    networks:
      - app-network

  # only needed for local prod testing, remove in actual prod (using neon)
  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

volumes:
  pgdata:

env files

root .env — compose substitution only:

SERVER_PORT=5000
CLIENT_PORT=3000
NEXT_PUBLIC_API_URL=http://localhost:5000

# only for local postgres testing
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=go-nextjs

apps/backend/.env.production:

DATABASE_URL=postgresql://user:password@db:5432/go-nextjs
JWT_SECRET=your_secret
PORT=5000
# other go app vars

apps/web/.env.production:

INTERNAL_API_URL=http://api:5000

how image naming works

compose prefixes images with the name: field at the top of the compose file:

name: go-nextjs-prod  →  images become go-nextjs-prod-api, go-nextjs-prod-web
name: go-nextjs       →  images become go-nextjs-api, go-nextjs-web (dev setup)

suffix is the service name (api, web). so keeping different name: values in dev and prod compose avoids image name clashes locally.

env var loading — the tricky parts

two different mechanigo-nextjs that look similar but aren't:

${} in compose file — always read from root .env or shell, never from env_file:

args:
  NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} # reads root .env only

env_file — injects vars into the running container at runtime:

env_file:
  - ./apps/web/.env.production # available inside container, not to compose itself

NEXT_PUBLIC_* vars are baked at build time — env_file is too late for them. only the build arg matters:

build:
  args:
    NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} # this is what gets baked

multiple env_file entries — loaded in order, last one wins for duplicates:

env_file:
  - ./apps/backend/.env # loaded first
  - .env # loaded second, overrides duplicates

getApiUrl — handling internal vs public url

problem: NEXT_PUBLIC_API_URL needs to be http://localhost:5000 for browser requests but server side fetches need http://api:5000 (docker internal network). checking NODE_ENV breaks things since standalone runs as production.

solution — check if INTERNAL_API_URL is set instead:

export function getApiUrl() {
  return typeof window === "undefined" && process.env.INTERNAL_API_URL
    ? process.env.INTERNAL_API_URL
    : process.env.NEXT_PUBLIC_API_URL;
}
  • server side + INTERNAL_API_URL set → uses http://api:5000
  • server side + no INTERNAL_API_URL → falls back to NEXT_PUBLIC_API_URL (current ec2 prod without docker keeps working)
  • client side → always NEXT_PUBLIC_API_URL

mistakes made

AS builder missing in first stage

# wrong — docker tries to pull an image called "builder" from docker hub
FROM oven/bun:1-alpine

# correct
FROM oven/bun:1-alpine AS builder

wrong output directory

next.js static export outputs to out/ not dist/. and standard build outputs to .next/ not out/. used standalone in the end which outputs to .next/standalone/.

migrate binary path

assumed /root/go/bin/migrate but inside golang alpine it's /go/bin/migrate. find it with:

RUN which migrate

copying node_modules manually

first attempt at runtime stage:

# this pulls in all devdependencies — image was 1.5gb
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/web/node_modules ./apps/web/node_modules

fix was output: standalone in next.config — standalone traces only used imports and bundles them. dropped to 247mb.

NEXT_PUBLIC_API_URL baked as http://api:5000

root .env had NEXT_PUBLIC_API_URL=http://api:5000 — got baked at build time so browser was making requests to http://api:5000 which it can't resolve. must be http://localhost:5000 in root .env, only INTERNAL_API_URL should be http://api:5000.

useful commands

# start prod build detached
docker compose up -d --build

# stop and remove containers
docker compose down

# stop without removing
docker compose stop

# start stopped containers
docker compose start

# run only one service
docker compose up api --build

# check what's on a port
sudo lsof -i :5000

# kill process on port
sudo kill <PID>

# stop host postgres if it conflicts with docker
sudo systemctl stop postgresql

# check all containers with ports
docker ps -a --format "table {{.Names}}\t{{.Ports}}"

# view logs for a service
docker compose logs -f api

image sizes

| service                 | size  |
| ----------------------- | ----- |
| api                     | 56mb  |
| web                     | 247mb |
| web (before standalone) | 1.5gb |
| web (dev image)         | 1gb+  |

On this page