| 1 | #!/usr/bin/env bash |
| 2 | # Deploy Diffinery to your own Cloudflare account and domain from your own machine: the first |
| 3 | # deploy and every update after it. It runs the same steps as .github/workflows/deploy.yml. |
| 4 | # |
| 5 | # scripts/setup.sh # asks for whatever isn't in the environment, then deploys |
| 6 | # scripts/setup.sh --dry-run # checks everything and builds the Worker, but changes nothing |
| 7 | # pnpm setup:cloudflare # the same as scripts/setup.sh |
| 8 | # |
| 9 | # Settings, from the environment or asked for (in a terminal): |
| 10 | # CLOUDFLARE_API_TOKEN API token with the permissions in docs/self-hosting/deploy.md (asked without echo) |
| 11 | # CLOUDFLARE_ACCOUNT_ID the Cloudflare account to deploy to |
| 12 | # DIFFINERY_DOMAIN your instance's hostname, e.g. git.example.com |
| 13 | # BOOTSTRAP_ADMINS comma-separated emails that become site administrators on first sign-in; |
| 14 | # empty keeps the list the deployed Worker has (scripts/bootstrap-admins.sh) |
| 15 | # Optional, from the environment only: |
| 16 | # DIFFINERY_ZONE the Cloudflare zone holding DIFFINERY_DOMAIN (default: its last two labels) |
| 17 | # MAIL_DOMAIN send email from notifications@<MAIL_DOMAIN> (default DIFFINERY_DOMAIN) |
| 18 | # ACCESS_TEAM_DOMAIN your Zero Trust team domain, e.g. example.cloudflareaccess.com, when the token can't read it |
| 19 | # ROLLOUT_WAIT_MINUTES how long to wait for the container rollout (default 10) |
| 20 | # |
| 21 | # Every step is idempotent: it creates only what is missing and keeps your data and secrets, so run |
| 22 | # it again to update. Secrets are never printed. Needs Node.js 22+, pnpm, jq, curl, openssl and Docker. |
| 23 | set -euo pipefail |
| 24 | |
| 25 | ROOT="$(cd "$(dirname "$0")/.." && pwd)" |
| 26 | APP="$ROOT/apps/diffinery" |
| 27 | CF_API="https://api.cloudflare.com/client/v4" |
| 28 | PLACEHOLDER_D1="00000000-0000-0000-0000-000000000000" |
| 29 | DRY_RUN=0 |
| 30 | ASK=1 |
| 31 | |
| 32 | usage() { |
| 33 | cat <<'EOF' |
| 34 | Usage: scripts/setup.sh [--dry-run] [--yes] [--help] |
| 35 | |
| 36 | Deploys Diffinery to your Cloudflare account and domain, or updates a deployment: checks the |
| 37 | tools and your API token, then builds, creates any missing Cloudflare resources (scripts/bootstrap.sh), |
| 38 | deploys the Worker and runner image, sets the Worker's secrets and checks the live site. |
| 39 | |
| 40 | --dry-run Check the tools, the token and the zone (read-only, when a token is given), build, |
| 41 | render apps/diffinery/wrangler.deploy.jsonc with a placeholder D1 id and run |
| 42 | `wrangler deploy --dry-run` on it. Creates, changes and deploys nothing. |
| 43 | --yes, -y Don't ask: take every setting from the environment and fail if one is missing. |
| 44 | --help, -h Show this help. |
| 45 | |
| 46 | Settings (environment variables; asked for in a terminal when missing): |
| 47 | CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, DIFFINERY_DOMAIN (e.g. git.example.com), |
| 48 | BOOTSTRAP_ADMINS (comma-separated emails of the first site administrators; empty keeps the |
| 49 | deployed Worker's list). Optional: DIFFINERY_ZONE, MAIL_DOMAIN, ACCESS_TEAM_DOMAIN. |
| 50 | |
| 51 | Docs: apps/diffinery/docs/self-hosting/deploy.md (served at /docs/self-hosting/deploy). |
| 52 | EOF |
| 53 | } |
| 54 | |
| 55 | for arg in "$@"; do |
| 56 | case "$arg" in |
| 57 | --dry-run) DRY_RUN=1 ;; |
| 58 | --yes | -y) ASK=0 ;; |
| 59 | --help | -h) usage; exit 0 ;; |
| 60 | *) echo "unknown option: $arg (see scripts/setup.sh --help)" >&2; exit 2 ;; |
| 61 | esac |
| 62 | done |
| 63 | [[ -t 0 ]] || ASK=0 |
| 64 | |
| 65 | step() { printf '\n==> %s\n' "$*"; } |
| 66 | info() { printf ' %s\n' "$*"; } |
| 67 | warn() { printf 'warning: %s\n' "$*" >&2; } |
| 68 | die() { printf 'error: %s\n' "$*" >&2; exit 1; } |
| 69 | |
| 70 | # ------------------------------------------------------------------ prerequisites |
| 71 | step "Checking tools" |
| 72 | missing=() |
| 73 | for tool in node pnpm jq curl openssl; do command -v "$tool" >/dev/null 2>&1 || missing+=("$tool"); done |
| 74 | ((${#missing[@]} == 0)) || die "missing: ${missing[*]}. Install Node.js 22+, pnpm 10, jq, curl, openssl and Docker (see the deploy docs)." |
| 75 | node_major="$(node -p 'process.versions.node.split(".")[0]')" |
| 76 | ((node_major >= 22)) || die "Node.js 22 or later is needed (this is $(node --version))" |
| 77 | info "node $(node --version), pnpm $(pnpm --version), $(jq --version), curl, openssl" |
| 78 | if ! command -v docker >/dev/null 2>&1; then |
| 79 | ((DRY_RUN)) || die "docker is missing: the runner container image is built with Docker. Install it and start the daemon." |
| 80 | warn "docker is missing; a real deploy needs it to build the runner image" |
| 81 | elif ! docker info >/dev/null 2>&1; then |
| 82 | ((DRY_RUN)) || die "docker is installed but its daemon isn't running (docker info failed). Start it and run this again." |
| 83 | warn "the Docker daemon isn't running; a real deploy needs it to build the runner image" |
| 84 | else |
| 85 | info "docker $(docker version --format '{{.Server.Version}}' 2>/dev/null || echo '(running)')" |
| 86 | fi |
| 87 | |
| 88 | # ------------------------------------------------------------------ settings |
| 89 | # ask VAR "Prompt" [secret]: keeps a value from the environment, else asks (in a terminal). |
| 90 | ask() { |
| 91 | local var="$1" prompt="$2" secret="${3:-}" value |
| 92 | value="${!var:-}" |
| 93 | if [[ -z "$value" && $ASK == 1 ]]; then |
| 94 | if [[ -n "$secret" ]]; then |
| 95 | read -rsp "$prompt: " value; echo |
| 96 | else |
| 97 | read -rp "$prompt: " value |
| 98 | fi |
| 99 | fi |
| 100 | printf -v "$var" '%s' "$value" |
| 101 | export "${var?}" |
| 102 | } |
| 103 | |
| 104 | step "Settings" |
| 105 | ask CLOUDFLARE_API_TOKEN "Cloudflare API token (input hidden)" secret |
| 106 | ask CLOUDFLARE_ACCOUNT_ID "Cloudflare account ID" |
| 107 | ask DIFFINERY_DOMAIN "Hostname for your instance (e.g. git.example.com)" |
| 108 | if [[ -z "${BOOTSTRAP_ADMINS:-}" && $ASK == 1 ]]; then |
| 109 | read -rp "Emails of the first site administrators, comma-separated (empty keeps the deployed list): " BOOTSTRAP_ADMINS |
| 110 | fi |
| 111 | export BOOTSTRAP_ADMINS="${BOOTSTRAP_ADMINS:-}" |
| 112 | |
| 113 | DIFFINERY_DOMAIN="$(tr '[:upper:]' '[:lower:]' <<<"$DIFFINERY_DOMAIN")" |
| 114 | [[ -n "$DIFFINERY_DOMAIN" ]] || die "set DIFFINERY_DOMAIN to your instance's hostname, for example git.example.com" |
| 115 | [[ "$DIFFINERY_DOMAIN" =~ ^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,63}$ ]] || die "DIFFINERY_DOMAIN must be a bare hostname such as git.example.com, without https:// or a path (got \"$DIFFINERY_DOMAIN\")" |
| 116 | ZONE="${DIFFINERY_ZONE:-$(awk -F. '{print $(NF-1)"."$NF}' <<<"$DIFFINERY_DOMAIN")}" |
| 117 | [[ "$DIFFINERY_DOMAIN" == "$ZONE" || "$DIFFINERY_DOMAIN" == *".$ZONE" ]] || die "DIFFINERY_ZONE ($ZONE) doesn't hold $DIFFINERY_DOMAIN" |
| 118 | MAIL_FROM="notifications@${MAIL_DOMAIN:-$DIFFINERY_DOMAIN}" |
| 119 | if [[ -n "$BOOTSTRAP_ADMINS" ]]; then |
| 120 | IFS=, read -ra entries <<<"$BOOTSTRAP_ADMINS" |
| 121 | for e in "${entries[@]}"; do |
| 122 | e="$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$e")" |
| 123 | [[ -z "$e" || "$e" =~ ^[^[:space:]@]+@[^[:space:]@]+$ ]] || die "BOOTSTRAP_ADMINS takes comma-separated email addresses; \"$e\" isn't one" |
| 124 | done |
| 125 | fi |
| 126 | export DIFFINERY_DOMAIN |
| 127 | [[ -z "${DIFFINERY_ZONE:-}" ]] || export DIFFINERY_ZONE |
| 128 | info "hostname: https://$DIFFINERY_DOMAIN (route $DIFFINERY_DOMAIN/* in zone $ZONE)" |
| 129 | info "email: $MAIL_FROM" |
| 130 | info "admins: $(if [[ -n "$BOOTSTRAP_ADMINS" ]]; then tr ',' '\n' <<<"$BOOTSTRAP_ADMINS" | grep -c '[^[:space:]]' | sed 's/$/ address(es) from BOOTSTRAP_ADMINS/'; else echo "keep the deployed Worker's list (none on a new instance)"; fi)" |
| 131 | if ((DRY_RUN)); then info "mode: dry run (changes nothing)"; else info "mode: deploy"; fi |
| 132 | |
| 133 | # ------------------------------------------------------------------ token and zone |
| 134 | cf_get() { curl -sS --retry 3 --retry-delay 2 "$CF_API$1" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"; } |
| 135 | errors_of() { jq -r '[.errors[]? | "\(.code): \(.message)"] | join("; ")' <<<"$1" 2>/dev/null || echo "no answer"; } |
| 136 | |
| 137 | if [[ -z "$CLOUDFLARE_API_TOKEN" || -z "$CLOUDFLARE_ACCOUNT_ID" ]]; then |
| 138 | ((DRY_RUN)) || die "set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID (Step 2 of the deploy docs says which permissions the token needs)" |
| 139 | step "Cloudflare token" |
| 140 | warn "no CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID: the dry run skips the token and zone checks" |
| 141 | else |
| 142 | [[ "$CLOUDFLARE_ACCOUNT_ID" =~ ^[0-9a-f]{32}$ ]] || die "CLOUDFLARE_ACCOUNT_ID should be 32 hex characters (the dashboard's Account ID), not \"$CLOUDFLARE_ACCOUNT_ID\"" |
| 143 | step "Cloudflare token" |
| 144 | # User tokens verify at /user/tokens/verify, account-owned tokens at /accounts/{id}/tokens/verify. |
| 145 | verify="$(cf_get /user/tokens/verify || true)" |
| 146 | if ! jq -e '.success == true and .result.status == "active"' >/dev/null 2>&1 <<<"$verify"; then |
| 147 | verify="$(cf_get "/accounts/$CLOUDFLARE_ACCOUNT_ID/tokens/verify" || true)" |
| 148 | fi |
| 149 | jq -e '.success == true and .result.status == "active"' >/dev/null 2>&1 <<<"$verify" || die "Cloudflare doesn't accept the API token: $(errors_of "$verify")" |
| 150 | info "the token is valid and active" |
| 151 | zones="$(cf_get "/zones?name=$ZONE&account.id=$CLOUDFLARE_ACCOUNT_ID" || true)" |
| 152 | jq -e '.success == true' >/dev/null 2>&1 <<<"$zones" || die "couldn't look up the zone $ZONE: $(errors_of "$zones")" |
| 153 | zone_status="$(jq -r '.result[0].status // empty' <<<"$zones")" |
| 154 | [[ -n "$zone_status" ]] || die "the zone $ZONE isn't in account $CLOUDFLARE_ACCOUNT_ID, or the token can't see it. Add the domain to the account, give the token Zone → Zone → Read on it, or set DIFFINERY_ZONE if the zone that holds $DIFFINERY_DOMAIN is another one." |
| 155 | [[ "$zone_status" == active ]] || warn "the zone $ZONE is $zone_status, not active: the route serves nothing until its nameservers point at Cloudflare" |
| 156 | info "zone $ZONE is in the account ($zone_status)" |
| 157 | fi |
| 158 | |
| 159 | cd "$ROOT" |
| 160 | |
| 161 | # ------------------------------------------------------------------ build |
| 162 | step "Installing and building" |
| 163 | pnpm install --frozen-lockfile |
| 164 | pnpm -r --if-present build |
| 165 | |
| 166 | if ((DRY_RUN)); then |
| 167 | # ---------------------------------------------------------------- dry run |
| 168 | step "Bootstrap administrators" |
| 169 | # Reads the deployed Worker's list when there are credentials (a read, never a write). |
| 170 | ADMINS="$("$ROOT/scripts/bootstrap-admins.sh")" |
| 171 | |
| 172 | step "Rendering the deploy config (placeholder D1 id) and bundling the Worker" |
| 173 | # A placeholder runner image: the dry run builds no container image (that needs Docker and the registry). |
| 174 | RUNNER_IMAGE="registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID:-00000000000000000000000000000000}/diffinery-runner:dry-run" \ |
| 175 | node "$ROOT/scripts/render-config.mjs" --d1 "$PLACEHOLDER_D1" ${ACCESS_TEAM_DOMAIN:+--team "$ACCESS_TEAM_DOMAIN"} \ |
| 176 | --public-url "https://$DIFFINERY_DOMAIN" ${DIFFINERY_ZONE:+--zone "$DIFFINERY_ZONE"} --mail-from "$MAIL_FROM" --admins "$ADMINS" |
| 177 | OUT="$(mktemp -d "${TMPDIR:-/tmp}/diffinery-dry-run.XXXXXX")" |
| 178 | (cd "$APP" && pnpm exec wrangler deploy --dry-run -c wrangler.deploy.jsonc --outdir "$OUT") |
| 179 | |
| 180 | CFG="$(tail -n +2 "$APP/wrangler.deploy.jsonc")" |
| 181 | step "Dry run summary" |
| 182 | info "route: $(jq -r '.routes | map("\(.pattern) (zone \(.zone_name))") | join(", ")' <<<"$CFG")" |
| 183 | info "PUBLIC_URL: $(jq -r .vars.PUBLIC_URL <<<"$CFG")" |
| 184 | info "MAIL_FROM: $(jq -r .vars.MAIL_FROM <<<"$CFG")" |
| 185 | info "BOOTSTRAP_ADMINS: $(jq -r '.vars.BOOTSTRAP_ADMINS | split(",") | map(select(length > 0)) | length' <<<"$CFG") address(es)" |
| 186 | info "bundle: $OUT" |
| 187 | if [[ "$DIFFINERY_DOMAIN" != diffinery.com ]] && grep -q 'diffinery\.com' <<<"$CFG"; then |
| 188 | warn "the rendered config still mentions diffinery.com: $(grep -o '"[^"]*diffinery\.com[^"]*"' <<<"$CFG" | sort -u | tr '\n' ' ')" |
| 189 | fi |
| 190 | echo |
| 191 | echo "Dry run passed: apps/diffinery/wrangler.deploy.jsonc is valid and the Worker bundles. Nothing was" |
| 192 | echo "created or deployed. Run scripts/setup.sh without --dry-run to deploy to https://$DIFFINERY_DOMAIN." |
| 193 | exit 0 |
| 194 | fi |
| 195 | |
| 196 | # ------------------------------------------------------------------ deploy |
| 197 | wrangler() { (cd "$APP" && pnpm exec wrangler "$@"); } |
| 198 | OUTPUTS="$(mktemp "${TMPDIR:-/tmp}/diffinery-setup.XXXXXX")" |
| 199 | trap 'rm -f "$OUTPUTS"' EXIT |
| 200 | |
| 201 | step "Runner image (built only when the runner changed)" |
| 202 | image_log="$(mktemp "${TMPDIR:-/tmp}/diffinery-image.XXXXXX")" |
| 203 | env -u GITHUB_ENV "$ROOT/scripts/runner-image.sh" | tee "$image_log" |
| 204 | RUNNER_IMAGE="$(tail -n 1 "$image_log")" |
| 205 | rm -f "$image_log" |
| 206 | [[ "$RUNNER_IMAGE" == registry.cloudflare.com/* ]] || die "scripts/runner-image.sh didn't print the runner image" |
| 207 | export RUNNER_IMAGE |
| 208 | |
| 209 | step "Creating or updating Cloudflare resources (scripts/bootstrap.sh)" |
| 210 | GITHUB_OUTPUT="$OUTPUTS" "$ROOT/scripts/bootstrap.sh" |
| 211 | ACCESS_TEAM="$(sed -n 's/^access_team=//p' "$OUTPUTS" | tail -n 1)" |
| 212 | |
| 213 | step "Deploying the Worker and runner image" |
| 214 | wrangler deploy -c wrangler.deploy.jsonc |
| 215 | |
| 216 | step "Waiting for new containers to get the new image" |
| 217 | "$ROOT/scripts/wait-container-rollout.sh" |
| 218 | |
| 219 | step "Worker secrets (generated on the first deploy; never printed)" |
| 220 | "$ROOT/scripts/ensure-secrets.sh" |
| 221 | |
| 222 | if [[ -z "$ACCESS_TEAM" ]]; then |
| 223 | step "Finishing the Access setup (first deploy)" |
| 224 | : >"$OUTPUTS" |
| 225 | GITHUB_OUTPUT="$OUTPUTS" "$ROOT/scripts/bootstrap.sh" |
| 226 | wrangler deploy -c wrangler.deploy.jsonc --containers-rollout none |
| 227 | ACCESS_TEAM="$(sed -n 's/^access_team=//p' "$OUTPUTS" | tail -n 1)" |
| 228 | fi |
| 229 | |
| 230 | # ------------------------------------------------------------------ checks |
| 231 | step "Checking https://$DIFFINERY_DOMAIN" |
| 232 | code="" |
| 233 | for _ in $(seq 1 30); do |
| 234 | code="$(curl -s -o /dev/null -w '%{http_code}' "https://$DIFFINERY_DOMAIN/healthz" || true)" |
| 235 | [[ "$code" == 200 ]] && break |
| 236 | sleep 10 |
| 237 | done |
| 238 | [[ "$code" == 200 ]] || die "https://$DIFFINERY_DOMAIN/healthz did not return 200 (last: ${code:-no answer}). Check that the $DIFFINERY_DOMAIN DNS record is proxied (orange cloud); a new certificate can take a few minutes, so run this again." |
| 239 | info "/healthz -> 200" |
| 240 | curl -sS "https://$DIFFINERY_DOMAIN/login" | grep -q "Sign in with a passkey" || die "/login is not the Diffinery sign-in page: check DIFFINERY_DOMAIN, DIFFINERY_ZONE and the route in apps/diffinery/wrangler.deploy.jsonc" |
| 241 | info "/login serves the passkey sign-in page" |
| 242 | loc="$(curl -sS -o /dev/null -w '%{http_code} %{redirect_url}' "https://$DIFFINERY_DOMAIN/auth/access" || true)" |
| 243 | if [[ "$loc" == 302\ https://*.cloudflareaccess.com/* ]]; then |
| 244 | info "/auth/access is behind Cloudflare Access" |
| 245 | else |
| 246 | warn "/auth/access is not behind Cloudflare Access yet ($loc), so email recovery and the first admin sign-in won't work. Set ACCESS_TEAM_DOMAIN and run this again." |
| 247 | fi |
| 248 | |
| 249 | echo |
| 250 | echo "Diffinery is live at https://$DIFFINERY_DOMAIN." |
| 251 | echo "Sign in as the first administrator at https://$DIFFINERY_DOMAIN/auth/access with an email in BOOTSTRAP_ADMINS," |
| 252 | echo "then add a passkey. Run scripts/setup.sh again at any time to update." |
| 253 | [[ -n "$ACCESS_TEAM" ]] || warn "the Access team domain is still unknown; set ACCESS_TEAM_DOMAIN (Zero Trust → Settings → Custom pages shows it) and run this again" |