Best Next.js docker-compose hot-reload production-ready Docker setup
As I was putting together my Dockerfile & docker-compose for my startup’s frontend, I found a lack of quality solutions that were both production-ready and have hot reload when in dev. There were solutions that had hot-reload, but they were not production ready. There were also great production Dockerfiles, but they weren’t built with hot-reload in mind.
Create Your Dockerfile
Multi-staged builds are the best way to create an optimized and flexible Dockerfile. Simply put, it allows you to separate the steps required to configure the image. Create a file called Dockerfile
at the top level of your project and then follow along. A full version will be posted at the bottom, but I encourage you to follow the steps to understand what’s happening.
- Base Image & Dependencies
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
First, we will be using node:18-alpine
. Feel free to use a newer Alpine version, but I would avoid using the base node image because they can be bloated.
Next, there is a quick fix for a bug that can happen with Alpine. Vercel uses this in their official image for Next.
We then set the working directory (feel free to change this) to /app
and copy in the file(s) that describe which dependencies to install. This can be optionally simplified to only take one package manager’s config like so for yarn.
COPY yarn.lock* ./
Finally, we install our dependencies. This can also be simplified, but like before should work with all popular dependency managers by default.
2. Dev Stage
The dev stage is very simple. If you don’t want to enable hot-reload then don’t include this stage.
...
FROM base AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
We copy over the deps from our install stage & copy the rest of the source code into the image. If you have a sharp eye, then you’ll see that there could be an issue here. The source files could override the node modules that we just installed. To make sure that doesn’t happen we’ll need to update our .dockerignore
. If one doesn’t exist then create it at the top level of your project. Depending on your specific setup, you may want to add more to this file.
.next
node_modules
next-env.d.ts
3. Create The Builder
...
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# If using npm comment out above and use below instead
# RUN npm run build
We then create our second stage (the build stage). First, set the working directory like before and then copy over the node modules that we installed in the deps stage.
You can optionally opt out of the Next.js telemetry data. If you would like it turned on set NEXT_TELEMETRY_DISABLED
to 0 (represents false).
Then we do what this stage is meant for, which is building. You can switch this out to your tool of choice. I’m using yarn, but if you would like to use another tool instead just switch out the run line with yours.
4. The Runner
...
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
CMD ["node", "server.js"]
We follow the same config steps as before to get all of the necessary files. Then we are going to create a group & user to control permissions. If you would like to learn more about these Ubuntu commands check this.
Now we copy over our public, build, and static files. And set the user to the one we just created. Last, we initialize the production server, which is effectively equivalent to yarn start
.
The runner won’t work at first because the files we copied from standalone won’t exist, so you will need to alter your next.config.js
.
/** @type {import('next').NextConfig} */
const nextConfig = {
/// ...
output: 'standalone',
};
module.exports = nextConfig;
This puts your build files into .next/standalone
.
5. Completed Dockerfile
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
FROM base AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# If using npm comment out above and use below instead
# RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
CMD ["node", "server.js"]
Setting Up Docker-Compose & Hot-Reload
Create a docker-compose.yml
and docker-compose.prod.yml
file.
- Set up the default/dev docker-compose.
version: "3.8"
services:
frontend:
container_name: frontend
build:
context: . #if your Dockerfile is not at the same level change the path here (./frontend)
target: dev
restart: always
command: yarn dev
environment:
- NODE_ENV=development
#if you’re using Windows, you may need to uncomment the next line - Sol from @Kobe E
#- WATCHPACK_POLLING=true
volumes:
- .:/app
- /app/node_modules
- /app/.next
ports:
- 3000:3000
It’s that simple. Just run docker compose up -d --build
and your container will rebuild & run in the background. In this file, we can add our other services such as databases and other backends. (you may need to run docker-compose
instead of docker compose
depending on your setup).
The key value that allows us to use hot-reload is the target, which is set to dev. This is telling Docker to run the dev stage of our container rather than the default runner. If you left it only with the target, your container would fail because our command (CMD) was meant for the runner stage. To inject a command at the dev stage, we set the command to yarn dev
. This will now run as though you run it locally in dev.
The build will now be checking for file changes inside the container, but those internal files won’t change without mounting volumes. We map our project-level directory to the app (working directory) and make sure to not include the dependencies as those should come from within the container. Lastly, we expose our ports.
2. Create the prod docker compose
The prod docker-compose is even easier.
version: "3.8"
services:
frontend:
container_name: frontend
build: .
restart: always #change if you don't want your container restarting
environment:
- NODE_ENV=production
ports:
- 3000:3000
It works very similar to the dev compose except it uses the runner stage & doesn’t have any mounted files. To run it just do docker compose -f "docker-compose.prod.yml" up -d --build
.
Good Luck!
Best,
Eli Front — elifront.com