go nextjs multistage docker build

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:

copy
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

copy
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:

web dockerfile

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

in apps/web/next.config.ts:

copy
const nextConfig = {
  output: "standalone",
};
copy
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:

why only 3 copy lines:

compose file

copy
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:

copy
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:

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

apps/web/.env.production:

copy
INTERNAL_API_URL=http://api:5000

how image naming works

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

copy
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:

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

env_file — injects vars into the running container at runtime:

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

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

copy
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:

copy
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:

copy
export function getApiUrl() {
  return typeof window === "undefined" && process.env.INTERNAL_API_URL
    ? process.env.INTERNAL_API_URL
    : process.env.NEXT_PUBLIC_API_URL;
}

mistakes made

AS builder missing in first stage

copy
# 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:

copy
RUN which migrate

copying node_modules manually

first attempt at runtime stage:

copy
# 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

copy
# 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

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