Create a Nginx Reverse Proxy for a Node Server with Docker Compose
Create a Nginx reverse proxy for a Node server application with Docker and Docker Compose. We will also learn how to work with environment variables in Nginx, Docker, and Node.
Table of Contents 📖
- Why use Nginx with Node?
- Node Project Setup
- Nginx Docker Setup
- Create docker-compose.yaml
- Creating Environment Variable File
- Running the Program
Why use Nginx with Node?
Nginx can act as a reverse proxy by being placed between clients and Node application servers. This increases application performance by managing traffic and distributing it among Node servers. Nginx can also cache and serve up static content to clients without contacting the Node application servers, decreasing the load placed on them.
Node Project Setup
To begin, lets create a folder to hold our Node project and then initialize it with npm.
mkdir server
cd server
npm init es6 -y
mkdir src
touch src/index.js
For this demonstration, we will be using the Express library to handle requests. Install it from npm.
npm i express
Now lets set up a simple Express app inside our index.js file.
import express from "express";
const app = express();
app.get('/', (req, res) => {
const headers = req.headers;
res.status(200).send(headers);
});
app.listen(process.env.SERVER_PORT, () => {
console.log(`Express listening on port ${process.env.SERVER_PORT}`);
});
Here, the environment variable SERVER_PORT will be loaded from a .env file provided to Docker Compose. Also, when this express app receives a request to the root url, it will respond with the request headers. This is so we can see the headers that the Nginx reverse proxy will append. Now, lets create a Dockerfile to build our node image.
touch Dockerfile
Inside this Dockerfile we will use the node:16-alpine image as the base build.
FROM node:16-alpine
WORKDIR /server
COPY package.json .
RUN npm i
COPY . .
CMD ["npm", "start"]
The node:16-alpine image uses Node version 16 and is based on the Alpine Linux project which is smaller than most base images. Setting WORKDIR to /server will create a folder called server inside the image where all our code will be placed. We then copy over the package.json file and run npm install before copying over the rest of the code to enable some caching. We then run npm start when the built image is launched. Next, lets create the npm start script to get the program running.
"start": "node ./src/index.js"
This simple script runs our index.js file, starting our Express application.
Nginx Docker Setup
Lets now start building our Nginx reverse proxy. To start, lets create a directory to hold our nginx configuration and then place a template configuration file inside it.
mkdir nginx
cd nginx
touch default.conf.template
This template configuration file will hold our nginx proxy configuration. We use the .template extension because we want work with environment variables inside this file. By default, the Nginx docker image runs a script that uses envsubst to replace environment variables before Nginx starts. Now, lets fill in this configuration file.
server {
listen ${NGINX_PORT};
server_name ${NGINX_HOST};
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_pass http://${SERVER_HOST}:${SERVER_PORT};
proxy_http_version 1.1;
}
}
Here, NGINX_PORT is the port Nginx is running on, NGINX_HOST is the domain that Nginx is running on, SERVER_PORT is the port the Node server is running on, and SERVER_HOST is the address that the Node server is running on. These are all environment variables that will be replaced by envsubst. The proxy_set_header directive is used to add headers to the proxied request. Here is what each does:
- X-Forwarded-For - a request header that identifies the originating IP address of a client connecting to a web server through a proxy. $proxy_add_x_forwarded_for is a variable handled by Nginx that adds the client address.
- Host - specifies the host and port number of the server the request is being sent to
- Upgrade - used to upgrade an already establish connection to a different protocol such as HTTP to WebSocket. It is supplied from the client so if not provided it will not be added to the request
- Connection - determines whether the network connection stays open after the current transaction ends
The meat and potatoes of this configuration is the proxy_pass directive which tells Nginx where to forward the request. Specifically, the proxy_pass directive is used inside a location context to pass a request to a HTTP server. Here, we want Nginx to pass on the request to our Node server. Now lets create the Dockerfile that will make our Nginx image work with the configuration.
FROM nginx:1.18.0-alpine
WORKDIR /etc/nginx/templates
COPY default.conf.template /etc/nginx/templates/default.conf.template
Here, we create the working directory /etc/nginx/templates and then copy our template configuration file into it. This is because by default the Nginx docker container reads template files in /etc/nginx/templates/*.template and outputs the result to /etc/nginx/conf.d, the folder that contains default.conf which is ultimately loaded into the main Nginx configuration file nginx.conf.
Create docker-compose.yaml
Now lets work with Docker Compose to build our Nginx and Node images. First, create a docker-compose.yaml file at the top level of the project.
touch docker-compose.yaml
Lets build our Node image first.
version: "3.8"
services:
server:
image: server
build:
context: ./server
dockerfile: Dockerfile
container_name: ${SERVER_HOST}
env_file: .env
ports:
- ${SERVER_PORT}:${SERVER_PORT}
Here we call the service and image server. We then provide the location of our Dockerfile to the build context, name the container the environment variable SERVER_HOST, and map the machine port to the docker container port with the environment variable SERVER_PORT. These environment variables are provided from our environment variable file called .env. Now, lets set up our nginx service.
nginx:
image: reverse-proxy
restart: always
build:
context: ./nginx
dockerfile: Dockerfile
container_name: ${NGINX_HOST}
env_file: .env
ports:
- ${NGINX_PORT}:${NGINX_PORT}
Here we call the service nginx and the image reverse-proxy. We set restart to always which means that the container will restart automatically if it stops. We then provide the location of the Dockerfile to the build context, set the container name to the environment variable NGINX_HOST, and map the machine port to container port with the environment variable NGINX_PORT. These variables are also provided by the .env file.
Creating Environment Variable File
Now lets create the environment variable file. At the top level of the project, create a file called .env.
touch .env
Now lets define our environment variables.
SERVER_PORT=8888
SERVER_HOST=node-c
NGINX_PORT=9999
NGINX_HOST=nginx-c
Now, both Docker and Docker Compose will use this file to populate the environment variables with these values. This means that our Node server can be contacted at the domain node-c and the port 8888 while our Nginx proxy can be contacted at the domain nginx-c and the port 9999. This is because docker compose will allow our containers to communicate over a bridge network by their container name.
Running the Program
To run the program, simply type the command docker compose up at the top level of the project.
docker compose up
⠿ Network node-nginx-reverse-proxy_default Created 0.1s
⠿ Container nginx-c Created 0.1s
⠿ Container node-c Created 0.1s
Attaching to nginx-c, node-c
nginx-c | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
nginx-c | 20-envsubst-on-templates.sh: Running envsubst on /etc/nginx/templates/default.conf.template to /etc/nginx/conf.d/default.conf
nginx-c | /docker-entrypoint.sh: Configuration complete; ready for start up
node-c |
node-c | > node-nginx-reverse-proxy@1.0.0 start
node-c | > node ./src/index.js
node-c |
node-c | Express listening on port 8888
Looking at the output, we can see information about envsubst being ran, the port number our Express server is listening on, and the containers and network that docker compose created. Docker compose created a network named after the working directory with default appended. It is this network that our containers can communicate over using their container names. Now lets send a curl to localhost:9999 where Nginx is listening.
curl localhost:9999
{"x-forwarded-for":"192.168.0.1","host":"localhost","connection":"Upgrade","user-agent":"curl/7.81.0","accept":"*/*"}
What we get in response are the request headers along with the ones that Nginx appended such as X-Forwarded-For. This is because in our Express application we are responding with the request headers.
app.get('/', (req, res) => {
const headers = req.headers;
res.status(200).send(headers);
});
Therefore, we can see how Nginx is acting as a reverse proxy with our Express application: intercepting the request, forwarding it to Express, receiving a response from Express, and then returning the response to the user. We can also contact our Node server directly by using curl localhost:8888.
curl localhost:8888
{"host":"localhost:8888","user-agent":"curl/7.81.0","accept":"*/*"}
In this case, we are returned the request headers without the ones that Nginx appended as we didn't send the request through Nginx. Rather, we contacted our Node server directly.