I’m assuming you have the containers you want to reverse proxy to already set up and running correctly.
Initial Setup
Create a directory and file structure like the one shown below. The certbot
directory is shared between nginx and certbot. It’s where certbot creates the certificate files and where nginx reads them from. You will almost certainly never touch the files in here. The configuration for nginx is in the config
directory. The nginx.conf
file really only includes the other .conf
files. The html
directory and it’s child directories give certbot somewhere to place Let’s Encrypt challenge files. It’s only needed when a certificate is first acquired but it doesn’t hurt to keep it around. It’s mapped into the nginx server so it can serve up the challenge files when needed..
~/docker nginx certbot compose.yaml certificate_renewal.sh config nginx.conf conf.d example.conf example_ssl.conf html .well-known acme-challenge
Here is the initial nginx.conf
file. Notice that the example_ssl.conf
directive is commented out for now. The server won’t start if this enabled as it won’t be able to find the certificate.
user nginx; worker_processes auto; error_log /var/log/nginx/error.log notice; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; keepalive_timeout 65; # These two lines are repeated for each domain. To enable SSL you need to # be able to run the domain without SSL at the start. include /etc/nginx/conf.d/example.conf; #include /etc/nginx/conf.d/example_ssl.conf; }
The configuration file for HTTP only example.conf
server. This is set up ready to redirect all requests to HTTPS but there shouldn’t be anything listening at the moment. There is a location block block before the rewrite block in the configuration which catches any requests for certificate challenges. It’s important this is before any other location blocks.
server { listen 80; listen [::]:80; server_name example.com www.example.com; # It's important this goes first in the configuration file so that it matches before the proxy_pass. location ~ /.well-known/acme-challenge { allow all; root /var/www/html; } location / { rewrite ^ https://$host$request_uri? permanent; } }
A basic HTTPS example_ssl.conf
with reverse proxying set up. The certificates have to exist before this file is included in the main nginx.conf
file. The proxy_pass setting should point to the IP address of the container running the application you are reverse proxying.
server { listen 443 ssl; listen [::]:443 ssl; http2 on; server_name example.com www.example.com; index index.php index.html index.htm; root /var/www/html; server_tokens off; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; include /etc/nginx/conf.d/options-ssl-nginx.conf; 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 location / { proxy_pass http://10.10.0.13:80; # All of the following settings are required to make WordPress work proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Host $server_name; proxy_set_header X-Forwarded-Proto $scheme; proxy_redirect off; } location = /favicon.ico { log_not_found off; access_log off; } location = /robots.txt { log_not_found off; access_log off; allow all; } }
In the nginx directory you should have a compose.yaml
with the following settings. The volumes mapped into the container just give nginx access to the settings, certificate challenges, and SSL certificates. The certbot settings cause it to do nothing and exit with a code 1. This is fine, it just needs to be present and available to run.
services: nginx: container_name: nginx image: nginx:latest restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./config/nginx.conf:/etc/nginx/nginx.conf - ./config/conf.d:/etc/nginx/conf.d - ./html:/var/www/html - ./certbot:/etc/letsencrypt networks: web: ipv4_address: 10.10.0.2 certbot: depends_on: - nginx image: certbot/certbot:latest container_name: certbot volumes: - ./certbot:/etc/letsencrypt - ./html:/var/www/html networks: web: ipv4_address: 10.10.0.200 networks: web: external: true
Setting up a Domain with SSL
You can think of certbot running almost in two different modes, create and renew. When you first provide a certificate to a domain you need to run it in create mode and forever more after that you run in renew mode. You can have certbot create a single certificate for multiple domains e.g. one certificate that covers foo.com and bar.com. I don’t like the idea of that. If I get rid of bar.com I don’t want it affecting foo.com at all. For that reason I use the slightly longer winded approach of creating certificates for each domain separately.
Creating an Initial Certificate
Make sure that nginx is running and that the SSL configuration file is not included for the domain you are setting up. These commands can be completed by modifying the compose file with a command option but as it only needs doing once I’m going to suggest you manually run the creation command. At a command prompt from the nginx directory run the command shown below. This tells certbot to create a new certificate which will be placed in a subfolder in the certbot directory.
Note: all the commands below are run with the –test-cert flag which is used when testing a set up. The Let’s Encrypt testing system has generous rate limits which you need when setting up a system for the first time. The certificates it issues aren’t valid but switching to valid certificates is as trivially easy, simply run the command below again but without the –test-cert flag. Certbot will then recreate the certificates against the live certification system. You will likely need to SIGHUP the server at this point, see certificate renewal below.
sudo docker compose run certbot certonly --test-cert --webroot --webroot-path=/var/www/html --email [email protected] --agree-tos --no-eff-email --force-renewal -d example.com -d www.example.com
The output of the above command is shown below. If you get a warning about orphan containers you are almost certainly fine to run a prune. If you watch the log file of nginx you should see a number or requests for files in the .well-known/acme-challenge
directory.
$ sudo docker compose run certbot certonly --test-cert --webroot --webroot-path=/var/www/html --email [email protected] --agree-tos --no-eff-email --force-renewal -d example.com -d www.example.com [+] Creating 1/0 ✔ Container nginx Running 0.0s Saving debug log to /var/log/letsencrypt/letsencrypt.log Account registered. Requesting a certificate for example.com and www.example.com Successfully received certificate. Certificate is saved at: /etc/letsencrypt/live/example.com/fullchain.pem Key is saved at: /etc/letsencrypt/live/example.com/privkey.pem This certificate expires on 2025-01-25. These files will be updated when the certificate renews. NEXT STEPS: - The certificate will need to be renewed before it expires. Certbot can automatically renew the certificate in the background, but you may need to take steps to enable that functionality. See https://certbot.org/renewal-setup for instructions.
Turning on SSL
Stop the nginx server, open the nginx.conf file in nano and uncomment the include line for example_ssl.conf
. Save the file and restart nginx. You should now be able to get to your site using HTTPS. If you are using a test certificate it will likely give you a warning and if you are using HSTS you won’t be able to access the site at all but a warning is enough for now. Chrome seems more forgiving about invalid certificates.
Renewing the Certificate Manually
The command to renew the certificate is even simpler than creating a certificate and is shown below. It’s fine to run the renew manually, as shown, but it’s much better to run it automatically. After you renew the certificate it’s important you restart nginx as it caches the certificates in memory.
sudo docker compose run certbot renew
The output of the above command is shown below. Note that the renew command attempts to renew all the certificates on the system. As you can see from the output none of mine needed renewing. If any were renewed the nginx server should be restarted.
$ sudo docker compose run certbot renew [+] Creating 1/0 ✔ Container nginx Running 0.0s Saving debug log to /var/log/letsencrypt/letsencrypt.log - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Processing /etc/letsencrypt/renewal/example.com.conf - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Certificate not yet due for renewal - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - The following certificates are not due for renewal yet: /etc/letsencrypt/live/example.com/fullchain.pem expires on 2025-01-25 (skipped) No renewals were attempted. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
The least disruptive way to restart the nginx server is to send it a SIGHUP signal as shown here. This causes nginx to reread it’s configuration files.
sudo docker compose kill -s SIGHUP nginx
Renewing the Certificate Automatically
Renewing the certificate manually will get boring quickly so it’s time to automate the process. This is best done with a cron job that you can run once a day. In your nginx directory create a file called certificate_renewal.sh
and put the following in it. Don’t forget you’ll need to mark it as executable. All this does is run the commands detailed in the manual renew process above.
#!/bin/bash DOCKER="/usr/bin/docker" cd /home/username/docker/nginx/ $DOCKER compose run certbot renew && $DOCKER compose kill -s SIGHUP nginx $DOCKER system prune -af
If you test run it you’ll get output like that shown below. Notice I got a warning about orphans, the prune at the end of the script will fix that and it’ll bin any containers that aren’t being used. If it bins ones I need I don’t care they’ll download again in a flash.
$ sudo /home/username/docker/nginx/certificate_renewal.sh WARN[0000] Found orphan containers ([nginx-certbot-run-82e9b2c454b2 nginx-certbot-run-1d1bebd54e1e nginx-certbot-run-f91b59b053be nginx-certbot-run-4bcae709ca74 nginx-certbot-run-067902d01c35 nginx-certbot-run-d3b90b7bb4ea]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up. [+] Creating 1/0 ✔ Container nginx Running 0.0s Saving debug log to /var/log/letsencrypt/letsencrypt.log - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Processing /etc/letsencrypt/renewal/example.com.conf - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Certificate not yet due for renewal - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Processing /etc/letsencrypt/renewal/new_example.com.conf - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Certificate not yet due for renewal - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - The following certificates are not due for renewal yet: /etc/letsencrypt/live/example.com/fullchain.pem expires on 2025-01-25 (skipped) /etc/letsencrypt/live/new_example.com/fullchain.pem expires on 2025-01-25 (skipped) No renewals were attempted. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - [+] Killing 1/0 ✔ Container nginx Killed 0.0s Deleted Containers: e59cca0aa84d1cd7f4623aa3254efaf1f0199a7cb4855cad5a99eca5189a74b6 ... snip ... fc179bcb6ab3fb492af9f66c7aa8d02a38337b60c3bf33dd07adf62522b85ebe Deleted Images: untagged: httpd:latest untagged: httpd@sha256:bbea29057f25d9543e6a96a8e3cc7c7c937206d20eab2323f478fdb2469d536d ... snip ... deleted: sha256:ef5f5ddeb0a6492f959cfdcfc6b0a3518e0a120db92e53ccb8225ee481e7a4a1 Total reclaimed space: 315.5MB
Once you are happy the command is working correctly it’s time to add it to the root crontab:
$ sudo crontab -e
Select your editor if needed and add this line to the end of the file. This will cause the renewal script to run every day at 12 and log the output to /var/log/cron.log.
0 12 * * * /home/username/docker/nginx/certificate_renewal.sh >> /var/log/cron.log 2>&1
Setting Up More Domains
Just copy the above steps but replace example.com with your new domain name. This means that you should have the new_example.conf and new_example_ssl.conf in the nginx.conf file. For the certificates you just replace example.com with new_example.com in the creation command, the renew command will just magically work. For example, a manual renew would look like this:
$ sudo docker compose run certbot renew [+] Creating 1/0 ✔ Container nginx Running 0.0s Saving debug log to /var/log/letsencrypt/letsencrypt.log - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Processing /etc/letsencrypt/renewal/example.com.conf - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Certificate not yet due for renewal - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Processing /etc/letsencrypt/renewal/new_example.com.conf - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Certificate not yet due for renewal - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - The following certificates are not due for renewal yet: /etc/letsencrypt/live/example.com/fullchain.pem expires on 2025-01-25 (skipped) /etc/letsencrypt/live/new_example.com/fullchain.pem expires on 2025-01-25 (skipped) No renewals were attempted. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -