DB-유

Redirecting HTTP to HTTPS in nginx

· linux, nginx, ssl

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 to http:// and redirects back. Check with curl -IL and look at which hop produces the second 301.
  • Certbot renewal fails with Invalid response from http://example.com/.well-known/acme-challenge/...: 301. The acme-challenge location is missing from the port-80 block or the root path does not match certbot's --webroot-path. Check with certbot renew --dry-run.
  • nginx: [emerg] duplicate listen options for [::]:443. Only one server block may carry ssl http2 parameters for a given address and port; the others should just say listen 443 ssl; or listen 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.