Skip to content

Instantly share code, notes, and snippets.

@ricalamino
Created July 28, 2026 20:24
Show Gist options
  • Select an option

  • Save ricalamino/16839c78f2eca6004a3d752ca9470a03 to your computer and use it in GitHub Desktop.

Select an option

Save ricalamino/16839c78f2eca6004a3d752ca9470a03 to your computer and use it in GitHub Desktop.
Interactive wizard: Cloudflare Origin CA + nginx reverse proxy - Run on the VPS as root (or with sudo).
#!/usr/bin/env bash
# Interactive wizard: Cloudflare Origin CA + nginx reverse proxy
# Run on the VPS as root (or with sudo).
set -euo pipefail
# ---------------------------------------------------------------------------
# Colors / UI helpers
# ---------------------------------------------------------------------------
if [[ -t 1 ]]; then
C_RESET='\033[0m'
C_BOLD='\033[1m'
C_DIM='\033[2m'
C_CYAN='\033[36m'
C_GREEN='\033[32m'
C_YELLOW='\033[33m'
C_RED='\033[31m'
else
C_RESET='' C_BOLD='' C_DIM='' C_CYAN='' C_GREEN='' C_YELLOW='' C_RED=''
fi
step_n=0
banner() {
echo
echo -e "${C_CYAN}${C_BOLD}╔══════════════════════════════════════════════════════════╗${C_RESET}"
echo -e "${C_CYAN}${C_BOLD}β•‘ nginx + Cloudflare Origin CA deploy wizard β•‘${C_RESET}"
echo -e "${C_CYAN}${C_BOLD}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${C_RESET}"
echo
}
step() {
step_n=$((step_n + 1))
echo
echo -e "${C_BOLD}${C_CYAN}── Step ${step_n}: $1 ──${C_RESET}"
echo
}
info() { echo -e "${C_DIM}$*${C_RESET}"; }
ok() { echo -e "${C_GREEN}βœ” $*${C_RESET}"; }
warn() { echo -e "${C_YELLOW}⚠ $*${C_RESET}"; }
die() { echo -e "${C_RED}βœ– $*${C_RESET}" >&2; exit 1; }
press_next() {
echo
read -r -p "$(echo -e "${C_BOLD}Press Enter for next step…${C_RESET}")"
}
ask() {
# ask VAR "Prompt" [default]
local __var="$1" __prompt="$2" __default="${3-}" __reply
if [[ -n "$__default" ]]; then
read -r -p "$(echo -e "${C_BOLD}${__prompt}${C_RESET} [${C_DIM}${__default}${C_RESET}]: ")" __reply
__reply="${__reply:-$__default}"
else
while true; do
read -r -p "$(echo -e "${C_BOLD}${__prompt}${C_RESET}: ")" __reply
[[ -n "$__reply" ]] && break
warn "Required."
done
fi
printf -v "$__var" '%s' "$__reply"
}
ask_yn() {
# ask_yn VAR "Prompt" [Y|N]
local __var="$1" __prompt="$2" __default="${3:-Y}" __reply __hint
if [[ "$__default" =~ ^[Yy] ]]; then __hint="Y/n"; else __hint="y/N"; fi
read -r -p "$(echo -e "${C_BOLD}${__prompt}${C_RESET} [${__hint}]: ")" __reply
__reply="${__reply:-$__default}"
if [[ "$__reply" =~ ^[Yy] ]]; then
printf -v "$__var" '1'
else
printf -v "$__var" '0'
fi
}
# Read a PEM block from stdin until a blank line after END, or Ctrl-D.
# Usage: read_pem_block VAR "-----BEGIN CERTIFICATE-----"
read_pem_block() {
local __var="$1" __begin="$2" __line __buf="" __started=0
echo -e "${C_DIM}Paste the full PEM below (including BEGIN/END lines)."
echo -e "When finished, press Enter on an empty line (or Ctrl-D).${C_RESET}"
echo
while IFS= read -r __line || [[ -n "$__line" ]]; do
if [[ $__started -eq 0 ]]; then
# skip leading blank lines
[[ -z "$__line" ]] && continue
if [[ "$__line" != *"$__begin"* ]]; then
warn "Expected a line containing: $__begin"
warn "Got: ${__line:0:60}…"
continue
fi
__started=1
fi
__buf+="${__line}"$'\n'
if [[ "$__line" == *"-----END "* ]]; then
# consume one optional trailing blank
break
fi
done
[[ $__started -eq 1 ]] || die "No PEM content received."
printf -v "$__var" '%s' "$__buf"
}
normalize_upstream() {
# Accept: 3555 | localhost:3555 | 127.0.0.1:3555 | http://localhost:3555
local raw="$1" host port
raw="${raw#http://}"
raw="${raw#https://}"
if [[ "$raw" =~ ^[0-9]+$ ]]; then
echo "127.0.0.1:${raw}"
return
fi
if [[ "$raw" == *:* ]]; then
host="${raw%%:*}"
port="${raw##*:}"
[[ "$host" == "localhost" ]] && host="127.0.0.1"
echo "${host}:${port}"
return
fi
die "Invalid upstream '$1'. Use PORT, host:PORT, or localhost:PORT."
}
derive_cert_basename() {
# lectio.alami.no -> lectio ; app.example.com -> app ; example.com -> example
local d="$1"
echo "${d%%.*}"
}
need_root() {
if [[ "${EUID}" -ne 0 ]]; then
die "Run as root (or: sudo $0)"
fi
}
require_nginx() {
command -v nginx >/dev/null 2>&1 || die "nginx not found. Install it first."
[[ -d /etc/nginx/sites-available ]] || die "Missing /etc/nginx/sites-available (Debian/Ubuntu layout expected)."
[[ -d /etc/nginx/sites-enabled ]] || die "Missing /etc/nginx/sites-enabled."
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
banner
need_root
require_nginx
# ── Collect inputs ──────────────────────────────────────────────────────────
step "Configuration"
ask DOMAIN "Domain (FQDN)" ""
[[ "$DOMAIN" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] || die "Doesn't look like a domain: $DOMAIN"
ask UPSTREAM_RAW "App upstream (e.g. localhost:3555 or just 3555)" "localhost:3000"
UPSTREAM="$(normalize_upstream "$UPSTREAM_RAW")"
DEFAULT_CERT_BASE="$(derive_cert_basename "$DOMAIN")"
ask CERT_BASE "Certificate basename (files under /etc/ssl/cloudflare/)" "$DEFAULT_CERT_BASE"
ask BODY_SIZE "client_max_body_size" "100M"
CERT_DIR="/etc/ssl/cloudflare"
CERT_PEM="${CERT_DIR}/${CERT_BASE}.pem"
CERT_KEY="${CERT_DIR}/${CERT_BASE}.key"
SITE_AVAIL="/etc/nginx/sites-available/${DOMAIN}.conf"
SITE_ENABLED="/etc/nginx/sites-enabled/${DOMAIN}.conf"
echo
info "Summary:"
info " domain = ${DOMAIN}"
info " upstream = http://${UPSTREAM}"
info " cert = ${CERT_PEM}"
info " key = ${CERT_KEY}"
info " nginx site = ${SITE_AVAIL}"
echo
ask_yn CONFIRM "Continue?" "Y"
[[ "$CONFIRM" == "1" ]] || die "Aborted."
# ── DNS instructions ───────────────────────────────────────────────────────
step "Cloudflare DNS"
cat <<EOF
In Cloudflare Dashboard β†’ your zone β†’ DNS β†’ Records:
1. Type: A (or AAAA)
2. Name: ${DOMAIN%%.*} (or @ for apex)
3. IPv4: THIS SERVER'S PUBLIC IP
4. Proxy status: Proxied (orange cloud) ← required for CF edge SSL
5. Save
Your public IPv4 (guess):
EOF
PUBLIC_IP="$(curl -4 -fsS --max-time 3 https://ifconfig.me 2>/dev/null || curl -4 -fsS --max-time 3 https://api.ipify.org 2>/dev/null || echo 'unknown')"
echo -e " ${C_BOLD}${PUBLIC_IP}${C_RESET}"
echo
info "SSL/TLS mode in Cloudflare should be Full (strict) once Origin CA is installed."
press_next
# ── Origin Certificate instructions ────────────────────────────────────────
step "Create Cloudflare Origin Certificate"
cat <<EOF
Cloudflare Dashboard β†’ SSL/TLS β†’ Origin Server β†’ Create Certificate:
1. Private key type: RSA (2048) [or whatever you prefer]
2. Hostnames: ${DOMAIN} (add *.$(echo "$DOMAIN" | cut -d. -f2-) if you want wildcard)
3. Validity: 15 years is fine
4. Click Create
You will get TWO blocks:
β€’ Origin Certificate β†’ becomes ${CERT_PEM}
β€’ Private Key β†’ becomes ${CERT_KEY}
${C_YELLOW}Copy them now β€” Cloudflare only shows the private key once.${C_RESET}
EOF
press_next
# ── Paste / write certs ─────────────────────────────────────────────────────
step "Install Origin Certificate (.pem)"
ask_yn HAVE_PEM_FILE "Do you already have the .pem file on disk?" "N"
if [[ "$HAVE_PEM_FILE" == "1" ]]; then
ask PEM_SRC "Path to .pem file"
[[ -f "$PEM_SRC" ]] || die "File not found: $PEM_SRC"
PEM_CONTENT="$(cat "$PEM_SRC")"
else
read_pem_block PEM_CONTENT "BEGIN CERTIFICATE"
fi
step "Install Private Key (.key)"
ask_yn HAVE_KEY_FILE "Do you already have the .key file on disk?" "N"
if [[ "$HAVE_KEY_FILE" == "1" ]]; then
ask KEY_SRC "Path to .key file"
[[ -f "$KEY_SRC" ]] || die "File not found: $KEY_SRC"
KEY_CONTENT="$(cat "$KEY_SRC")"
else
read_pem_block KEY_CONTENT "BEGIN"
fi
mkdir -p "$CERT_DIR"
umask 077
printf '%s' "$PEM_CONTENT" > "$CERT_PEM"
printf '%s' "$KEY_CONTENT" > "$CERT_KEY"
chmod 644 "$CERT_PEM"
chmod 600 "$CERT_KEY"
ok "Wrote ${CERT_PEM}"
ok "Wrote ${CERT_KEY} (mode 600)"
# ── Write nginx conf ────────────────────────────────────────────────────────
step "Write nginx site config"
if [[ -f "$SITE_AVAIL" ]]; then
BACKUP="${SITE_AVAIL}.bak.$(date +%Y%m%d%H%M%S)"
cp -a "$SITE_AVAIL" "$BACKUP"
warn "Existing config backed up β†’ ${BACKUP}"
fi
cat > "$SITE_AVAIL" <<NGINX
server {
listen 80;
listen [::]:80;
server_name ${DOMAIN};
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name ${DOMAIN};
ssl_certificate ${CERT_PEM};
ssl_certificate_key ${CERT_KEY};
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size ${BODY_SIZE};
location / {
proxy_pass http://${UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
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;
proxy_read_timeout 86400;
}
}
NGINX
ok "Wrote ${SITE_AVAIL}"
ln -sfn "$SITE_AVAIL" "$SITE_ENABLED"
ok "Enabled β†’ ${SITE_ENABLED}"
# ── Optional UFW ────────────────────────────────────────────────────────────
step "Firewall (optional)"
if command -v ufw >/dev/null 2>&1; then
ask_yn DO_UFW "Open 80/443 with ufw?" "Y"
if [[ "$DO_UFW" == "1" ]]; then
ufw allow 80/tcp >/dev/null
ufw allow 443/tcp >/dev/null
ok "ufw: allowed 80/tcp and 443/tcp"
info "Run 'ufw status' / 'ufw enable' yourself if not already active."
fi
else
info "ufw not installed β€” skip. Ensure 80/443 are open on your host firewall."
fi
press_next
# ── Test & reload ───────────────────────────────────────────────────────────
step "Test and reload nginx"
echo "Running: nginx -t"
if ! nginx -t; then
die "nginx -t failed. Fix the config before reload. Site left enabled at ${SITE_ENABLED}"
fi
ok "nginx -t passed"
ask_yn DO_RELOAD "Reload nginx now?" "Y"
if [[ "$DO_RELOAD" == "1" ]]; then
systemctl reload nginx 2>/dev/null || nginx -s reload
ok "nginx reloaded"
else
warn "Skipped reload. Run: systemctl reload nginx"
fi
# ── Done ────────────────────────────────────────────────────────────────────
step "Done"
cat <<EOF
${C_GREEN}${C_BOLD}Deploy complete.${C_RESET}
https://${DOMAIN} β†’ http://${UPSTREAM}
Checklist:
β–‘ Cloudflare DNS A/AAAA pointing here, Proxied (orange)
β–‘ SSL/TLS mode = Full (strict)
β–‘ App listening on ${UPSTREAM}
β–‘ Test: curl -I https://${DOMAIN}
Files:
${CERT_PEM}
${CERT_KEY}
${SITE_AVAIL}
${SITE_ENABLED}
EOF
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment