Isometric illustration of static site pages on a platform connected to a secured web server with a padlock

A Jekyll SSL setup has two halves. First, the host serves your built _site/ over HTTPS with a valid certificate and redirects HTTP to HTTPS: GitHub Pages, Render, Netlify and Enkihost do this automatically, and on your own server, certbot with Nginx or Caddy does it for free with Let’s Encrypt. Second, the Jekyll side has to agree: url in _config.yml must start with https://, and no page may load images, scripts or styles over http://. This How-To covers both halves for every common host, with complete Nginx and Caddy configs, a safe HSTS rollout, and fixes for the errors you’ll actually see.


TL;DR

  • On GitHub Pages, Render, Netlify, Cloudflare Pages, Upsun and Enkihost, certificates are issued and renewed automatically. You configure DNS, wait for issuance, and turn on the HTTP→HTTPS redirect if it isn’t on by default.
  • On your own server, use certbot with Nginx (certbot certonly --webroot + a 301 redirect), or Caddy, which obtains and renews certificates with no configuration.
  • Set url: "https://example.com" in _config.yml (no trailing slash), build with JEKYLL_ENV=production, and grep _site/ for src="http:// to find mixed content.
  • Add HSTS only after every subdomain serves HTTPS, and start with a short max-age.
  • Jekyll assets aren’t fingerprinted, so cache them for days, not a year, unless you add digests to filenames.

Table of Contents

How does SSL work for a Jekyll site?

Jekyll generates static files. It never handles HTTPS itself in production. The web server or hosting platform in front of _site/ holds the TLS certificate, terminates HTTPS, and redirects plain HTTP. Jekyll’s only job is to generate URLs and asset references that use https://.

Where a Jekyll site's certificate comes from: managed static hosts, Ruby/Jekyll PaaS, Nginx or Caddy on your own server

Where the site runs Who gets the certificate Renewal HTTP→HTTPS redirect
GitHub Pages GitHub (Let’s Encrypt) Automatic “Enforce HTTPS” checkbox
Render static site Render (Let’s Encrypt / Google Trust Services) Automatic Automatic
Netlify, Cloudflare Pages The platform Automatic Automatic or a toggle
Upsun, Enkihost The platform’s proxy, per custom domain Automatic Automatic for HTTPS routes
Your server with Nginx You, with certbot systemd timer You (a return 301)
Your server with Caddy Caddy Automatic Automatic

“SSL” here really means TLS. SSL itself has been obsolete for years, but the name stuck, and certificates are still called SSL certificates.

Prerequisites

  • A Jekyll 4.x site that builds with bundle exec jekyll build.
  • A domain you control, with access to its DNS records.
  • For self-hosting: a Debian or Ubuntu server with ports 80 and 443 open to the internet, and sudo access.

Step 1: Configure Jekyll for HTTPS

Tell Jekyll the site’s canonical URL is HTTPS, so every absolute link, the feed, the sitemap and SEO tags use it.

# _config.yml
url: "https://example.com"   # scheme + host, no trailing slash
baseurl: ""                  # "" for a site at the domain root, "/blog" for a subpath

A trailing slash in url produces double slashes (https://example.com//feed.xml) in anything built with absolute_url, so leave it off.

In templates, build links with Jekyll’s filters instead of hardcoding the scheme:

<!-- _includes/head.html -->
<link rel="canonical" href="{{ page.url | absolute_url }}">
<link rel="alternate" type="application/rss+xml" href="{{ '/feed.xml' | absolute_url }}">
<meta property="og:image" content="{{ '/assets/images/og.png' | absolute_url }}">

Build for production so plugins such as jekyll-seo-tag, jekyll-feed and jekyll-sitemap use url instead of localhost:

JEKYLL_ENV=production bundle exec jekyll build

If you want to test HTTPS locally, jekyll serve can serve over TLS with a locally trusted certificate:

mkcert -install && mkcert localhost
bundle exec jekyll serve --ssl-cert localhost.pem --ssl-key localhost-key.pem
# Server address: https://127.0.0.1:4000/

Step 2: Enable HTTPS on a managed host

On managed hosts, HTTPS is mostly a matter of DNS and waiting for the certificate. The platform does the rest.

GitHub Pages. Point DNS at GitHub, add the custom domain, then enable HTTPS:

# DNS for an apex domain
example.com.      A      185.199.108.153
example.com.      A      185.199.109.153
example.com.      A      185.199.110.153
example.com.      A      185.199.111.153
www.example.com.  CNAME  yourname.github.io.

Then in Settings → Pages, enter example.com as the custom domain, wait for the DNS check and certificate, and tick Enforce HTTPS. Remove any extra A/AAAA records that point elsewhere, or the certificate won’t be issued.

Render static site. Create a static site with:

# render.yaml
services:
  - type: web
    runtime: static
    name: my-jekyll-site
    buildCommand: bundle install && JEKYLL_ENV=production bundle exec jekyll build
    staticPublishPath: ./_site
    domains:
      - example.com
      - www.example.com
    headers:
      - path: /*
        name: Strict-Transport-Security
        value: max-age=31536000; includeSubDomains

Render issues certificates for both domains and redirects HTTP to HTTPS automatically.

Netlify / Cloudflare Pages. Set the build command to JEKYLL_ENV=production bundle exec jekyll build and the publish directory to _site, add the custom domain, and the platform provisions the certificate. On Netlify, keep Force HTTPS on (the default once a certificate exists).

Enkihost. Enkihost hosts Jekyll sites and issues certificates for custom domains at its proxy. That architecture is described in how we built a PaaS on Coolify and Docker.

Step 3: Self-host with Nginx and Let’s Encrypt

On your own server, get a free Let’s Encrypt certificate with certbot, serve _site/ from Nginx over HTTPS, and redirect everything else there.

1. Install Nginx and certbot:

sudo apt update
sudo apt install -y nginx certbot
sudo mkdir -p /var/www/certbot /var/www/example.com/current

2. Start with an HTTP-only server that can answer the Let’s Encrypt challenge:

# /etc/nginx/sites-available/example.com
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://example.com$request_uri;
    }
}
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

3. Request the certificate for both names:

sudo certbot certonly --webroot -w /var/www/certbot \
  -d example.com -d www.example.com \
  --deploy-hook "systemctl reload nginx"

4. Add the HTTPS servers to the same file:

# /etc/nginx/sites-available/example.com (append below the port 80 server)
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    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;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com;

    root /var/www/example.com/current;
    index index.html;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:SSL:10m;

    add_header Strict-Transport-Security "max-age=300" always;   # raise later, see Step 5

    location / {
        try_files $uri $uri.html $uri/ =404;
    }

    location ~* \.(css|js|png|jpe?g|gif|svg|webp|ico|woff2?)$ {
        expires 7d;
        add_header Cache-Control "public";
        add_header Strict-Transport-Security "max-age=300" always;   # add_header in a location replaces the server's
    }

    error_page 404 /404.html;
}
sudo nginx -t && sudo systemctl reload nginx

5. Deploy the site by syncing the build output:

JEKYLL_ENV=production bundle exec jekyll build
rsync -az --delete _site/ deploy@example.com:/var/www/example.com/current/

6. Confirm automatic renewal. Debian’s certbot package installs a systemd timer that renews certificates about 30 days before they expire:

systemctl list-timers | grep certbot
sudo certbot renew --dry-run

Pro Tip: The second add_header in the assets location isn’t a mistake. In Nginx, any add_header inside a location block replaces all add_header directives inherited from the server block. Without repeating HSTS there, your images and CSS are served without it.

Step 4: Or self-host with Caddy

Caddy gets and renews certificates automatically and redirects HTTP to HTTPS by default, so the whole configuration fits on one screen.

sudo apt install -y caddy
# /etc/caddy/Caddyfile
example.com {
    root * /var/www/example.com/current
    encode zstd gzip
    file_server
    try_files {path} {path}.html {path}/

    header Strict-Transport-Security "max-age=300"
    @assets path *.css *.js *.png *.jpg *.jpeg *.svg *.webp *.woff2
    header @assets Cache-Control "public, max-age=604800"

    handle_errors {
        rewrite * /404.html
        file_server
    }
}

www.example.com {
    redir https://example.com{uri} permanent
}
sudo systemctl reload caddy
journalctl -u caddy --since "5 min ago" | grep -i certificate

Caddy needs ports 80 and 443 reachable and DNS pointing at the server before the first start. Otherwise it retries certificate issuance with backoff.

Step 5: Fix mixed content and add HSTS

Once the site is on HTTPS, any image, script or stylesheet still loaded over http:// triggers mixed-content warnings or is blocked. Find them in the built site, not the source, because includes and data files also contribute.

JEKYLL_ENV=production bundle exec jekyll build
grep -rnoE '(src|srcset)="http://[^"]+' _site | head
grep -rnoE 'url\(["'"'"']?http://[^)]+' _site | head
grep -rnoE '<link[^>]+href="http://[^"]+' _site | head

Fix each one at the source: change hardcoded http:// asset URLs to https:// or to a path (/assets/images/...), and download images from third-party hosts into assets/ rather than hotlinking them. Plain <a href="http://..."> links to other sites aren’t mixed content. They’re only navigation, though switching them to https:// where available is still good practice.

HSTS rollout. HSTS tells browsers to use HTTPS automatically for a period of time. Increase it in stages, because browsers cache it and you can’t take it back early:

Stage Header Wait before next stage
1 max-age=300 A day
2 max-age=604800; includeSubDomains A week, after checking every subdomain has HTTPS
3 max-age=31536000; includeSubDomains —
4 (optional) max-age=63072000; includeSubDomains; preload Submit at hstspreload.org

The same rollout applies to Rails apps, as explained in how to force HTTPS in Rails.

Step 6: Verify it works

Check redirects, the certificate, HSTS and mixed content from the outside.

# HTTP and www redirect to the canonical HTTPS URL
curl -sI http://example.com/blog/ | grep -iE "^HTTP|^location"
curl -sI https://www.example.com/blog/ | grep -iE "^HTTP|^location"
# 301 → location: https://example.com/blog/

# Certificate covers both names and isn't expiring soon
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -subject -ext subjectAltName -dates

# HSTS on pages and on assets
curl -sI https://example.com/ | grep -i strict-transport
curl -sI https://example.com/assets/main.css | grep -i strict-transport

# Canonical and feed URLs use https
curl -s https://example.com/ | grep -oE 'rel="canonical" href="[^"]+"'
curl -s https://example.com/feed.xml | grep -oE '<link[^>]+href="[^"]+"' | head -2

Then open the site in a browser with the developer console open. A clean console with no “Mixed Content” warnings, plus an A or A+ from SSL Labs’ server test, means you’re done.

How Heroku, Render, Fly.io, Upsun and Enkihost handle HTTPS for static sites

All five can serve a Jekyll site over HTTPS, but only some have a native static-site product. The others run Jekyll’s output behind a web server in an app container, and the HTTPS details follow from that.

Platform How a Jekyll site runs Certificate HTTP→HTTPS redirect Cost for a small site
Heroku As an app: buildpack builds _site, a web server (e.g. Nginx buildpack or Rack) serves it Automated Certificate Management No router redirect, so the web server must do it Paid dyno
Render Native static site on a global CDN Automatic (Let’s Encrypt / Google Trust Services) Automatic Free tier; custom domain limits per plan
Fly.io Nginx or Caddy container serving _site fly certs add example.com force_https = true in fly.toml Smallest Machine
Upsun App with web.locations serving _site Automatic Let’s Encrypt, renewed before expiry Automatic for https:// routes Per project resources
GitHub Pages Native, built by GitHub Actions or the Pages builder Let’s Encrypt “Enforce HTTPS” toggle Free
Enkihost Spark plan: built in a multi-stage image and served by Nginx Automatic Let’s Encrypt at the proxy Not documented 0 EUR on Spark

Render’s native static sites and GitHub Pages are the simplest for a pure Jekyll site. Heroku is the one where HTTPS takes extra work: its router doesn’t redirect HTTP, so the server inside the dyno has to.

Enkihost hosts Jekyll sites as a first-class runtime, alongside Ruby, Rails and Sinatra apps, and handles certificates for custom domains at its proxy, as described in our Enkihost introduction and architecture post.

Troubleshooting Jekyll SSL

GitHub Pages: “Enforce HTTPS — Unavailable for your site”

Enforce HTTPS — Unavailable for your site because your domain is not properly configured to support HTTPS

DNS doesn’t point only at GitHub’s IPs yet, or the certificate is still being issued. Remove extra A/AAAA records (for example a leftover AAAA from an old host), wait, and if nothing changes after an hour, remove and re-add the custom domain to restart provisioning.

certbot: Timeout during connect (likely firewall problem)

Certbot failed to authenticate some domains (authenticator: webroot).
  Domain: example.com
  Type:   connection
  Detail: 203.0.113.10: Fetching http://example.com/.well-known/acme-challenge/…: Timeout during connect (likely firewall problem)

Let’s Encrypt can’t reach port 80. Open it (sudo ufw allow 80/tcp), check the cloud provider’s firewall, and make sure DNS for both names already points at this server.

NET::ERR_CERT_COMMON_NAME_INVALID on www

The certificate only covers example.com. Re-run certbot with both -d example.com -d www.example.com, or on managed hosts add www as a domain too.

Mixed content warnings after switching to HTTPS

Mixed Content: The page at 'https://example.com/blog/post/' was loaded over HTTPS, but requested an insecure image 'http://example.com/assets/images/old.png'.

A post or layout hardcodes http://. Run the grep commands from Step 5 on _site/ and fix the sources. If url in _config.yml still says http://, every absolute_url will too.

ERR_TOO_MANY_REDIRECTS behind Cloudflare

Cloudflare’s Flexible mode connects to your origin over HTTP, and your server redirects that to HTTPS, so the loop never ends. Switch Cloudflare to Full (strict) once the origin has a valid certificate.

HSTS header missing on CSS and images

You set add_header inside an Nginx location (for caching), which drops the server-level HSTS header for those files. Repeat the HSTS header in that location, as in Step 3.

Author Perspective: the certificate is the easy part

Getting a certificate for a static site is close to a solved problem: every host does it, and on my own servers certbot or Caddy does it in a minute. What actually breaks HTTPS on Jekyll sites is everything around the certificate: a url: still set to http:// from 2016, an old post hotlinking an image from a site that’s gone, a www subdomain nobody added to the certificate, an Nginx location that silently drops the HSTS header. If I could add only one check to a Jekyll site’s CI, it would be the grep from Step 5, failing the build on any src="http://". It’s two lines, and it’s the check that keeps HTTPS working.

Jekyll hosting with HTTPS on Enkihost

Enkihost was built with Jekyll sites as a first-class citizen, next to Ruby, Rails and Sinatra apps:

Enkihost

  • Ruby hosting built by Rubyists, with Jekyll as a supported runtime, so bundle exec jekyll build runs the way it does on your machine.
  • Certificates for custom domains handled at the platform’s proxy, so the Jekyll-side checklist above (url, mixed content, HSTS) is all that’s left for you.
  • Zero-downtime deploys, so publishing a post never shows visitors a half-uploaded _site/.
  • Per-app resource isolation, and plans at Spark (0 EUR, one Jekyll site), Ignite (9 EUR) and Blaze (29 EUR) per month, with a 14-day free trial on Ignite.

See enkihost.com.

FAQ

Does Jekyll support HTTPS?

Jekyll generates static files and doesn’t serve production traffic, so HTTPS is handled by the host or web server in front of the generated _site folder. Jekyll’s part is to use https in the url setting of _config.yml and avoid http asset references. For local testing, jekyll serve accepts –ssl-cert and –ssl-key options.

Is SSL free for Jekyll sites?

Yes, in almost every setup. GitHub Pages, Render, Netlify, Cloudflare Pages and most platforms issue certificates at no charge, and on your own server Let’s Encrypt certificates are free through certbot or Caddy. You only pay if you want a specific commercial certificate type, such as extended validation.

Why does my Jekyll site show “Not Secure” after enabling HTTPS?

Usually because a page still loads an image, script or stylesheet over http, which browsers report as mixed content. Search the built _site folder for src=”http:// and fix the sources, and make sure url in _config.yml starts with https.

How do I redirect www to the root domain on a Jekyll site?

Do it on the host, not in Jekyll. On Nginx, add a server block for www that returns a 301 to the root domain; on Caddy, use redir; on GitHub Pages, Render and Netlify, add both domains and set the root domain as primary. Make sure the certificate covers both names.

How often do Let’s Encrypt certificates need renewing?

Let’s Encrypt certificates are valid for 90 days. certbot’s systemd timer and Caddy both renew automatically well before expiry, typically around 30 days before. Managed hosts renew for you. Run certbot renew –dry-run once after setup to confirm renewal works on your own server.

Sources