// Tutorial //

How To Secure a Containerized Node.js Application with Nginx, Let's Encrypt, and Docker Compose

Published on January 4, 2019 · Updated on August 16, 2022
Default avatar
By Kathleen Juell
Developer and author at DigitalOcean.
English
How To Secure a Containerized Node.js Application with Nginx, Let's Encrypt, and Docker Compose

Introduction

There are multiple ways to enhance the flexibility and security of your Node.js application. Using a reverse proxy like Nginx offers you the ability to load balance requests, cache static content, and implement Transport Layer Security (TLS). Enabling encrypted HTTPS on your server ensures that communication to and from your application remains secure.

Implementing a reverse proxy with TLS/SSL on containers involves a different set of procedures from working directly on a host operating system. For example, if you were obtaining certificates from Let’s Encrypt for an application running on a server, you would install the required software directly on your host. Containers allow you to take a different approach. Using Docker Compose, you can create containers for your application, your web server, and the Certbot client that will enable you to obtain your certificates. By following these steps, you can take advantage of the modularity and portability of a containerized workflow.

In this tutorial, you will deploy a Node.js application with an Nginx reverse proxy using Docker Compose. You will obtain TLS/SSL certificates for the domain associated with your application and ensure that it receives a high security rating from SSL Labs. Finally, you will set up a cron job to renew your certificates so that your domain remains secure.

Prerequisites

To follow this tutorial, you will need:

  • An Ubuntu 18.04 server, a non-root user with sudo privileges, and an active firewall. For guidance on how to set these up, please read this Initial Server Setup guide.

  • Docker and Docker Compose installed on your server. For guidance on installing Docker, follow Steps 1 and 2 of How To Install and Use Docker on Ubuntu 18.04. For guidance on installing Compose, follow Step 1 of How To Install Docker Compose on Ubuntu 18.04.

  • A registered domain name. This tutorial will use your_domain throughout. You can get one for free at Freenom, or use the domain registrar of your choice.

  • Both of the following DNS records set up for your server. You can follow this introduction to DigitalOcean DNS for details on how to add them to a DigitalOcean account, if that’s what you’re using:

    • An A record with your_domain pointing to your server’s public IP address.
    • An A record with www.your_domain pointing to your server’s public IP address.

Once you have everything set up, you’re ready to begin the first step.

Step 1 — Cloning and Testing the Node Application

As a first step, you’ll clone the repository with the Node application code, which includes the Dockerfile to build your application image with Compose. Then you’ll test the application by building and running it with the docker run command, without a reverse proxy or SSL.

In your non-root user’s home directory, clone the nodejs-image-demo repository from the DigitalOcean Community GitHub account. This repository includes the code from the setup described in How To Build a Node.js Application with Docker.

Clone the repository into a directory. This example uses node_project as the directory name. Feel free to name this directory to your liking:

  1. git clone https://github.com/do-community/nodejs-image-demo.git node_project

Change into the node_project directory:

  1. cd node_project

In this directory, there is a Dockerfile that contains instructions for building a Node application using the Docker node:10 image and the contents of your current project directory. You can preview the contents of the Dockerfile with the following:

  1. cat Dockerfile
Output
FROM node:10-alpine RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app WORKDIR /home/node/app COPY package*.json ./ USER node RUN npm install COPY --chown=node:node . . EXPOSE 8080 CMD [ "node", "app.js" ]

These instructions build a Node image by copying the project code from the current directory to the container and installing dependencies with npm install. They also take advantage of Docker’s caching and image layering by separating the copy of package.json and package-lock.json, containing the project’s listed dependencies, from the copy of the rest of the application code. Finally, the instructions specify that the container will be run as the non-root node user with the appropriate permissions set on the application code and node_modules directories.

For more information about this Dockerfile and Node image best practices, please explore the complete discussion in Step 3 of How To Build a Node.js Application with Docker.

To test the application without SSL, you can build and tag the image using docker build and the -t flag. This example names the image node-demo, but you are free to name it something else:

  1. docker build -t node-demo .

Once the build process is complete, you can list your images with docker images:

  1. docker images

The following output confirms the application image build:

Output
REPOSITORY TAG IMAGE ID CREATED SIZE node-demo latest 23961524051d 7 seconds ago 73MB node 10-alpine 8a752d5af4ce 3 weeks ago 70.7MB

Next, create the container with docker run. Three flags are included with this command:

  • -p: This publishes the port on the container and maps it to a port on your host. You will use port 80 on the host in this example, but feel free to modify this as necessary if you have another process running on that port. For more information about how this works, review this discussion in the Docker documentation on port binding.
  • -d: This runs the container in the background.
  • --name: This allows you to give the container a memorable name.

Run the following command to build the container:

  1. docker run --name node-demo -p 80:8080 -d node-demo

Inspect your running containers with docker ps:

  1. docker ps

The following output confirms that your application container is running:

Output
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 4133b72391da node-demo "node app.js" 17 seconds ago Up 16 seconds 0.0.0.0:80->8080/tcp node-demo

You can now visit your domain to test your setup: http://your_domain. Remember to replace your_domain with your own domain name. Your application will display the following landing page:

Application Landing Page

Now that you have tested the application, you can stop the container and remove the images. Use docker ps to get your CONTAINER ID:

  1. docker ps
Output
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 4133b72391da node-demo "node app.js" 17 seconds ago Up 16 seconds 0.0.0.0:80->8080/tcp node-demo

Stop the container with docker stop. Be sure to replace the CONTAINER ID listed here with your own application CONTAINER ID:

  1. docker stop 4133b72391da

You can now remove the stopped container and all of the images, including unused and dangling images, with docker system prune and the -a flag:

  1. docker system prune -a

Press y when prompted in the output to confirm that you would like to remove the stopped container and images. Be advised that this will also remove your build cache.

With your application image tested, you can move on to building the rest of your setup with Docker Compose.

Step 2 — Defining the Web Server Configuration

With our application Dockerfile in place, you’ll create a configuration file to run your Nginx container. You can start with a minimal configuration that will include your domain name, document root, proxy information, and a location block to direct Certbot’s requests to the .well-known directory, where it will place a temporary file to validate that the DNS for your domain resolves to your server.

First, create a directory in the current project directory, node_project, for the configuration file:

  1. mkdir nginx-conf

Create and open the file with nano or your favorite editor:

  1. nano nginx-conf/nginx.conf

Add the following server block to proxy user requests to your Node application container and to direct Certbot’s requests to the .well-known directory. Be sure to replace your_domain with your own domain name:

~/node_project/nginx-conf/nginx.conf
server {
        listen 80;
        listen [::]:80;

        root /var/www/html;
        index index.html index.htm index.nginx-debian.html;

        server_name your_domain www.your_domain;

        location / {
                proxy_pass http://nodejs:8080;
        }

        location ~ /.well-known/acme-challenge {
                allow all;
                root /var/www/html;
        }
}

This server block will allow you to start the Nginx container as a reverse proxy, which will pass requests to your Node application container. It will also allow you to use Certbot’s webroot plugin to obtain certificates for your domain. This plugin depends on the HTTP-01 validation method, which uses an HTTP request to prove that Certbot can access resources from a server that responds to a given domain name.

Once you have finished editing, save and close the file. If you used nano, you can do this by pressing CTRL + X, then Y, and ENTER. To learn more about Nginx server and location block algorithms, please refer to this article on Understanding Nginx Server and Location Block Selection Algorithms.

With the web server configuration details in place, you can move on to creating your docker-compose.yml file, which will allow you to create your application services and the Certbot container you will use to obtain your certificates.

Step 3 — Creating the Docker Compose File

The docker-compose.yml file will define your services, including the Node application and web server. It will specify details like named volumes, which will be critical to sharing SSL credentials between containers, as well as network and port information. It will also allow you to specify commands to run when your containers are created. This file is the central resource that will define how your services will work together.

Create and open the file in your current directory:

  1. nano docker-compose.yml

First, define the application service:

~/node_project/docker-compose.yml
version: '3'

services:
  nodejs:
    build:
      context: .
      dockerfile: Dockerfile
    image: nodejs
    container_name: nodejs
    restart: unless-stopped

The nodejs service definition includes the following:

  • build: This defines the configuration options, including the context and dockerfile, that will be applied when Compose builds the application image. If you wanted to use an existing image from a registry like Docker Hub, you could use the image instruction instead, with information about your username, repository, and image tag.
  • context: This defines the build context for the application image build. In this case, it’s the current project directory which is represented with the ..
  • dockerfile: This specifies the Dockerfile that Compose will use for the build — the Dockerfile reviewed at in Step 1.
  • image, container_name: These apply names to the image and container.
  • restart: This defines the restart policy. The default is no, but in this example, the container is set to restart unless it is stopped.

Note that you are not including bind mounts with this service, since your setup is focused on deployment rather than development. For more information, please read the Docker documentation on bind mounts and volumes.

To enable communication between the application and web server containers, add a bridge network called app-network after the restart definition:

~/node_project/docker-compose.yml
services:
  nodejs:
...
    networks:
      - app-network

A user-defined bridge network like this enables communication between containers on the same Docker daemon host. This streamlines traffic and communication within your application, since it opens all ports between containers on the same bridge network, while exposing no ports to the outside world. Thus, you can be selective about opening only the ports you need to expose your frontend services.

Next, define the webserver service:

~/node_project/docker-compose.yml
...
 webserver:
    image: nginx:mainline-alpine
    container_name: webserver
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - web-root:/var/www/html
      - ./nginx-conf:/etc/nginx/conf.d
      - certbot-etc:/etc/letsencrypt
      - certbot-var:/var/lib/letsencrypt
    depends_on:
      - nodejs
    networks:
      - app-network

Some of the settings defined here for the nodejs service remain the same, but some of the following changes were made:

The following named volumes and bind mounts are also specified:

  • web-root:/var/www/html: This will add your site’s static assets, copied to a volume called web-root, to the the /var/www/html directory on the container.
  • ./nginx-conf:/etc/nginx/conf.d: This will bind mount the Nginx configuration directory on the host to the relevant directory on the container, ensuring that any changes you make to files on the host will be reflected in the container.
  • certbot-etc:/etc/letsencrypt: This will mount the relevant Let’s Encrypt certificates and keys for your domain to the appropriate directory on the container.
  • certbot-var:/var/lib/letsencrypt: This mounts Let’s Encrypt’s default working directory to the appropriate directory on the container.

Next, add the configuration options for the certbot container. Be sure to replace the domain and email information with your own domain name and contact email:

~/node_project/docker-compose.yml
...
  certbot:
    image: certbot/certbot
    container_name: certbot
    volumes:
      - certbot-etc:/etc/letsencrypt
      - certbot-var:/var/lib/letsencrypt
      - web-root:/var/www/html
    depends_on:
      - webserver
    command: certonly --webroot --webroot-path=/var/www/html --email sammy@your_domain --agree-tos --no-eff-email --staging -d your_domain  -d www.your_domain 

This definition tells Compose to pull the certbot/certbot image from Docker Hub. It also uses named volumes to share resources with the Nginx container, including the domain certificates and key in certbot-etc, the Let’s Encrypt working directory in certbot-var, and the application code in web-root.

Again, you’ve used depends_on to specify that the certbot container should be started once the webserver service is running.

The command option specifies the command to run when the container is started. It includes the certonly subcommand with the following options:

  • --webroot: This tells Certbot to use the webroot plugin to place files in the webroot folder for authentication.
  • --webroot-path: This specifies the path of the webroot directory.
  • --email: Your preferred email for registration and recovery.
  • --agree-tos: This specifies that you agree to ACME’s Subscriber Agreement.
  • --no-eff-email: This tells Certbot that you do not wish to share your email with the Electronic Frontier Foundation (EFF). Feel free to omit this if you would prefer.
  • --staging: This tells Certbot that you would like to use Let’s Encrypt’s staging environment to obtain test certificates. Using this option allows you to test your configuration options and avoid possible domain request limits. For more information about these limits, please read Let’s Encrypt’s rate limits documentation.
  • -d: This allows you to specify domain names you would like to apply to your request. In this case, you’ve included your_domain and www.your_domain. Be sure to replace these with your own domains.

As a final step, add the volume and network definitions. Be sure to replace the username here with your own non-root user:

~/node_project/docker-compose.yml
...
volumes:
  certbot-etc:
  certbot-var:
  web-root:
    driver: local
    driver_opts:
      type: none
      device: /home/sammy/node_project/views/
      o: bind

networks:
  app-network:
    driver: bridge

Your named volumes include your Certbot certificate and working directory volumes, and the volume for your site’s static assets, web-root. In most cases, the default driver for Docker volumes is the local driver, which on Linux accepts options similar to the mount command. Thanks to this, you are able to specify a list of driver options with driver_opts that mount the views directory on the host, which contains your application’s static assets, to the volume at runtime. The directory contents can then be shared between containers. For more information about the contents of the views directory, please read Step 2 of How To Build a Node.js Application with Docker.

The following is the complete docker-compose.yml file:

~/node_project/docker-compose.yml
version: '3'

services:
  nodejs:
    build:
      context: .
      dockerfile: Dockerfile
    image: nodejs
    container_name: nodejs
    restart: unless-stopped
    networks:
      - app-network

  webserver:
    image: nginx:mainline-alpine
    container_name: webserver
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - web-root:/var/www/html
      - ./nginx-conf:/etc/nginx/conf.d
      - certbot-etc:/etc/letsencrypt
      - certbot-var:/var/lib/letsencrypt
    depends_on:
      - nodejs
    networks:
      - app-network

  certbot:
    image: certbot/certbot
    container_name: certbot
    volumes:
      - certbot-etc:/etc/letsencrypt
      - certbot-var:/var/lib/letsencrypt
      - web-root:/var/www/html
    depends_on:
      - webserver
    command: certonly --webroot --webroot-path=/var/www/html --email sammy@your_domain --agree-tos --no-eff-email --staging -d your_domain  -d www.your_domain 

volumes:
  certbot-etc:
  certbot-var:
  web-root:
    driver: local
    driver_opts:
      type: none
      device: /home/sammy/node_project/views/
      o: bind

networks:
  app-network:
    driver: bridge  

With the service definitions in place, you are ready to start the containers and test your certificate requests.

Step 4 — Obtaining SSL Certificates and Credentials

You can start the containers with docker-compose up. This will create and run your containers and services in the order you have specified. Once your domain requests succeed, your certificates will be mounted to the /etc/letsencrypt/live folder on the webserver container.

Create the services with docker-compose up with the -d flag, which will run the nodejs and webserver containers in the background:

  1. docker-compose up -d

Your output will confirm that your services have been created:

Output
Creating nodejs ... done Creating webserver ... done Creating certbot ... done

Use docker-compose ps to check the status of your services:

  1. docker-compose ps

If everything was successful, your nodejs and webserver services will be Up and the certbot container will have exited with a 0 status message:

Output
Name Command State Ports ------------------------------------------------------------------------ certbot certbot certonly --webroot ... Exit 0 nodejs node app.js Up 8080/tcp webserver nginx -g daemon off; Up 0.0.0.0:80->80/tcp

If you notice anything other than Up in the State column for the nodejs and webserver services, or an exit status other than 0 for the certbot container, be sure to check the service logs with the docker-compose logs command. For example, if you wanted to check the Certbot log, you would run:

  1. docker-compose logs certbot

You can now check that your credentials have been mounted to the webserver container with docker-compose exec:

  1. docker-compose exec webserver ls -la /etc/letsencrypt/live

Once your request succeeds, your output will reveal the following:

Output
total 16 drwx------ 3 root root 4096 Dec 23 16:48 . drwxr-xr-x 9 root root 4096 Dec 23 16:48 .. -rw-r--r-- 1 root root 740 Dec 23 16:48 README drwxr-xr-x 2 root root 4096 Dec 23 16:48 your_domain

Now that you know your request will be successful, you can edit the certbot service definition to remove the --staging flag.

Open the docker-compose.yml file:

  1. nano docker-compose.yml

Find the section of the file with the certbot service definition, and replace the --staging flag in the command option with the --force-renewal flag. This will tell Certbot that you want to request a new certificate with the same domains as an existing certificate. The certbot service definition should have the following definitions:

~/node_project/docker-compose.yml
...
  certbot:
    image: certbot/certbot
    container_name: certbot
    volumes:
      - certbot-etc:/etc/letsencrypt
      - certbot-var:/var/lib/letsencrypt
      - web-root:/var/www/html
    depends_on:
      - webserver
    command: certonly --webroot --webroot-path=/var/www/html --email sammy@your_domain --agree-tos --no-eff-email --force-renewal -d your_domain -d www.your_domain
...

When you’re done editing, save and exit the file. You can now run docker-compose up to recreate the certbot container and its relevant volumes. By including the --no-deps option, you’re telling Compose that it can skip starting the webserver service, since it is already running:

  1. docker-compose up --force-recreate --no-deps certbot

The following output indicates that your certificate request was successful:

Output
Recreating certbot ... done Attaching to certbot certbot | Account registered. certbot | Renewing an existing certificate for your_domain and www.your_domain certbot | certbot | Successfully received certificate. certbot | Certificate is saved at: /etc/letsencrypt/live/your_domain/fullchain.pem certbot | Key is saved at: /etc/letsencrypt/live/your_domain phd.com/privkey.pem certbot | This certificate expires on 2022-11-03. certbot | These files will be updated when the certificate renews. certbot | NEXT STEPS: certbot | - The certificate will need to be renewed before it expires. Cert bot can automatically renew the certificate in the background, but you may need to take steps to enable that functionality. See https://certbot.org/renewal-setu p for instructions. certbot | Saving debug log to /var/log/letsencrypt/letsencrypt.log certbot | certbot | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - certbot | If you like Certbot, please consider supporting our work by: certbot | * Donating to ISRG / Let's Encrypt: https://letsencrypt.org/do nate certbot | * Donating to EFF: https://eff.org/donate-le certbot | - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - certbot exited with code 0

With your certificates in place, you can move on to modifying your Nginx configuration to include SSL.

Step 5 — Modifying the Web Server Configuration and Service Definition

Enabling SSL in your Nginx configuration will involve adding an HTTP redirect to HTTPS and specifying your SSL certificate and key locations. It will also involve specifying the Diffie-Hellman group, which you will use for Perfect Forward Secrecy.

Since you are going to recreate the webserver service to include these additions, you can stop it now:

  1. docker-compose stop webserver

Next, create a directory in your current project directory for the Diffie-Hellman key:

  1. mkdir dhparam

Generate your key with the openssl command:

  1. sudo openssl dhparam -out /home/sammy/node_project/dhparam/dhparam-2048.pem 2048

It will take a few moments to generate the key.

To add the relevant Diffie-Hellman and SSL information to your Nginx configuration, first remove the Nginx configuration file you created earlier:

  1. rm nginx-conf/nginx.conf

Open another version of the file:

  1. nano nginx-conf/nginx.conf

Add the following code to the file to redirect HTTP to HTTPS and to add SSL credentials, protocols, and security headers. Remember to replace your_domain with your own domain:

~/node_project/nginx-conf/nginx.conf

server {
        listen 80;
        listen [::]:80;
        server_name your_domain www.your_domain;

        location ~ /.well-known/acme-challenge {
          allow all;
          root /var/www/html;
        }

        location / {
                rewrite ^ https://$host$request_uri? permanent;
        }
}

server {
        listen 443 ssl http2;
        listen [::]:443 ssl http2;
        server_name your_domain www.your_domain;

        server_tokens off;

        ssl_certificate /etc/letsencrypt/live/your_domain/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/your_domain/privkey.pem;

        ssl_buffer_size 8k;

        ssl_dhparam /etc/ssl/certs/dhparam-2048.pem;

        ssl_protocols TLSv1.2;
        ssl_prefer_server_ciphers on;

        ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5;

        ssl_ecdh_curve secp384r1;
        ssl_session_tickets off;

        ssl_stapling on;
        ssl_stapling_verify on;
        resolver 8.8.8.8;

        location / {
                try_files $uri @nodejs;
        }

        location @nodejs {
                proxy_pass http://nodejs:8080;
                add_header X-Frame-Options "SAMEORIGIN" always;
                add_header X-XSS-Protection "1; mode=block" always;
                add_header X-Content-Type-Options "nosniff" always;
                add_header Referrer-Policy "no-referrer-when-downgrade" always;
                add_header Content-Security-Policy "default-src * data: 'unsafe-eval' 'unsafe-inline'" always;
                #add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
                # enable strict transport security only if you understand the implications
        }

        root /var/www/html;
        index index.html index.htm index.nginx-debian.html;
}

The HTTP server block specifies the webroot for Certbot renewal requests to the .well-known/acme-challenge directory. It also includes a rewrite directive that directs HTTP requests to the root directory to HTTPS.

The HTTPS server block enables ssl and http2. To read more about how HTTP/2 iterates on HTTP protocols and the benefits it can have for website performance, please read the introduction to How To Set Up Nginx with HTTP/2 Support on Ubuntu 18.04. This block also includes a series of options to ensure that you are using the most up-to-date SSL protocols and ciphers and that OSCP stapling is turned on. OSCP stapling allows you to offer a time-stamped response from your certificate authority during the initial TLS handshake, which can speed up the authentication process.

The block also specifies your SSL and Diffie-Hellman credentials and key locations.

Finally, you’ve moved the proxy pass information to this block, including a location block with a try_files directive, pointing requests to your aliased Node.js application container, and a location block for that alias, which includes security headers that will enable you to get A ratings on things like the SSL Labs and Security Headers server test sites. These headers include X-Frame-Options, X-Content-Type-Options, Referrer Policy, Content-Security-Policy, and X-XSS-Protection. The HTTP Strict Transport Security (HSTS) header is commented out — enable this only if you understand the implications and have assessed its “preload” functionality.

Once you have finished editing, save and close the file.

Before recreating the webserver service, you need to add a few things to the service definition in your docker-compose.yml file, including relevant port information for HTTPS and a Diffie-Hellman volume definition.

Open the file:

  1. nano docker-compose.yml

In the webserver service definition, add the following port mapping and the dhparam named volume:

~/node_project/docker-compose.yml
...
 webserver:
    image: nginx:latest
    container_name: webserver
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - web-root:/var/www/html
      - ./nginx-conf:/etc/nginx/conf.d
      - certbot-etc:/etc/letsencrypt
      - certbot-var:/var/lib/letsencrypt
      - dhparam:/etc/ssl/certs
    depends_on:
      - nodejs
    networks:
      - app-network

Next, add the dhparam volume to your volumes definitions. Remember to replace the sammy and node_project directories to match yours:

~/node_project/docker-compose.yml
...
volumes:
  ...
  webroot:
  ...
  dhparam:
    driver: local
    driver_opts:
      type: none
      device: /home/sammy/node_project/dhparam/
      o: bind

Similarly to the web-root volume, the dhparam volume will mount the Diffie-Hellman key stored on the host to the webserver container.

Save and close the file when you are finished editing.

Recreate the webserver service:

  1. docker-compose up -d --force-recreate --no-deps webserver

Check your services with docker-compose ps:

  1. docker-compose ps

The following output indicates that your nodejs and webserver services are running:

Output
Name Command State Ports ---------------------------------------------------------------------------------------------- certbot certbot certonly --webroot ... Exit 0 nodejs node app.js Up 8080/tcp webserver nginx -g daemon off; Up 0.0.0.0:443->443/tcp, 0.0.0.0:80->80/tcp

Finally, you can visit your domain to ensure that everything is working as expected. Navigate your browser to https://your_domain, making sure to substitute your_domain with your own domain name:

Application Landing Page

A lock icon should appear in your browser’s security indicator. If you would like to, you can navigate to the SSL Labs Server Test landing page or the Security Headers server test landing page. The configuration options included should earn your site an A rating on the SSL Labs Server Test. In order to get an A rating on the Security Headers server test, you would have to uncomment the Strict Transport Security (HSTS) header in your nginx-conf/nginx.conf file:

~/node_project/nginx-conf/nginx.conf
location @nodejs {
                proxy_pass http://nodejs:8080;
                add_header X-Frame-Options "SAMEORIGIN" always;
                add_header X-XSS-Protection "1; mode=block" always;
                add_header X-Content-Type-Options "nosniff" always;
                add_header Referrer-Policy "no-referrer-when-downgrade" always;
                add_header Content-Security-Policy "default-src * data: 'unsafe-eval' 'unsafe-inline'" always;
                add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
                # enable strict transport security only if you understand the implications
        }

Again, enable this option only if you understand the implications and have assessed its “preload” functionality.

Step 6 — Renewing Certificates

Let’s Encrypt certificates are valid for 90 days. You can set up an automated renewal process to ensure that they do not lapse. One way to do this is to create a job with the cron scheduling utility. You can schedule a cron job using a script that will renew your certificates and reload your Nginx configuration.

Open a script called ssl_renew.sh in your project directory:

  1. nano ssl_renew.sh

Add the following code to the script to renew your certificates and reload your web server configuration:

~/node_project/ssl_renew.sh
#!/bin/bash

COMPOSE="/usr/local/bin/docker-compose --ansi never"
DOCKER="/usr/bin/docker"

cd /home/sammy/node_project/
$COMPOSE run certbot renew --dry-run && $COMPOSE kill -s SIGHUP webserver
$DOCKER system prune -af

This script first assigns the docker-compose binary to a variable called COMPOSE, and specifies the --no-ansi option, which will run docker-compose commands without ANSI control characters. It then does the same with the docker binary. Finally, it changes to the ~/node_project directory and runs the following docker-compose commands:

  • docker-compose run: This will start a certbot container and override the command provided in the certbot service definition. Instead of using the certonly subcommand use the renew subcommand, which will renew certificates that are close to expiring. Also included is the --dry-run option to test the script.
  • docker-compose kill: This will send a SIGHUP signal to the webserver container to reload the Nginx configuration.

It then runs docker system prune to remove all unused containers and images.

Close the file when you are finished editing, then make it executable:

  1. chmod +x ssl_renew.sh

Next, open your root crontab file to run the renewal script at a specified interval:

sudo crontab -e 

If this is your first time editing this file, you will be asked to choose an editor:

crontab
no crontab for root - using an empty one
Select an editor.  To change later, run 'select-editor'.
  1. /bin/ed
  2. /bin/nano        <---- easiest
  3. /usr/bin/vim.basic
  4. /usr/bin/vim.tiny
Choose 1-4 [2]: 
...

At the end of the file, add the following line:

crontab
...
*/5 * * * * /home/sammy/node_project/ssl_renew.sh >> /var/log/cron.log 2>&1

This will set the job interval to every five minutes, so you can test whether your renewal request has worked as intended. You have also created a log file, cron.log, to record relevant output from the job.

After five minutes, check cron.log to confirm whether the renewal request has succeeded:

  1. tail -f /var/log/cron.log

After a few moments, the following output signals a successful renewal:

Output
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ** DRY RUN: simulating 'certbot renew' close to cert expiry ** (The test certificates below have not been saved.) Congratulations, all renewals succeeded. The following certs have been renewed: /etc/letsencrypt/live/your_domain/fullchain.pem (success) ** DRY RUN: simulating 'certbot renew' close to cert expiry ** (The test certificates above have not been saved.) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Killing webserver ... done
Output
… Congratulations, all simulated renewals succeeded: /etc/letsencrypt/live/your_domain/fullchain.pem (success) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Saving debug log to /var/log/letsencrypt/letsencrypt.log Killing webserver ... Killing webserver ... done Deleted Containers: 00cad94050985261e5b377de43e314b30ad0a6a724189753a9a23ec76488fd78 Total reclaimed space: 824.5kB

Exit out by entering CTRL + C in your terminal.

You can now modify the crontab file to set a daily interval. To run the script every day at noon, for example, you could modify the last line of the file like the following:

crontab
...
0 12 * * * /home/sammy/node_project/ssl_renew.sh >> /var/log/cron.log 2>&1

You can also remove the --dry-run option from your ssl_renew.sh script:

~/node_project/ssl_renew.sh
#!/bin/bash

COMPOSE="/usr/local/bin/docker-compose --no-ansi"
DOCKER="/usr/bin/docker"

cd /home/sammy/node_project/
$COMPOSE run certbot renew && $COMPOSE kill -s SIGHUP webserver
$DOCKER system prune -af

Your cron job will ensure that your Let’s Encrypt certificates don’t lapse by renewing them when they are eligible. You can also set up log rotation with the Logrotate utility to rotate and compress your log files.

Conclusion

You have used containers to set up and run a Node application with an Nginx reverse proxy. You have also secured SSL certificates for your application’s domain and set up a cron job to renew these certificates when necessary.

If you are interested in learning more about Let’s Encrypt plugins, please review our articles on using the Nginx plugin or the standalone plugin.

You can also learn more about Docker Compose with the following resources:

The Compose documentation is also a great resource for learning more about multi-container applications.

If you’ve enjoyed this tutorial and our broader community, consider checking out our DigitalOcean products which can also help you achieve your development goals.

Learn more here


Tutorial Series: From Containers to Kubernetes with Node.js

In this series, you will build and containerize a Node.js application with a MongoDB database. The series is designed to introduce you to the fundamentals of migrating an application to Kubernetes, including modernizing your app using the 12FA methodology, containerizing it, and deploying it to Kubernetes. The series also includes information on deploying your app with Docker Compose using an Nginx reverse proxy and Let’s Encrypt.

About the authors
Default avatar
Developer and author at DigitalOcean.

Still looking for an answer?

Was this helpful?
10 Comments

This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

References to create xxx in current directory is ambiguous.

Saying “create xxx in the ~/node_project folder” would make for less head scratching of where you mean to locate the next resource.

Thanks for the tutorial, really helpful.

hi, thanks for the solid guide. My problem is ssl_renewal.sh script you suggested, namely ending line: docker system prune -af

It will delete certbot container / image completely from server, because certbot container stops immediately after renewal job/check. Also I noticed that some new duplicate certbot containers can appear (don’t know why?) with cron restarts, so some cleaning is needed. So, how to clean space but avoid of idle certbot image deleting?

Hi, can you please upload the completed project to github? I can’t seem to get mine to work once I try and enable https

Great tutorial! Had some issues that were mostly self-created, non-root user and references to folders that didn’t exist, but otherwise this tutorial allowed me to create an Express.js container that is now running a build for my React front-end and serving it with https. Fantastic work!

Very helpful tutorial. I am actually trying to add a subdomain in a different server block on nginx. But I get privacy error on chrome. Is there something else todo apart from adding -d subdomain.mywebsite.com? Got this working:

server {
        listen 80;
        listen [::]:80;
# sub domain added here
        server_name ambroise-rabier.fr www.ambroise-rabier.fr analytics.ambroise-rabier.fr;

        location ~ /.well-known/acme-challenge {
          allow all;
          root /var/www/html;
        }

        location / {
                rewrite ^ https://$host$request_uri? permanent;
        }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
# if you only add analytics.ambroise-rabier.fr you get the error
# by adding ambroise-rabier.fr nginx throw a warning but it work
    server_name ambroise-rabier.fr analytics.ambroise-rabier.fr;

    server_tokens off;

    ssl_certificate /etc/letsencrypt/live/ambroise-rabier.fr/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ambroise-rabier.fr/privkey.pem;

    ssl_buffer_size 8k;

    ssl_dhparam /etc/ssl/certs/dhparam-2048.pem;

    ssl_protocols TLSv1.2 TLSv1.1 TLSv1;
    ssl_prefer_server_ciphers on;

    ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5;

    ssl_ecdh_curve secp384r1;
    ssl_session_tickets off;

    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 8.8.8.8;

    root /var/www/html/matomo;

    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

}

Now I get:

2019/06/16 09:23:57 [warn] 67#67: conflicting server name "ambroise-rabier.fr" on 0.0.0.0:443, ignored
nginx: [warn] conflicting server name "ambroise-rabier.fr" on 0.0.0.0:443, ignored
2019/06/16 09:23:57 [warn] 67#67: conflicting server name "ambroise-rabier.fr" on [::]:443, ignored
nginx: [warn] conflicting server name "ambroise-rabier.fr" on [::]:443, ignored
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

How will this hack affect my web server? Is there a better way to do it?

Just a quick question, If I would like to make a change in app.js file because I am using nodejs as a server proxy RESTful APIs what can I do with volume to update everytime I change the data in app.js file?

Don’t be like me, don’t forget to set “–force-renewal” in docker-compose.yml (I lost way to much time than I care to admit).

By the way, if you have environmental variables in your docker-compose.yml the whole setup will not work as cron cannot read these vars properly.

Thanks for this wonderful tutorial. Learned a lot from it working on my Laradock setup at https://github.com/Larastudio/lsdock . This post helped me get moving with Certbot. Nginx tweaks and deployment structure are up next.

Hi i packed this tutorial in a git repo , you can find it here https://github.com/Kos-M/docker-nginx-letsencrypt Feel free to send any pull request if you see something odd :)