DEV Community

mattleeee
mattleeee

Posted on Originally published at hkcode.dpdns.org

Enforcing HTTPS on GitHub Pages After a Domain Switch

Pointing a new domain at GitHub Pages works on the first try often enough that the greyed-out checkbox comes as a surprise. You add the custom domain, GitHub accepts it, the site loads over plain HTTP, and the Enforce HTTPS option sits there disabled with a note that the certificate has not been provisioned yet. In most cases nothing is wrong. GitHub is still in the middle of issuing the certificate, and the toggle unlocks on its own once that finishes.

This post covers the whole lifecycle: what happens after you save a custom domain, how to watch it progress from the command line, why you should fix mixed content before you enforce HTTPS, and what to check when provisioning genuinely fails.

The provisioning sequence

When you save a custom domain for a GitHub Pages site, GitHub runs a fixed pipeline in the background. The UI gives you one checkbox as feedback, but three distinct things happen.

1. DNS check

GitHub resolves the domain and confirms it points where it should:

  • For an apex domain (example.com), A records must point to the Pages IPs, or an ALIAS/ANAME record must be configured.
  • For a subdomain (www.example.com, blog.example.com), a CNAME record must point to <user>.github.io (or <org>.github.io).

If DNS does not resolve correctly at this stage, the certificate request never starts. The UI will keep saying the certificate is pending, and it will keep saying that indefinitely. This is the failure mode worth knowing about, because it looks identical to normal waiting.

2. Certificate issuance

Once DNS checks out, GitHub requests a certificate for the domain (and the www variant where applicable) from its CA. This is the part that takes time. Typical latency is 10 to 30 minutes, but it depends on DNS TTLs and CA queueing, so an hour is not unheard of after a domain switch. During this window the site is reachable over HTTP and, in many cases, already over HTTPS with a certificate that browsers may or may not accept yet.

3. Enforce toggle unlocks

Only after the certificate is live does GitHub enable the Enforce HTTPS checkbox. Ticking it makes Pages redirect all HTTP requests to HTTPS with a 301. Until then, the box stays disabled, and there is no override, no query parameter, no API call that forces it early.

The practical takeaway: after a domain switch, expect a wait. Do not start changing DNS records while the certificate is pending, because you will reset the process.

Watching progress from the terminal

The GitHub settings page is a poor progress indicator. It shows a static message until it flips. curl tells you more.

Check what is actually being served

curl -I http://example.com
curl -I https://example.com
Enter fullscreen mode Exit fullscreen mode

Run both. Before the certificate is live, the HTTPS request typically fails at the TLS handshake, which curl reports as:

curl: (60) SSL certificate problem: unable to get local issuer certificate
Enter fullscreen mode Exit fullscreen mode

or a certificate mismatch error. The HTTP request, meanwhile, returns 200 with no redirect. That combination means: DNS is fine, certificate is not ready.

Once provisioning completes, https:// returns 200 and http:// still returns 200 until you tick the box. After enforcing, http:// returns:

HTTP/1.1 301 Moved Permanently
Location: https://example.com/
Enter fullscreen mode Exit fullscreen mode

Inspect the served certificate

curl -I only tells you whether TLS works. To see which certificate is being served and for which names, use openssl:

echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
Enter fullscreen mode Exit fullscreen mode

A healthy result looks roughly like:

subject=CN = example.com
issuer=C = US, O = Let's Encrypt, CN = R3
notBefore=...
notAfter=...
X509v3 Subject Alternative Name:
    DNS:example.com, DNS:www.example.com
Enter fullscreen mode Exit fullscreen mode

Two things to verify here. First, the SAN list must include every hostname you actually use. If you serve www.example.com but the certificate only covers the apex, browsers will warn on the www host even though the apex is fine. Second, check notBefore and notAfter to rule out a stale or expired certificate being served from an edge cache.

If openssl returns a certificate whose subject is *.github.io or belongs to a different domain, the request is landing on the wrong vhost. That usually means DNS is still pointing at an old host, or a proxy (Cloudflare, for example) is terminating TLS in front of Pages. Cloudflare's "Full" or "Flexible" SSL modes in front of GitHub Pages are a common source of confusing certificate output.

Scripting the wait

If you switch domains often, a small polling script saves a lot of tab-refreshing. This one checks the HTTPS handshake and the redirect status until both are correct, or gives up after a timeout:

import ssl
import socket
import time
import urllib.request
import urllib.error

DOMAIN = "example.com"
TIMEOUT_MINUTES = 45
INTERVAL_SECONDS = 120


def tls_ok(host: str, port: int = 443) -> bool:
    ctx = ssl.create_default_context()
    try:
        with socket.create_connection((host, port), timeout=10) as sock:
            with ctx.wrap_socket(sock, server_hostname=host) as tls:
                cert = tls.getpeercert()
                sans = [v for k, v in cert.get("subjectAltName", ()) if k == "DNS"]
                return host in sans or f"*.{host.split('.', 1)[1]}" in sans
    except (ssl.SSLError, socket.error, OSError):
        return False


def redirects_to_https(url: str) -> bool:
    req = urllib.request.Request(url, method="HEAD")
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            return resp.geturl().startswith("https://")
    except urllib.error.HTTPError as e:
        return e.code in (301, 308) and e.headers.get("Location", "").startswith("https://")
    except urllib.error.URLError:
        return False


deadline = time.time() + TIMEOUT_MINUTES * 60
while time.time() < deadline:
    cert = tls_ok(DOMAIN)
    enforced = redirects_to_https(f"http://{DOMAIN}")
    print(f"cert={cert} enforced={enforced}")
    if cert and enforced:
        print("Done.")
        break
    time.sleep(INTERVAL_SECONDS)
else:
    print("Timed out. Check DNS and the Pages settings.")
Enter fullscreen mode Exit fullscreen mode

The tls_ok check is stricter than a plain connection test: it validates that the hostname appears in the SAN list, which is the thing that actually matters for browsers. The redirects_to_https check confirms enforcement is active. Run it from a machine that is not behind the same DNS resolver you just changed, or you may be testing a cached view.

Fix mixed content before you enforce

This is the step people skip, and it causes the most visible breakage. Enforcing HTTPS makes the page itself load over TLS, but it does not rewrite the URLs inside your HTML, CSS, or JavaScript. Any resource still referenced as http:// becomes mixed content. Modern browsers block active mixed content (scripts, stylesheets, iframes) outright and either block or auto-upgrade passive mixed content (images), depending on the browser and the resource type.

The result: the page loads, the layout looks wrong, and the console is full of errors. Users blame the certificate; the real cause is a handful of hard-coded http:// URLs.

Find them before you flip the switch

Crawl your own site over HTTP and grep the rendered HTML for http:// references. A minimal crawler:

import re
from urllib.parse import urljoin, urlparse
from collections import deque

import requests

START = "http://example.com/"
SAME_HOST = urlparse(START).netloc

# Ignore URLs that are fine as http:// (rare) or not resources at all.
IGNORE = re.compile(r"^(mailto:|tel:|javascript:|#)")

seen = set()
queue = deque([START])
findings = []

session = requests.Session()
session.headers["User-Agent"] = "mixed-content-audit/1.0"

while queue:
    url = queue.popleft()
    if url in seen or urlparse(url).netloc != SAME_HOST:
        continue
    seen.add(url)

    try:
        resp = session.get(url, timeout=10)
    except requests.RequestException:
        continue
    if "text/html" not in resp.headers.get("Content-Type", ""):
        continue

    for match in re.finditer(r'(?:src|href|action)\s*=\s*["\'](http://[^"\']+)', resp.text):
        findings.append((url, match.group(1)))

    for match in re.finditer(r'<a\s[^>]*href\s*=\s*["\']([^"\'#]+)', resp.text):
        link = urljoin(url, match.group(1))
        if not IGNORE.match(link):
            queue.append(link)

for page, asset in findings:
    print(f"{page} -> {asset}")
print(f"\n{len(findings)} http:// references across {len(seen)} pages")
Enter fullscreen mode Exit fullscreen mode

Anything it prints is a candidate for breakage. The fix is mechanical: rewrite to https://, or better, to protocol-relative or root-relative URLs (//cdn.example.com/lib.js or /assets/lib.js) so the scheme follows the page.

Common offenders

  • Hard-coded CDN links in templates, often copied from documentation years ago.
  • Markdown content with embedded images using http:// URLs.
  • Analytics or font snippets pasted from a provider dashboard that predates HTTPS-by-default.
  • srcset and <picture> sources, which are easy to miss because they are not plain src attributes.
  • CSS url() references to background images or fonts.

The crawler above only covers HTML attributes. For a thorough pass, also fetch your CSS files and search them for http://, and check any client-side JavaScript that builds URLs at runtime. A Content-Security-Policy header with upgrade-insecure-requests is a useful safety net, but it is a mitigation, not a fix. Do the rewrite first.

When provisioning actually fails

If more than a couple of hours pass and curl -I https://example.com still fails the handshake, the pipeline is stuck. Work through these in order.

Verify the CNAME value exactly

For a subdomain, the CNAME record must point to the Pages hostname, not to an IP, not to a proxy, and not to a URL with a path. The value is <user>.github.io. (or <org>.github.io.), with or without the trailing dot depending on your DNS provider. The apex domain is a special case: it cannot be a CNAME, so it needs A records to the documented GitHub Pages IPs, plus a CNAME file in the repository or the custom domain set in settings.

dig +short CNAME www.example.com
dig +short A example.com
dig +short example.com NS
Enter fullscreen mode Exit fullscreen mode

Check the NS output too. If the domain is still delegated to your registrar's parking nameservers because you changed nameservers recently, the records you configured in the new DNS provider are not live yet. That alone will stall provisioning.

Wait out the TTL

If you changed DNS records within the last 48 hours, resolvers across the internet are still serving cached answers with the old target. GitHub's checker may have queried a resolver that still had the old record and concluded DNS was wrong. There is no way to force GitHub's check; it retries on its own schedule. Lowering the TTL before a planned migration (to 300 seconds, a day or two in advance) makes this window much shorter.

To see what different resolvers think, query a public resolver directly:

dig +short CNAME www.example.com @1.1.1.1
dig +short CNAME www.example.com @8.8.8.8
Enter fullscreen mode Exit fullscreen mode

If these disagree, you are still inside the propagation window. Wait.

Remove and re-add the custom domain

If DNS is correct across resolvers, the served certificate is wrong or absent, and several hours have passed, remove the custom domain in the Pages settings, save, and add it back. This cancels the stuck request and starts a fresh one. It is safe, but it resets the clock, so only do it once you are confident DNS is right. Re-adding the domain while DNS is still wrong just reproduces the same stuck state.

Rule out a proxy in front of Pages

If the domain is behind Cloudflare or a similar proxy, the certificate GitHub issues may never be served, because the proxy terminates TLS with its own certificate. That is not necessarily a problem, but it changes where you look: the openssl output will show the proxy's issuer, and the "Enforce HTTPS" toggle in GitHub may stay disabled because GitHub cannot complete its own validation through the proxy. Either set the proxy to DNS-only during provisioning and re-enable it afterwards, or accept that HTTPS is handled at the proxy layer and configure enforcement there instead.

Check the CNAME file in the repo

If you set the custom domain through the repository's CNAME file rather than the settings UI, a mismatch between the file contents and the settings value can cause the domain to flap between configured states. Pick one method and stick to it; the settings UI writes the CNAME file for you.

A short checklist

Before enforcing HTTPS after a domain switch:

  1. Confirm DNS resolves correctly from multiple public resolvers.
  2. Wait for the certificate; verify it with openssl s_client and check the SAN list covers every hostname.
  3. Crawl the site for http:// asset references and rewrite them.
  4. Tick Enforce HTTPS once the checkbox unlocks.
  5. Verify the redirect with curl -I http://example.com and re-run the crawler against the HTTPS origin to confirm nothing regressed.

The greyed-out checkbox is normal. The failures that matter are the ones where it stays greyed out for hours, and those almost always trace back to DNS.

More notes like this ship every week on this site.


Daily Picks

The following pairs are selected from the multi-timeframe trend scanner (Gate.io futures) and are for technical-analysis study only — not investment advice.
Data updated: 2026-10-25 08:56:33

Long

Pair Signal Price Take Profit Stop Loss R/R
AXIS 短平快做多 $0.2484 $0.2559 $0.2422 1:1.2
ORDER 短平快做多 $0.0566 $0.0583 $0.0552 1:1.2

2 picks selected. Scanner runs every 15 minutes.

Top comments (0)