This document captures the current server setup and the SSL automation design using Caddy while keeping the same server IP (95.216.207.99).
- Server managed by Laravel Forge.
- IP:
95.216.207.99 - Nginx keeps handling HTTP on
:80. - Caddy handles HTTPS on
:443with On-Demand TLS. - Caddy reverse proxies HTTPS traffic to Nginx on
127.0.0.1:80. - Default app catches all hostnames pointing to this IP (intentional).
assets.shuttlemath.com
- Static assets site (games/assets).
- Nginx-only app in Forge.
shuttlemath.com
- Node.js app.
- Deployment script:
cd /home/forge/shuttlemath.com
git pull origin $FORGE_SITE_BRANCH
npm ci
npm run build
pm2 delete shuttlemath || true
pm2 start npm --name "shuttlemath" -- start- Nginx proxies to
http://localhost:3000.
default
- Node.js app (catch-all for domains on this IP).
- Deployment script:
cd /home/forge/default
git fetch
git reset --hard origin/$FORGE_SITE_BRANCH
npm ci
npm run restart- Nginx default server proxies to
http://127.0.0.1:8080.
Forge/Nginx catch-all can route unknown hosts on HTTP, but HTTPS certificates must still be issued per hostname.
Caddy On-Demand TLS issues certs at first HTTPS hit (if approved by ask endpoint), which gives low-effort SSL coverage for newly pointed domains.
- New domain points
Ato95.216.207.99. - HTTPS request hits Caddy (
:443). - Caddy calls local
askendpoint (127.0.0.1:9123) to decide if cert issuance is allowed. - If allowed, Caddy obtains/serves cert and proxies request to Nginx
127.0.0.1:80. - Nginx default app handles host logic and upstream proxying.
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddyCreate /opt/caddy-ask/ask.mjs:
import http from 'node:http';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
const PORT = 9123;
const SERVER_IPS = new Set(['95.216.207.99']); // Add IPv6 too if used
const CACHE_TTL_MS = 5 * 60 * 1000;
const cache = new Map();
function validDomain(d) {
if (!d || d.length > 253 || isIP(d)) return false;
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i.test(d);
}
async function pointsToUs(domain) {
try {
const addrs = await lookup(domain, { all: true, verbatim: true });
return addrs.some(a => SERVER_IPS.has(a.address));
} catch {
return false;
}
}
http.createServer(async (req, res) => {
const u = new URL(req.url, 'http://127.0.0.1');
if (u.pathname !== '/allow') return res.writeHead(404).end();
const domain = (u.searchParams.get('domain') || '').toLowerCase().trim();
if (!validDomain(domain)) return res.writeHead(403).end('invalid');
const now = Date.now();
const hit = cache.get(domain);
if (hit && hit.exp > now) return res.writeHead(hit.ok ? 200 : 403).end();
const ok = await pointsToUs(domain);
cache.set(domain, { ok, exp: now + CACHE_TTL_MS });
return res.writeHead(ok ? 200 : 403).end(ok ? 'ok' : 'deny');
}).listen(PORT, '127.0.0.1', () => {
console.log(`ask service listening on 127.0.0.1:${PORT}`);
});Create /etc/systemd/system/caddy-ask.service:
[Unit]
Description=Caddy On-Demand TLS ask endpoint
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=www-data
Group=www-data
ExecStart=/usr/bin/node /opt/caddy-ask/ask.mjs
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.targetEnable:
sudo systemctl daemon-reload
sudo systemctl enable --now caddy-ask
sudo systemctl status caddy-ask --no-pagerUse /etc/caddy/Caddyfile:
{
email you@shuttlemath.com
auto_https disable_redirects
on_demand_tls {
ask http://127.0.0.1:9123/allow
}
}
:443 {
tls {
on_demand
issuer acme {
disable_http_challenge
}
}
reverse_proxy 127.0.0.1:80
}Validate and confirm it listens only on :443:
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile --pretty | jq '.apps.http.servers|to_entries[]|{name:.key,listen:.value.listen}'Expected:
{
"name": "srv0",
"listen": [
":443"
]
}Start Caddy:
sudo systemctl enable --now caddy
sudo systemctl restart caddy
sudo systemctl status caddy --no-pagerIn the existing default app server { ... } block (port 80), keep redirect before location /:
if ($redirect_host != "") {
return 301 https://$redirect_host$request_uri;
}This is the correct replacement for:
return 301 $scheme://$redirect_host$request_uri;Do not add a blanket return 301 https://$host$request_uri; in this server unless you implement loop-safe conditions.
sudo ss -ltnp | egrep ':80|:443'
sudo systemctl status caddy --no-pager
sudo systemctl status caddy-ask --no-pager
curl -vkI https://h.shuttlemath.com
journalctl -u caddy -n 100 --no-pagerExpected listener state:
:80->nginx:443->caddy
- Caddy says port 80 in use
- Caddy config still contains
:80or auto-redirect behavior not disabled. - Confirm Caddyfile exactly matches this doc and validate/adapt output shows only
:443.
- Cert not issued for new domain
- Domain not resolving to
95.216.207.99yet. askendpoint denies domain.- DNS propagation delay.
- Let’s Encrypt rate limits.
- Check active Caddy unit options
sudo systemctl cat caddyEnsure startup uses /etc/caddy/Caddyfile and no unexpected override.
sudo systemctl disable --now caddyNginx on :80 continues serving HTTP exactly as before.