SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

HTTP vs HTTPS: what changes on your server

HTTP and HTTPS differ on the server side: a second listener, a redirect, HSTS you cannot easily undo, mixed content, and renewal that fails at 3am.

HTTP vs HTTPS: the short answer

HTTPS is HTTP carried inside a TLS (transport layer security) connection. The protocol above the encryption does not change: the same methods and headers cross the wire, and the status codes mean the same things. What changes is the port, 443 instead of 80, a certificate that proves your server controls the name the visitor typed, and encryption that stops every network in between from reading or editing the traffic. If you want the request format itself, what an HTTP request and response actually contain covers it line by line.

Be honest about what TLS hides. It hides the path, the headers, the cookies and the body. It does not hide the IP address you connected to, the DNS lookup that got you there, or the server name sent in the TLS handshake, which travels before encryption starts because the server needs it to pick a certificate. A certificate proves control of a domain name and says nothing about the person operating it. That is the whole comparison. The rest of this page is the operator's half of it: what you run differently once TLS is on.

What actually changes on the server

Five things arrive with TLS, and each one is work you did not have before. A second listener on 443, plus a redirect from 80 that has to stay. A certificate and a private key on disk, owned by root. A renewal job that runs forever and can fail months after you stopped thinking about it. Response headers that change how browsers treat your name, one of which is much harder to remove than to add. And an application behind the proxy that no longer knows how the client connected.

The second listener, and the redirect you must keep

Before TLS you had one server block on port 80. After TLS you have two listeners, because the old one still has a job. Deleting the port 80 block is the common mistake. An old bookmark, a hard-coded URL in someone's script, or a monitoring check then gets a refused connection instead of a redirect, and the http-01 renewal challenge stops working at the same time.

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    location /.well-known/acme-challenge/ {
        root /srv/acme;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    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;

    root /srv/sites/example.com;
    index index.html;
}

The challenge location is safe from the catch-all redirect because nginx picks the longest matching prefix, so /.well-known/acme-challenge/ wins over / whichever order you write them in. The redirect uses $host, which is the Host header the client sent, so the visitor lands on the same name and path over TLS. If this block is also your default_server, write the literal hostname instead, because a request with a forged Host header would otherwise bounce a visitor somewhere you did not choose.

http2 on; needs nginx 1.25.1 or newer. Ubuntu 24.04 ships nginx 1.24, where HTTP/2 goes on the listen line instead: listen 443 ssl http2;. Point ssl_certificate at fullchain.pem, never cert.pem. The full chain includes the intermediate certificate, and without it many clients cannot build a path to a trusted root.

sudo nginx -t
sudo systemctl reload nginx
curl -sI http://example.com | head -n 2
curl -sI https://example.com | head -n 1

nginx -t should print that the configuration file test is successful. The first curl should print HTTP/1.1 301 Moved Permanently followed by a Location: line starting with https://. The second should print HTTP/2 200. If the reload fails, read journalctl -u nginx -n 20. A line reading bind() to 0.0.0.0:443 failed (98: Address already in use) means another process already holds the port, often a container publishing 443 or a second web server. Find it with sudo ss -lptn 'sport = :443'.

One symptom worth recognising while you test. If a client speaks plain HTTP to the TLS port, nginx logs client sent plain HTTP request to HTTPS port and answers with a 400. The reverse case, a browser speaking TLS to a plaintext port, shows in Firefox as SSL_ERROR_RX_RECORD_TOO_LONG. Both mean a port number is wrong somewhere, not that the certificate is bad.

Why is HSTS so hard to undo?

HSTS (HTTP strict transport security) is one response header.

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

A browser that reads it remembers your hostname for max-age seconds, 31536000 being one year, and refuses to send plain HTTP to that name. It rewrites http:// to https:// before the request leaves the machine, so your 301 never runs. It also removes the button that lets a user continue past a certificate warning.

That memory lives in the client, not on your server. Deleting the header from nginx changes nothing for anyone who already saw it. The only way back is to serve max-age=0 over a working HTTPS connection and wait for each browser to visit again. If HTTPS is the thing that broke, no browser will read the new header, so your site stays unreachable for those users until the clock runs out. Start with max-age=300, leave it for a few days, and raise it only once nothing is limping.

includeSubDomains covers names you are not thinking about. An internal subdomain still on plain HTTP, or one using a self-signed certificate, becomes unreachable in that browser too. Add it after every subdomain answers on HTTPS with a trusted certificate. When there are many of them, a wildcard certificate issued through the DNS-01 challenge covers the whole set with one renewal. The preload token is a further step again: it puts your name in a list compiled into browser binaries, and getting removed takes months and rides browser release schedules.

Two nginx details bite here. Without the always keyword the header is omitted on error responses, so a 404 or a 502 arrives with no HSTS at all. And add_header in an inner block replaces every header inherited from the parent, so a location with its own add_header silently drops HSTS for everything it serves. On your own machine you can clear a stuck entry at chrome://net-internals/#hsts under "Delete domain security policies", which is useful while testing and useless as a fix for your visitors.

Why does the page still say "Not secure" after the certificate installs?

Mixed content. The page arrived over HTTPS, then asked for something over http://. The browser console shows a line that begins Mixed Content: The page at 'https://example.com/' was loaded over HTTPS, but requested an insecure, and the URL at the end of it is the one to fix.

Browsers treat two categories differently, which is why the page mostly works. Active content such as scripts, stylesheets and fetch calls is blocked outright, because a modified script controls the page. Passive content such as images and video is upgraded to https:// automatically and dropped only if that fails. So the usual report is "the site works but the logo is missing and the padlock is gone".

These URLs are almost never typed into a template by hand. They come from an application setting: a site URL stored in the database, or a base URL compiled into a frontend build. Grep the files, then check the app's own configuration.

grep -rn "http://" /srv/sites/example.com --include="*.html" --include="*.css" --include="*.js"

Fix them by using root-relative paths such as /static/app.js for your own assets and explicit https:// for anything hosted elsewhere. Protocol-relative URLs like //cdn.example.net/app.js also work in a browser, but they break when the same file is opened from disk, so the explicit scheme is the safer edit.

The other cause of a warning is the certificate itself. If curl reports curl: (60) SSL certificate problem: unable to get local issuer certificate while your desktop browser looks happy, the chain is incomplete: nginx is pointed at cert.pem rather than fullchain.pem. Browsers often fetch the missing intermediate on their own. Command-line tools and most language HTTP clients do not, so your API clients break while your test in Chrome passes.

What does a reverse proxy in front see, and what does it not?

When nginx terminates TLS and forwards to an app on 127.0.0.1:8000, decryption happens at nginx. The app receives ordinary HTTP on a local port. It cannot tell that the client used TLS, so it builds http:// absolute URLs, sets cookies without the Secure flag, and answers no to any framework check for "was this request secure". Pass the missing facts down explicitly.

location / {
    proxy_pass http://127.0.0.1:8000;
    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-Proto $scheme;
}

Sending the header is half the job. The application also has to trust it, and frameworks ignore it by default because a header is trivial to forge. In Django that switch is SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https"). Because the header is forgeable, bind the app to 127.0.0.1 rather than 0.0.0.0, so the proxy is the only path to it. Otherwise anyone who reaches port 8000 directly can claim their plaintext request arrived over TLS. What each line of an nginx reverse proxy block does goes through the rest of these headers.

Now the part people miss. A terminating proxy sees everything. It reads the full request in cleartext, including the password in a login POST. Encryption ends at nginx, and the hop from nginx to the app is plaintext. On one box that hop stays on the loopback interface and never touches a network, which is fine. Across two machines it is not fine, so put that hop on a private network or give it its own TLS connection. A proxy that does not terminate TLS, passing the TCP stream through untouched, has the opposite property: it cannot route on the path or the headers, because it cannot read them.

Renewal is a job that will page you at 3am

Let's Encrypt certificates last 90 days. Certbot installs a systemd timer that runs twice a day and renews anything within 30 days of expiry, so the machinery is already there. Verify it rather than assume it.

systemctl list-timers certbot.timer
sudo certbot certificates
sudo certbot renew --dry-run

certbot certificates lists each certificate with its names and expiry date. renew --dry-run runs the real renewal path against the staging service, which proves the challenge still works without spending a rate limit. First issuance is a separate walkthrough: issuing a Let's Encrypt certificate with Certbot on Ubuntu 24.04 and nginx.

Renewal rarely fails on the day you set it up. It fails later, because something around it changed.

  • Port 80 was closed during a firewall cleanup, so the http-01 challenge cannot reach the server.
  • The application grew a catch-all route that answers /.well-known/acme-challenge/ before nginx can serve the file.
  • The DNS name was moved to another host, or the site went behind a CDN.
  • Somebody copied the certificate files to another directory at install time. Renewal rewrites the files under /etc/letsencrypt/live/, and the copy keeps the old dates forever.

Renewal also restarts nothing by itself. nginx reads the certificate at start or reload and keeps it in memory, so a freshly renewed file is not served until you reload. The nginx plugin handles this for you; if you issued with certonly or --webroot, add the reload as a deploy hook.

sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null <<'EOF'
#!/bin/sh
systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Since June 2025 Let's Encrypt no longer sends expiry warning emails, so nothing outside your own systems will tell you the clock is running down. Check the live certificate from outside the box and alert on days remaining, not on the exit code of the renewal job, because days remaining is the number your visitors feel.

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates

That prints a notBefore= line and a notAfter= line for the certificate actually being served, which is the one that matters. A renewed file on disk that nginx never reloaded shows up here as an unchanged notAfter= date.

Why this matters on a VPS: the plaintext admin panel

A VPS has a public IP address from the moment it boots, and it is reachable by everyone. Open the access log on a new server and you will see requests for paths like /wp-login.php and /.env arriving from addresses you have never heard of, within hours, without you telling anyone the server exists.

On plain HTTP an admin login posts the password in cleartext and gets a session cookie back in cleartext. Every network on the path can read both. Every network on the path can also rewrite the response, because nothing signs it. This is the practical reason the comparison matters on a VPS, more than any ranking benefit: the panel is on a public address, and the credentials are in the clear.

Plain HTTP also costs you browser features. Service workers, WebAuthn and the clipboard API are restricted to secure contexts, so they do not run over http:// anywhere except localhost. The Secure cookie flag has nothing to attach to. Browsers require TLS for HTTP/2 in practice. If the service has a public DNS name, get a public certificate. If it only has an IP address or an internal name, no public CA will issue for it, and the two workable answers are a self-signed certificate with the warning that comes with it, or your own CA installed in the trust store of the machines you control, which removes the warning on those machines only.

Checklist for a new site

  1. DNS A and AAAA records point at the VPS and resolve before you request anything.
  2. Ports 80 and 443 are open on the VPS firewall and on your provider's separate network firewall, which is a different control on most panels.
  3. One server block on port 80 serves /.well-known/acme-challenge/ and redirects everything else.
  4. Certificate issued, nginx -t passing, then reload.
  5. curl -sI http://example.com returns 301 and curl -sI https://example.com returns 200.
  6. HSTS starts at max-age=300 and is raised only after a clean week.
  7. systemctl list-timers certbot.timer shows a next run, and a deploy hook reloads nginx.

Checklist for a site you are moving to HTTPS

  1. Inventory every absolute http:// URL in templates, in the database and in frontend build config.
  2. Issue the certificate while port 80 still serves the site normally, with no redirect yet.
  3. Load the site over https:// and confirm it renders the same page before you turn the redirect on.
  4. Turn on the redirect, then watch the error log for client sent plain HTTP request to HTTPS port from scripts and integrations you forgot about.
  5. Update the app's own base URL setting, plus webhooks, API clients, cron jobs and uptime checks.
  6. Add X-Forwarded-Proto and make the app trust it if anything runs behind a proxy.
  7. Add HSTS last, with a small max-age, and includeSubDomains only once every subdomain has a trusted certificate.

FAQ

Do I still need port 80 open after moving to HTTPS?

Yes, in almost every case. Port 80 keeps two jobs after the switch: it redirects visitors who typed the bare name or followed an old link, and it answers the http-01 challenge Certbot uses at renewal. Close it and old links produce a refused connection instead of a redirect, and renewal fails at the next attempt. The exception is a certificate issued over the DNS-01 challenge, which never touches port 80, though you still lose the redirect for anyone arriving over HTTP.

Why does my site still show "Not secure" after the certificate installed?

Usually mixed content: the page loaded over HTTPS but a script or an image was requested over http://. Open the browser console and look for a line beginning Mixed Content: The page at 'https://example.com/' was loaded over HTTPS, but requested an insecure. The URL at the end of that line is the one to fix, and it normally comes from an application setting rather than from a template you edited. The other cause is a certificate that does not cover the exact name typed, so test the apex name and the www name separately.

How do I turn HSTS off?

Slowly, and only from a site whose HTTPS still works. Serve Strict-Transport-Security: max-age=0 over HTTPS, and each browser clears its entry the next time it visits. A browser that never comes back keeps the old value until it expires on its own. If HTTPS is broken, no browser can read the new header at all, so there is no server-side fix available. On a single machine you can clear the entry by hand at chrome://net-internals/#hsts under "Delete domain security policies". This asymmetry is the reason to start at max-age=300.

Why does my app redirect in a loop behind an HTTPS reverse proxy?

The app is configured to force HTTPS but receives plain HTTP from the proxy, so it redirects to the https:// URL, the proxy terminates TLS and forwards plain HTTP again, and the browser stops with ERR_TOO_MANY_REDIRECTS. Send X-Forwarded-Proto $scheme from nginx and configure the app to trust it, which in Django is SECURE_PROXY_SSL_HEADER. Bind the app to 127.0.0.1 before you trust that header, because a header anyone can send proves nothing on its own.

Is a self-signed certificate good enough for an internal admin panel?

It encrypts the connection, which is the thing a plaintext panel is missing. It does not authenticate the server, so every browser shows a warning and your users learn the habit of clicking through warnings. For a service only your own machines reach, running a small CA and installing its root into those machines gives you the encryption without training that habit. For anything with a public DNS name, a free public certificate is less work than either option.