How to Force HTTPS Redirect From Port 80 to 443 on Nginx
Learn how to configure Nginx to force a permanent redirect from port 80 to 443, ensuring HTTPS on every request with 301 and HSTS.
When a web server accepts connections on both HTTP (port 80) and HTTPS (port 443) simultaneously, unencrypted traffic remains viable — any client that types the domain without a prefix, clicks an old link, or follows an http:// bookmark will hit port 80 and send credentials in plain text. The fix is to force a permanent 301 redirect from port 80 to port 443 in Nginx, ensuring every request is terminated over TLS.
This tutorial shows the correct HTTP → HTTPS redirect configuration on Nginx, including how to handle Let’s Encrypt’s http-01 challenge (which needs plain HTTP to validate), progressive HSTS activation, and config validation before reload. Applies to Nginx 1.18+ on any Linux distribution (Ubuntu, Debian, Rocky, Alma).
Execution time: about 10 minutes, including tests. Target persona: developer or sysadmin with Nginx already configured serving the site on both ports, who needs to consolidate everything on HTTPS without breaking certificate renewal.
Prerequisites
You need Nginx 1.18+ running on a Linux VPS, with a valid TLS certificate already installed (Let’s Encrypt, ZeroSSL, or commercial), sudo access via SSH, and the domain currently responding on ports 80 and 443. Confirm with nginx -v and curl -I https://yourdomain.com.
80 (HTTP) 443 (HTTPS) 301 Permanent /etc/nginx/sites-available/ Before touching Nginx, back up the current configuration. A syntax error in a server block leaves the service unable to start until you fix it, and editing production without a safety net is an invitation to downtime.
sudo cp -r /etc/nginx /etc/nginx.bak.$(date +%Y%m%d)
The command duplicates the entire directory with a timestamp — if anything goes wrong, just revert with sudo rm -rf /etc/nginx && sudo mv /etc/nginx.bak.YYYYMMDD /etc/nginx.
Recommended structure: two server blocks
The correct approach separates the HTTP redirect into its own server block, leaving HTTPS in a second block. This avoids conditional logic (if ($scheme = http)), which the Nginx author explicitly discourages — if inside location in Nginx has counter-intuitive behavior and has historically been a source of bugs.
Open the site’s configuration file in sites-available. On Debian/Ubuntu-based distributions, the default is /etc/nginx/sites-available/yourdomain.com. On Rocky/Alma, it may be in /etc/nginx/conf.d/yourdomain.conf:
sudo nano /etc/nginx/sites-available/yourdomain.comIf you don’t yet have a separate file per site and everything lives in nginx.conf, this is a good opportunity to extract the server blocks into individual files — it makes maintenance and auditing easier.
Replace the contents with the template below. Two server blocks: the first listens on 80 and redirects everything (except the ACME challenge) to HTTPS; the second terminates TLS and serves the site:
# HTTP server block — 301 redirect to HTTPS
server {
listen 80;
listen [::]:80;
server_name yourdomain.com www.yourdomain.com;
# Keeps the Let's Encrypt challenge working
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
default_type "text/plain";
allow all;
}
# Catch-all redirect to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server block — terminates TLS and serves the site
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# HSTS — start with 300s, raise after validation
add_header Strict-Transport-Security "max-age=300" always;
root /var/www/yourdomain.com;
index index.html index.php;
location / {
try_files $uri $uri/ =404;
}
}The return 301 https://$host$request_uri directive preserves the accessed domain ($host) and the full path with query string ($request_uri), so http://yourdomain.com/blog?id=42 becomes https://yourdomain.com/blog?id=42 without losing context.
Ensure the ACME challenge directory exists and has read permission for the Nginx user:
sudo mkdir -p /var/www/letsencrypt/.well-known/acme-challenge
sudo chown -R www-data:www-data /var/www/letsencrypt
sudo chmod -R 755 /var/www/letsencryptOn Rocky/Alma the user is nginx, not www-data — adjust according to your distribution. This directory needs to be accessible over plain HTTP for Certbot to renew the certificate every 60-90 days.
Never run nginx -s reload without testing the syntax first. A configuration error takes down all sites served by the same daemon, not just the one you’re editing.
Test the configuration syntax before applying:
sudo nginx -tThe expected output is:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successfulIf you see an error, read the message — Nginx points to the exact file and line. Fix it before continuing.
Apply the new configuration with reload (not restart) — reload does a graceful swap, keeping active connections alive:
sudo systemctl reload nginxOn systems without systemd: sudo nginx -s reload. The service keeps responding during the swap; in-flight connections finish on the old worker, and new requests go to the worker with the new config.
Verification
Confirm the redirect is active by testing via curl without opening a browser (browser cache can mask problems):
curl -I http://yourdomain.com
The response should show:
HTTP/1.1 301 Moved Permanently
Server: nginx
Location: https://yourdomain.com/
To confirm the HTTPS destination responds correctly, use -L to follow the redirect:
curl -IL http://yourdomain.com
You’ll see two responses: first the 301 from Nginx, then 200 OK from HTTPS. If the second is 502, 503, or another error, the problem isn’t the redirect — it’s the HTTPS backend.
Also test that the ACME challenge remains accessible over HTTP:
sudo mkdir -p /var/www/letsencrypt/.well-known/acme-challenge
echo "test-ok" | sudo tee /var/www/letsencrypt/.well-known/acme-challenge/test
curl http://yourdomain.com/.well-known/acme-challenge/test
The response should be test-ok in plain text — not a 301 redirect. If you get a redirect, the order of the location blocks is wrong.
Troubleshooting
Infinite redirect loop
Symptom: the browser shows the “ERR_TOO_MANY_REDIRECTS” error. Common cause: the application behind Nginx (PHP, Node) detects $_SERVER['HTTPS'] as empty and redirects to HTTPS again, but Nginx has already redirected — infinite cycle.
Solution: make sure the HTTPS server block passes the correct header to the upstream:
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
The application should trust this header instead of inspecting $_SERVER['HTTPS'] directly.
HSTS stuck on broken HTTPS
If you enable max-age=31536000 (1 year) and later find out the certificate has a problem, the browser will refuse HTTP for an entire year even if you revert the config. The only way out is to ask the user to clear HSTS manually at chrome://net-internals/#hsts (each browser has its own path).
That’s why the template starts with max-age=300. After a week of validating that everything works, raise it to max-age=31536000; includeSubDomains — that’s the value for stabilized production.
Let’s Encrypt renewal fails with 301
Symptom: certbot renew returns the “Failed authorization procedure” error after you enable the redirect. Cause: the location ^~ /.well-known/acme-challenge/ directive is below location / in the file, so the catch-all redirects first.
The ^~ modifier in the template has priority over location /, but if you edited and lost the ^~, order matters. Make sure the ACME location appears before the catch-all OR use ^~, which gives priority regardless of order.
Next steps
With HTTPS forced and validation confirmed, deepen the server’s security and performance:
- TLS hardening: tune
ssl_protocols TLSv1.2 TLSv1.3andssl_ciphersto remove legacy suites — use Mozilla’s SSL Config generator as a baseline. - OCSP Stapling: enable
ssl_stapling onto reduce certificate validation latency on the first request. - HSTS preload: after 1 year with
max-age=31536000; includeSubDomains; preload, submit the domain at hstspreload.org to land on the browser preload list. - HTTP/3: Nginx 1.25+ supports QUIC natively — worth enabling for clients with high latency.
- Separate logs: configure a dedicated
access_logfor the HTTPS server block to make traffic analysis easier.
If you’re running this config in production, a Hostini VPS ships with optimized Ubuntu/Debian and hardened SSH — a good base for bringing up Nginx with TLS without manually tuning the kernel.
Frequently asked questions
Why use 301 and not 302 in the HTTPS redirect?
301 is permanent — it signals to browsers and search engines to cache the HTTPS destination indefinitely, transferring PageRank correctly. 302 is temporary and forces the client to redo the HTTP request every time, wasting latency and hurting SEO. For forced HTTPS in production, always use 301.
Can I use `return 301` or do I need `rewrite`?
Use `return 301` — it's cheaper for Nginx (no regex compilation) and more explicit. `rewrite ... permanent` does the same thing but forces the regex parser and has historically been a source of loops when combined with `if`. Since Nginx 0.9.1, `return` is the recommended pattern for literal redirects.
Does the redirect break Let's Encrypt's `http-01` challenge?
It breaks if you don't exclude the `/.well-known/acme-challenge/` path from the redirect. Certbot needs to serve files over plain HTTP on port 80 during validation. The configuration shown in this tutorial uses a specific `location` that serves the challenge over HTTP before the catch-all redirect, keeping automatic renewal working.
Should I enable HSTS together with the redirect?
Yes, but carefully. HSTS (Strict-Transport-Security) makes the browser remember to only access via HTTPS for N seconds, avoiding the first HTTP request. Start with `max-age=300` (5 min) to test; only then bump to `max-age=31536000` (1 year) with `includeSubDomains`. Getting HSTS wrong locks the domain in broken HTTPS for months in the browser cache.
Does the redirect work if the client accesses by IP instead of domain?
Not with the `server_name example.com` configuration. Nginx only responds with that server block when the Host header matches. To capture requests by IP, add a default server block with `listen 80 default_server` that returns 444 (close connection with no response) or redirects to the canonical domain. This blocks random scanners.
How do I verify the redirect is active without opening a browser?
Use `curl -I http://yourdomain.com` — the response should show `HTTP/1.1 301 Moved Permanently` and the `Location: https://yourdomain.com/` header. If you see 200 OK, the redirect isn't active. Add `-L` to follow the redirect and confirm HTTPS responds 200 at the destination.