Deploying Next.js applications using the Nginx server keeping the Next Server intact(1/2).

Chenna Sreenu
10 min readAug 26, 2023

--

Part (1/2) explains the development setup using docker-compose.yml file for testing and debugging your application locally.

Part (2/2) explains the production setup using deployment.yml file for kubernettes clustor to test and debug your application in production.

Next.js applications can be easily deployed to Vercel(Cloud Hosting Provider) and in fact, Vercel was created by the team behind Next.js, so Vercel is specifically designed to work seamlessly with Next.js projects.

But, you don’t have enough budget to deploy your Next.js application to Vercel. So, you just rent virtual machines from one of the Cloud Providers(AWS, Azure, GCP, etc.), and also you want to(you should) protect your web server(serving Nextjs application) using a reverse proxy server like Nginx.

Here if you observe, you may require two servers(thus two Dockerfiles for each server).

  1. One server for serving your Next.js application started using the npm start command. I will give a nickname for it Next server.
  2. Another server is obviously the Nginx server which sits in between clients' requests and the Next server.

Q) Why do Next.js applications require two servers?

Completely client-side applications(say ReactJs) don’t require a separate server(thus NO separate Dockerfile is needed) for serving the application but only require an Nginx server(only nginx Dockerfile is enough). We just have to copy the generated React build folder to /usr/share/nginx/html of the Nginx Dockerfile.

# Use an official Nginx image as the base image
FROM nginx:latest

# Remove the default Nginx configuration
RUN rm -rf /etc/nginx/conf.d/*

# Copy the build files from the React application to the Nginx root directory
COPY build /usr/share/nginx/html

# Copy your custom Nginx configuration (if needed)
COPY nginx.conf /etc/nginx/conf.d/

# Expose port 80 to the outside world
EXPOSE 80

# Start Nginx when the container starts
CMD ["nginx", "-g", "daemon off;"]

React application(completely client side) build folder contains static HTML files, and corresponding javascript files to (i) handle routes parsing, (ii) populate the HTML page(by hitting APIs), (iii) add event listeners to the HTML page, etc., and the corresponding CSS files to paint the HTML page. That’s it, no separate server is needed to run this mechanism.

But in the case of Nextjs applications, a separate server(as we named it, Next Server) is a must for serving the application. This Next server does a lot of jobs on the fly. A few of them are:

  • It incrementally generates (if you use getStaticPaths & getStaticProps together) pages on the fly and stores them in the build folder, thus for the same subsequent requests, files are served instantly from the already stored build folder, and no regeneration of the page happens again.
  • It optimizes images on the fly based on different screen sizes and stores them in the build folder. For smaller screens, it sends lower-sized images, and for larger screens, it sends high-sized images.

This on the fly feature is what makes the Nextjs framework super powerfull, we should not miss it out.

Clearly, we need two servers, thus two Dockerfiles, one for the Next server and another for the Nginx server. Since it is a multi-container setup, we need docker-compose.yml

Case 1: Proxy_passing every request from the Nginx Server to the Next Server.

I suggest referring simple-nginx branch of my repo for replicating Case 1: https://github.com/csrinu236/medium-nextjs-nginx-setup.git

git clone https://github.com/csrinu236/medium-nextjs-nginx-setup.git

docker-compose up --build

nginx-docker / mynginx.conf

location / {
proxy_pass http://${NEXTJS_CONTAINER_IP}:3000;
add_header X-Custom-HeaderHome "Value for Custom Header Home";
}

In the simple-nginx branch of the above repo, when requests are hitting the Nginx server, we are proxy passing all the requests( “/” represents all the requests) to the Next Server (http://${NEXTJS_CONTAINER_IP}:3000). Here, as we discussed, the Next Server keeps on generating files on the fly, adds them to its build folder and serves files from its build folder to the incoming proxy_passed requests from Nginx server. You can cross-check with the X-Custom-HeaderHome value in the response headers added at the Nginx Server level.

So here Nginx server is just behaving as a Reverse proxy server. You can further configure mynginx.conf file however you want, like to stop the malicious traffic at the Nginx server level itself, thus protecting the Next server from the malicious traffic.

docker-compose.yml

version: '3.8'
services:
nextjs-app:
image: csrinu236/medium-nextjs-app # Placeholder for pushing image to Dockerhub
build:
context: .
dockerfile: Dockerfile
nginx:
image: csrinu236/medium-nginx-app # Placeholder for pushing image to Dockerhub
build:
context: ./nginx-docker
dockerfile: Dockerfile
depends_on:
- nextjs-app
ports:
- '8080:8080'
environment:
- NEXTJS_CONTAINER_IP=nextjs-app # supplying environment vars for convert-nginx.sh file.
command: [ 'sh', '/etc/nginx/convert-nginx.sh']
# this is for parsing the convert-nginx.sh file into nginx.conf file after supplying environment vars,
# to get the resultant nginx.conf file in different environments.
# We also supply kubernetes specific env vars in deployment.yml file

Next Server Dockerfile

FROM node:20.4.0 AS builder

WORKDIR /temp

RUN npm config set strict-ssl false
RUN npm config set registry http://registry.npmjs.org/

COPY . .

RUN npm install

RUN npm run build

#########=========>

FROM node:20.4.0 AS server

WORKDIR /app

# We only require these 5 folders/files for nextjs apps in production
COPY --from=builder /temp/next.config.js ./
COPY --from=builder /temp/public ./public
COPY --from=builder /temp/build ./build
COPY --from=builder /temp/node_modules ./node_modules
COPY --from=builder /temp/package.json ./package.json


CMD [ "npm", "run", "start" ]

Nginx Server Dockerfile

# FROM nginx:alpine  
# FROM nginx:1-alpine
FROM nginx:1.15-alpine
# alpine is light weight, doesnot have any executable shells like bash so
# FROM nginx:latest is recommended.
# But 1.15-alpine has executable shell.

# Remove any existing config files
RUN rm /etc/nginx/conf.d/*

# Copy config files
# *.conf files in "conf.d/" dir get included in main config
COPY ./my-nginx.conf /etc/nginx/conf.d/
# COPY ./default.conf /etc/nginx/conf.d/
COPY ./nginx.conf /etc/nginx/
COPY ./mime.types /etc/nginx/
COPY ./convert-nginx.sh /etc/nginx/

# giving execution permissions to convert-nginx.sh shell file.
RUN chmod +x /etc/nginx/convert-nginx.sh

# Expose the listening port
EXPOSE 8080

# Commented this because, we want COMMAND full control in docker-compose and deployment.yml files only.
# CMD [ "/bin/sh","-c","/etc/nginx/convert-nginx.sh"]

Case 2: Establishing two-way binding between the Nginx Server file system and the Next Server file system.

Proxy passing every request to the Next Server from the Nginx Server is a good solution. But, we can create a better solution.

Can we maintain the same build folder in both servers’ file systems?

Can we establish a two-way binding between the build folders of both servers’ file systems?

Can these on-the-fly generated files added by the Next Server to its own build folder be automatically reflected in the build folder of the Nginx Server file system?

Thus, only the initial request goes to the Next Server and further subsequent requests are directly served from the Nginx Server file system, thus reducing server traffic on the Next Server.

In development(in docker-compose.yml), we can establish such a two-way binding using named volumes shared between two or more container file systems. In production(in deployment.yml), we can also do the same (refer to part 2/2 of this article).

For such a shared volumes setup, docker-compose.yml looks like this

docker-compose.yml

version: '3.8'
services:
nextjs-app:
image: csrinu236/medium-nextjs-app # Placeholder for pushing image to Dockerhub
build:
context: .
dockerfile: Dockerfile
volumes:
- nextjs-app-build:/app # Named volume for nextjs-app build folder
nginx:
image: csrinu236/medium-nginx-app # Placeholder for pushing image to Dockerhub
build:
context: ./nginx-docker
dockerfile: Dockerfile
volumes:
- nextjs-app-build:/app # Mount the named volume to Nginx's /app/build
depends_on:
- nextjs-app
ports:
- '8080:8080'
environment:
- NEXTJS_CONTAINER_IP=nextjs-app # supplying environment vars for convert-nginx.sh file.
command: ['sh', '/etc/nginx/convert-nginx.sh']
# this is for parsing the convert-nginx.sh file into nginx.conf file after supplying environment vars,
# to get the resultant nginx.conf file in different environments.
# We also supply kubernetes specific env vars in deployment.yml file
volumes:
nextjs-app-build: # Define the named volume

Here if you observe, we are binding the entire /app folder but not the /app/build folder(as we expected). The reason is the build folder files internally import their corresponding required files from node_modules and adopt configurations from next.confg.js. Instead of individually binding all the files and folders, I am directly binding the entire /app of the Next Server file system with the Nginx Server file system, and the Nginx Server will serve files from the /app/build folder.

Next Server File System

Next Server File System

Nginx Server File System

Here is mynginx.conf file

server {

listen 8080;
listen [::]:8080;
server_name _;

# setting root target for static files
root /app/public;

proxy_cache off;
proxy_set_header Host $http_host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;


# You will observe hits to _next/data and _next/image going thorugh @nextserver instead of /app/build,
# you will not find these in nginx server file system nor even in next server file system, instead next server
# smartly manages these things (faster transitions and image optimisation) in
# it's server temporary memory(say like RAM). Hit will always go to next server only.
location ^~ /_next {
alias /app/build;
try_files $uri @nextserver;
expires 365d;
add_header Cache-Control 'public';
add_header X-Custom-Header_next "Value for Custom Header _NEXT";
}

# Only because of /index.html here, we have to write seperate block for products
location / {
root /app/build/server/pages/;
try_files $uri $uri.html /index.html @nextserver;
# proxy_pass http://${NEXTJS_CONTAINER_IP}:3000;
add_header X-Custom-HeaderHome "Value for Custom Header Home";
}

location @nextserver {
proxy_pass http://${NEXTJS_CONTAINER_IP}:3000;
add_header X-Custom-HeaderNextServer "Value for Custom Header @nextserver";
}

# location ~ /products {
# proxy_pass http://${NEXTJS_CONTAINER_IP}:3000;
# add_header X-Custom-HeaderProducts "Value for Custom Header Products";
# }

location ~ /products {
root /app/build/server/pages/;
try_files $uri $uri.html @nextserver;
add_header X-Custom-HeaderProducts "Value for Custom Header Products";
}

location ~* \.(ogg|ogv|svg|svgz|eot|otf|woff|woff2|mp4|mp3|ttf|css|rss|atom|js|jpg|jpeg|gif|png|ico|zip|tgz|gz|rar|bz2|doc|xls|exe|ppt|tar|mid|midi|wav|bmp|rtf)(\?ver=[0-9.]+)?$ {
# alias /app/build
access_log off;
log_not_found off;
expires 365d;
add_header Cache-Control "public";
add_header X-Custom-HeaderCatchAll "Value for Custom Header CatchAll";
}

location /test {
return 200 "ROUTE HIT REGISTERED";
}

location ~ /testhtml {
alias /app;
try_files $uri /index.html =404;
}

error_page 404 /404.html;
location = /40x.html {
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}

You can cross-check everything, including which block is serving the responses to incoming requests by verifying response header values.

To cross-check all the use cases below, you clone this repo(main branch) and run docker commands.

git clone https://github.com/csrinu236/medium-nextjs-nginx-setup.git

docker-compose up --build
  1. If you navigate to the single product page by clicking the single product (client-side navigation, I assume you knew it), the initial HTML doc response comes from @nextserver. But the problem is, that you can’t even see the initial HTML doc because the client-side navigation will not generate the doc. To replicate this scenario, you have to manually copy the productId of the single product from the JSON response of the all-products page, paste it, and ENTER. The website will reload and the doc gets generated. http://localhost:8080/products/<PRODUCT_ID_HERE>

See, the response is coming from the Next Server File System for the initial hit.

X-Custom-Headernextserver: Value for Custom Header @nextserver

If you reload again, you see the same doc response coming from the products block means the Nginx Server File System served the doc.

X-Custom-Headerproducts: Value for Custom Header Products

Two Way Binding Worked, Done…

2) Hits to _next/static is mostly served from Nginx Server files system’s /app/build only unless it is a route-specific corresponding CSS or JS file.

X-Custom-Header_next “Value for Custom Header _NEXT”;

3) Hits to the index, about page, and products page(I mean the HTML Doc when you reload the website, any way client-side navigation will not generate the doc) responses are served from the Nginx Server file system only.

X-Custom-HeaderHome “Value for Custom Header Home”;

4) You will observe hits to _next/data and _next/image proxy_pass to @nextserver(means the Next Server) instead of /app/build(instead of Nginx Server). You neither find these in the Nginx server file system nor in the Next server file system. Instead, the Next server smartly stores and manages these things (for faster transitions and image optimizations) in its running server memory.

X-Custom-HeaderNextServer “Value for Custom Header @nextserver”;

Done, two way binding between Next Server File System and Nginx Server File System is established in Development.

--

--

Chenna Sreenu

Hyderabad, India | Jio Platforms Limited | IIT Kharagapur