Created
July 28, 2026 20:24
-
-
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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