setting up production docker with multistage builds for the go/next.js turborepo, mistakes included
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.
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
.envFROM 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 prodmigrate up is idempotent — only pending ones run./cmd/server/main.go — adjust to wherever your main isnext.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:
server.js that is meant to be run directlybun run start runs next start which expects the full next.js installnode server.js is enoughwhy only 3 copy lines:
.next/standalone already contains a self-contained node_modules with only what next.js needs at runtime — traced at build timename: 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: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-nextjsapps/backend/.env.production:
DATABASE_URL=postgresql://user:password@db:5432/go-nextjs
JWT_SECRET=your_secret
PORT=5000
# other go app varsapps/web/.env.production:
INTERNAL_API_URL=http://api:5000compose 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.
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 onlyenv_file — injects vars into the running container at runtime:
env_file:
- ./apps/web/.env.production # available inside container, not to compose itselfNEXT_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 bakedmultiple env_file entries — loaded in order, last one wins for duplicates:
env_file:
- ./apps/backend/.env # loaded first
- .env # loaded second, overrides duplicatesproblem: 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;
}INTERNAL_API_URL set → uses http://api:5000INTERNAL_API_URL → falls back to NEXT_PUBLIC_API_URL (current ec2 prod without docker keeps working)NEXT_PUBLIC_API_URLAS 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 builderwrong 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 migratecopying 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_modulesfix 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.
# 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| service | size |
| ----------------------- | ----- |
| api | 56mb |
| web | 247mb |
| web (before standalone) | 1.5gb |
| web (dev image) | 1gb+ |