Redirecting HTTP to HTTPS in nginx
I once "finished" an HTTPS migration, then noticed three weeks later that half the traffic was still landing on plain http:// because of old links and bookmarks. And then certbot renewal broke because of my own redirect. So here's the setup I use now: a permanent redirect, www handled, and the Let's Encrypt challenge path left alone.
You need nginx 1.10 or newer (Ubuntu 16.04 has 1.10.3; CentOS 7 with EPEL or the nginx.org repo gives 1.12) and a certificate already installed. I use certbot with the webroot plugin, and the paths below match that layout.
Split the server block
Most people start with one server block that listens on both ports. Open the site's config, for example /etc/nginx/sites-available/example.com on Ubuntu or /etc/nginx/conf.d/example.com.conf on CentOS, and identify the listen 80 and listen 443 ssl directives. The goal is to split them into two blocks: one that only redirects, one that only serves.
The redirect block
Replace the port-80 part with a minimal block that returns a 301 for everything:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
location / {
return 301 https://$host$request_uri;
}
}
Two details matter here. return 301 is preferable to rewrite ^ https://... permanent; because it does not run the regex engine and is the form the nginx documentation recommends. And $host rather than a hard-coded name preserves whichever hostname the client used, which keeps the www and non-www variants working until you decide to canonicalize them (see below).
The acme-challenge location lets certbot renew over HTTP without following the redirect, which is the least fragile way to keep renewals working.
The HTTPS block only serves content
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2;
ssl_prefer_server_ciphers on;
add_header Strict-Transport-Security "max-age=31536000" always;
root /var/www/example.com/public_html;
index index.html index.php;
location / {
try_files $uri $uri/ =404;
}
}
http2 on the listen line requires nginx 1.9.5 or newer and OpenSSL 1.0.2 for ALPN; Ubuntu 16.04 has both. The Strict-Transport-Security header tells browsers to skip HTTP entirely on future visits. Start with a short max-age such as 300 while testing, then raise it. I've seen people set a year on day one and then get stuck when a subdomain wasn't ready for HTTPS.
Canonicalizing www (optional)
If you want a single canonical hostname, add a third block that catches the alternate name on 443 and redirects it, and drop the alternate from the main block's server_name:
server {
listen 443 ssl http2;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
return 301 https://example.com$request_uri;
}
The certificate must cover www.example.com for this to work without browser warnings; certbot's -d example.com -d www.example.com does that.
Test, reload, curl
sudo nginx -t
sudo systemctl reload nginx
Then:
curl -I http://example.com/some/page?x=1
Expected:
HTTP/1.1 301 Moved Permanently
Location: https://example.com/some/page?x=1
Then confirm the target answers with 200:
curl -I https://example.com/some/page?x=1
Also test http://www.example.com and check that the final location is the one you intended. curl -IL follows the whole chain and shows every hop, which is the quickest way to spot a loop.
Things that bit me
- Browser shows
ERR_TOO_MANY_REDIRECTS. Usually the HTTPS block also contains a redirect to itself, or an application behind nginx (WordPress, for instance) has its site URL set tohttp://and redirects back. Check withcurl -ILand look at which hop produces the second 301. - Certbot renewal fails with
Invalid response from http://example.com/.well-known/acme-challenge/...: 301. Theacme-challengelocation is missing from the port-80 block or therootpath does not match certbot's--webroot-path. Check withcertbot renew --dry-run. nginx: [emerg] duplicate listen options for [::]:443. Only one server block may carryssl http2parameters for a given address and port; the others should just saylisten 443 ssl;orlisten 443;.- Redirect works for the root but not for subdirectories. You used
return 301 https://$host;without$request_uri, which drops the path and query string.
One block that returns 301, one that serves, one exception for ACME. I've stopped looking things up for this.