Skip to content

SSL Certificates

This guide covers how HTTPS is set up for the AWS deployment, how auto-renewal works, and what to do if something goes wrong.

Overview

Both 2sigma.io and docs.2sigma.io are served over HTTPS using a free SSL certificate from Let's Encrypt, managed by certbot. A single certificate covers both domains. HTTP requests are automatically redirected to HTTPS via a 301 in nginx.

The certificate auto-renews via a systemd timer that runs twice daily. No manual intervention is needed under normal circumstances.

How It Works

Let's Encrypt is a free, automated certificate authority. It issues certificates that browsers trust, just like paid CAs, but at no cost.

Certbot is the tool that talks to Let's Encrypt. It requests the certificate, proves you control the domain (by temporarily serving a challenge file on port 80), and saves the cert files to /etc/letsencrypt/.

Certificates from Let's Encrypt expire every 90 days. This is intentional on their part: short lifetimes encourage automation and limit the damage if a cert is ever compromised. Certbot handles renewal automatically so you never need to think about the 90-day window.

The renewal flow:

  1. A systemd timer fires twice daily (3 AM and 3 PM UTC, with a random delay up to 1 hour)
  2. It runs certbot renew --quiet
  3. Certbot checks if any cert is within 30 days of expiry. If not, it does nothing
  4. If renewal is needed, certbot requests a new cert from Let's Encrypt
  5. After a successful renewal, the deploy hook at /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh runs automatically and reloads nginx so it picks up the new cert files

HTTP-to-HTTPS redirection is handled by a dedicated nginx server block that listens on port 80 and returns a 301 to the HTTPS equivalent URL.

Current Setup

Item Value
Certificate path /etc/letsencrypt/live/2sigma.io/
Domains covered 2sigma.io, docs.2sigma.io
Issued Feb 18, 2026
Expires May 19, 2026 (auto-renews before then)
Renewal timer certbot-renew.timer (systemd, twice daily)
Timer schedule 03:00 and 15:00 UTC, RandomizedDelaySec=3600
Deploy hook /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Infrastructure Files

File What changed
deploy/nginx/nginx.conf HTTP-to-HTTPS redirect server block; two HTTPS server blocks (app + docs) with ssl_certificate directives; HSTS header
deploy/docker-compose.yml Port 443 exposed; /etc/letsencrypt mounted read-only into the nginx container
deploy/.env.production CORS_ORIGINS updated to include https://2sigma.io
terraform/main.tf Port 443 was already open in the security group (no change needed)

Initial Setup (from scratch)

Use this if you ever need to set up SSL on a new EC2 instance.

Step 1: Verify DNS

Both domains must have A records pointing to the EC2 Elastic IP before certbot can issue a certificate. Let's Encrypt will try to reach your server over the public internet to verify domain ownership.

Record Type Value
2sigma.io A 3.151.25.120
docs.2sigma.io A 3.151.25.120

Step 2: Install certbot

sudo dnf install -y certbot

Step 3: Stop nginx to free port 80

Certbot's standalone mode starts its own temporary web server on port 80 to complete the domain challenge. Nginx must not be holding that port.

cd ~/ai-tutor-backend/deploy && docker compose --env-file .env.production stop nginx

Step 4: Request the certificate

sudo certbot certonly --standalone --non-interactive --agree-tos \
  --email admin@2sigma.io \
  -d 2sigma.io \
  -d docs.2sigma.io

Certbot saves the cert files to /etc/letsencrypt/live/2sigma.io/.

Step 5: Create the deploy hook

This script reloads nginx after each successful renewal so it picks up the new cert without a full restart.

sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null << 'EOF'
#!/bin/bash
cd /home/ec2-user/ai-tutor-backend/deploy
docker compose --env-file .env.production exec -T nginx nginx -s reload
EOF

sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Step 6: Create the systemd timer and service

Create the service unit:

sudo tee /etc/systemd/system/certbot-renew.service > /dev/null << 'EOF'
[Unit]
Description=Certbot renewal

[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet
EOF

Create the timer unit:

sudo tee /etc/systemd/system/certbot-renew.timer > /dev/null << 'EOF'
[Unit]
Description=Run certbot renewal twice daily

[Timer]
OnCalendar=*-*-* 03,15:00:00
RandomizedDelaySec=3600
Persistent=true

[Install]
WantedBy=timers.target
EOF

Step 7: Enable the timer

sudo systemctl daemon-reload && sudo systemctl enable --now certbot-renew.timer

Step 8: Start nginx

cd ~/ai-tutor-backend/deploy && docker compose --env-file .env.production up -d nginx

Step 9: Verify

curl -sf https://2sigma.io/health
curl -sf https://docs.2sigma.io/

Both should return a 200 response. If you see a certificate error, check that the cert paths in nginx.conf match /etc/letsencrypt/live/2sigma.io/.

Adding a New Domain

When adding another subdomain, you must reissue the certificate listing all domains together. Certbot replaces the existing cert rather than adding to it.

Step 1: Stop nginx

cd ~/ai-tutor-backend/deploy && docker compose --env-file .env.production stop nginx

Step 2: Reissue with all domains

sudo certbot certonly --standalone \
  -d 2sigma.io \
  -d docs.2sigma.io \
  -d new.2sigma.io

List every domain

You must include all existing domains plus the new one. Omitting a domain removes it from the certificate.

Step 3: Add the nginx server block

Add a new server block in deploy/nginx/nginx.conf for the new subdomain, pointing at the same cert files.

Step 4: Start nginx

docker compose --env-file .env.production up -d nginx

Checking Certificate Status

Check what certs are installed and when they expire:

sudo certbot certificates

Check that the renewal timer is active:

sudo systemctl list-timers | grep certbot

Test renewal without actually renewing (dry run):

cd ~/ai-tutor-backend/deploy && docker compose --env-file .env.production stop nginx
sudo certbot renew --dry-run
docker compose --env-file .env.production up -d nginx

Dry run requires stopping nginx

The dry run uses the same standalone challenge as the real renewal, so port 80 must be free.

Inspect the cert from outside the server:

echo | openssl s_client -servername 2sigma.io -connect 2sigma.io:443 2>/dev/null \
  | openssl x509 -noout -subject -dates

This shows the subject (domains covered) and the notAfter expiry date without needing SSH access.

Troubleshooting

"Challenge failed" during certbot

Two likely causes:

  • DNS A records don't point to this EC2 instance yet. Verify with dig 2sigma.io or nslookup 2sigma.io.
  • Port 80 is still in use. Nginx must be stopped before running certbot in standalone mode. Check with sudo ss -tlnp | grep :80.

Nginx won't start after cert setup

Check that the ssl_certificate and ssl_certificate_key paths in nginx.conf match the actual cert location:

/etc/letsencrypt/live/2sigma.io/fullchain.pem
/etc/letsencrypt/live/2sigma.io/privkey.pem

Also confirm the /etc/letsencrypt directory is mounted into the nginx container in docker-compose.yml.

Certificate expired / browser shows security warning

First check whether the timer is running:

sudo systemctl status certbot-renew.timer

If the timer is inactive or failed, re-enable it:

sudo systemctl enable --now certbot-renew.timer

Check the renewal log for errors:

sudo cat /var/log/letsencrypt/letsencrypt.log

If the cert is already expired, you'll need to stop nginx, run sudo certbot renew --force-renewal, then start nginx again.

Mixed content warnings in the browser

The browser is loading some resources over HTTP while the page itself is HTTPS. This usually means the frontend is making API calls to http://... instead of using a relative path.

Check that NEXT_PUBLIC_API_BASE_URL is set to /api/v1 (relative) rather than http://3.151.25.120/api/v1 or any other absolute HTTP URL. Relative URLs inherit the protocol of the page, so they work correctly under HTTPS without any changes.