Published on

Building Nextjs For Different Production Environments

Authors

Nextjs supports .env files to hold variables that differ per environment but one caveat I ran into is that the approach used is somewhat limited. The main limitation I ran into was that Next.js only supports 3 possible env files

  1. .env: for common configuration
  2. .env.*.local: for developer-specific configuration
  3. .env.developent: it seems to mainly be targeted at defining variables for unit tests
  4. .env.production: variables for non-dev environments

My issue is that I have a test and production environment which have different values for variables. Things like the base URL to use for an API can and will change.

Doing a bit of research it seemed that this is an intentional design decision for the Next framework and that alternate solutions are needed to work around this.

I did a bit more research and found this answer which suggests an approach. Off of this, I ended up with the following solution:

  • I defined a new type of dot file called .build.env.
  • I left a .env file in the project - this allows all other Next.js tooling to work correctly
  • I installed the dev dependency called env-cmd yarn add -D env-cmd
  • I added the following script to my package.json file:
"build-ci": "env-cmd -f .build.env.${BUILD_ENV} yarn build"

Whenever I want to build for an environment I set an environment variable and run the command as follows:

BUILD_ENV=UAT yarn build-ci

In this case, I have a file called .build.env.UAT in the Root of the project. This can easily be adapted to any environment you want.

The .build.env.<environment/> file has the same structure as a normal .env file. For example:

NEXT_PUBLIC_BASE_URL=http://localhost:8080/my/awesome/api
#...

This can easily be adapted to work with Docker by adapting the official docker file:

# Taken from the official site: https://nextjs.org/docs/deployment
# Also available on the official repo: https://github.com/vercel/next.js/tree/canary/examples/with-docker
# Install dependencies only when needed
FROM node:alpine 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
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# Rebuild the source code only when needed
FROM node:alpine AS builder
WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules

# Allow the environment to be specified at build time
# We default to production
# Arg is a way of providing a build time environment variable in Docker
ARG ENVIRONMENT=production
RUN echo "Build - ARG environment is ${ENVIRONMENT}"
ENV BUILD_ENV $ENVIRONMENT

RUN yarn build-ci && yarn install --production --ignore-scripts --prefer-offline

# Production image, copy all the files and run next
FROM node:alpine AS runner
WORKDIR /app
# We always use production for the NODE_ENV as Next.js uses this to build a productionised build - i.e. a non-dev machine build. This is required otherwise Nextjs will not build properly
ENV NODE_ENV production

ARG ENVIRONMENT=production
RUN echo "Run - ARG environment is ${ENVIRONMENT}"
ENV BUILD_ENV $ENVIRONMENT
ARG BUILD_PORT=80
ENV PORT $BUILD_PORT
RUN echo "Port ${PORT}"

RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001

# You only need to copy next.config.ts if you are NOT using the default configuration
# COPY --from=builder /app/next.config.ts ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json

USER nextjs

EXPOSE 80
EXPOSE 3000

# 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.
ENV NEXT_TELEMETRY_DISABLED 1

CMD ["yarn", "start"]

I then added a makefile target to bring it all together:

CURRENT_DATE=$(shell date +%Y-%h-%d_%H-%M)
CURRENT_DIR=$(shell pwd)
CURRENT_GIT_HASH=$(shell git rev-parse --short HEAD)
CONTAINER_REPO_URI="your.container.repo.uri"
DOCKER_IMAGE_NAME="yourApp"

help:
   @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

# ...
docker-build-uat: ## builds the uat docker container for this app
   docker build --build-arg ENVIRONMENT=uat --build-arg BUILD_PORT=3000 --build-arg BUILD_ENV="uat" -f ./Dockerfile -t $(CONTAINER_REPO_URI)/$(DOCKER_IMAGE_NAME):$(CURRENT_GIT_HASH)-uat

docker-build-prod: ## builds production docker container for this app
   docker build --build-arg ENVIRONMENT=production --build-arg BUILD_PORT=3000 --build-arg BUILD_ENV="production" -f ./Dockerfile -t $(CONTAINER_REPO_URI)/$(DOCKER_IMAGE_NAME):$(CURRENT_GIT_HASH)-prod
  • I provided 2 examples here to show how this differs between environment builds.
  • I include the environment in the docker tag as Nextjs bundles the environment variables into the build so they are not identical across environments purely because of that. This suffix helps make sure you have the correct image
  • The --build-arg is responsible for passing all the relevant build-time variables through to the Docker context

References