CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
bootstrap-hetzner.sh
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| fc1e337 | 1 | #!/usr/bin/env bash |
| 2 | # ============================================================================= | |
| 3 | # Gluecron one-shot Hetzner bootstrap. | |
| 4 | # | |
| 5 | # Run ONCE on a fresh (or partially-set-up) Hetzner box, via the web console, | |
| 6 | # as root. Idempotent: safe to re-run. Won't touch existing crontech systemd | |
| 7 | # units, will append gluecron's entries instead. | |
| 8 | # | |
| 9 | # Usage (paste this ONE line in Hetzner Console as root): | |
| 10 | # curl -sSL https://raw.githubusercontent.com/ccantynz-alt/Gluecron.com/main/scripts/bootstrap-hetzner.sh \ | |
| 11 | # | bash -s -- "ssh-ed25519 AAAA... your@laptop" | |
| 12 | # | |
| 13 | # What it does (every step is idempotent + verbose): | |
| 14 | # 1. Authorise the supplied SSH pubkey on root@ (so you can SSH from laptop) | |
| 15 | # 2. Re-enable PasswordAuthentication as a safety fallback | |
| 16 | # 3. Install Bun, Postgres, Caddy, git if missing | |
| 17 | # 4. Create local Postgres DB + user 'gluecron' if missing | |
| 18 | # 5. Clone /opt/gluecron from main if missing, or git pull if present | |
| 19 | # 6. Write /etc/gluecron.env with DATABASE_URL pointing at local Postgres | |
| 20 | # 7. Bun install + run migrations | |
| 21 | # 8. Write systemd unit for gluecron on port 3010 | |
| 22 | # 9. Append gluecron + www.gluecron blocks to Caddyfile (if missing) | |
| 23 | # 10. Reload Caddy + start gluecron | |
| 24 | # 11. Smoke test /healthz | |
| 25 | # ============================================================================= | |
| 26 | ||
| 27 | set -euo pipefail | |
| 28 | PUBKEY="${1:-}" | |
| 29 | PORT="${PORT:-3010}" | |
| 30 | SITE_DOMAIN="${SITE_DOMAIN:-gluecron.com}" | |
| 31 | DB_USER="gluecron" | |
| 32 | DB_NAME="gluecron" | |
| 33 | DB_PASS="$(openssl rand -hex 16)" | |
| 34 | ||
| 35 | say() { echo ""; echo "==> $*"; } | |
| 36 | ok() { echo " ✓ $*"; } | |
| 37 | warn() { echo " ⚠ $*"; } | |
| 38 | ||
| 39 | # ──────────────────────────────────────────────────────────────────────────── | |
| 40 | # 1. Authorise SSH pubkey | |
| 41 | # ──────────────────────────────────────────────────────────────────────────── | |
| 42 | say "[1/11] Authorising SSH key on root@" | |
| 43 | mkdir -p /root/.ssh | |
| 44 | chmod 700 /root/.ssh | |
| 45 | touch /root/.ssh/authorized_keys | |
| 46 | chmod 600 /root/.ssh/authorized_keys | |
| 47 | if [ -n "$PUBKEY" ]; then | |
| 48 | if grep -qF "$PUBKEY" /root/.ssh/authorized_keys 2>/dev/null; then | |
| 49 | ok "SSH key already authorised" | |
| 50 | else | |
| 51 | echo "$PUBKEY" >> /root/.ssh/authorized_keys | |
| 52 | ok "SSH key added" | |
| 53 | fi | |
| 54 | else | |
| 55 | warn "No PUBKEY argument — skipping SSH key install" | |
| 56 | fi | |
| 57 | ||
| 58 | # ──────────────────────────────────────────────────────────────────────────── | |
| 59 | # 2. Re-enable PasswordAuthentication (safety fallback) | |
| 60 | # ──────────────────────────────────────────────────────────────────────────── | |
| 61 | say "[2/11] Configuring sshd (allow both key + password as fallback)" | |
| 62 | sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config | |
| 63 | sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config | |
| 64 | sed -i 's/^#*PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config | |
| 65 | mkdir -p /etc/ssh/sshd_config.d | |
| 66 | cat > /etc/ssh/sshd_config.d/99-gluecron-auth.conf <<EOF | |
| 67 | PasswordAuthentication yes | |
| 68 | PubkeyAuthentication yes | |
| 69 | PermitRootLogin yes | |
| 70 | EOF | |
| 71 | systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true | |
| 72 | ok "sshd reloaded — both auth methods enabled" | |
| 73 | ||
| 74 | # ──────────────────────────────────────────────────────────────────────────── | |
| 75 | # 3. Install dependencies if missing | |
| 76 | # ──────────────────────────────────────────────────────────────────────────── | |
| 77 | say "[3/11] Ensuring git, curl, postgresql, caddy, bun are installed" | |
| 78 | export DEBIAN_FRONTEND=noninteractive | |
| 79 | apt-get update -qq | |
| 80 | ||
| ee80a15 | 81 | for pkg in git curl ca-certificates gnupg debian-keyring debian-archive-keyring apt-transport-https openssl unzip xz-utils tar; do |
| fc1e337 | 82 | dpkg -l | grep -qw "$pkg" || apt-get install -y -qq "$pkg" |
| 83 | done | |
| 84 | ok "base packages installed" | |
| 85 | ||
| 86 | # Postgres | |
| 87 | if ! command -v psql >/dev/null 2>&1; then | |
| 88 | apt-get install -y -qq postgresql postgresql-contrib | |
| 89 | systemctl enable --now postgresql | |
| 90 | ok "postgres installed + started" | |
| 91 | else | |
| 92 | ok "postgres already installed" | |
| 93 | fi | |
| 94 | ||
| 95 | # Caddy (skip install if already there — likely from crontech setup) | |
| 96 | if ! command -v caddy >/dev/null 2>&1; then | |
| 97 | curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg | |
| 98 | curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list | |
| 99 | apt-get update -qq | |
| 100 | apt-get install -y -qq caddy | |
| 101 | systemctl enable --now caddy | |
| 102 | ok "caddy installed + started" | |
| 103 | else | |
| 104 | ok "caddy already installed (likely from crontech)" | |
| 105 | fi | |
| 106 | ||
| 107 | # Bun | |
| 108 | if [ ! -x /root/.bun/bin/bun ]; then | |
| 109 | curl -fsSL https://bun.sh/install | bash >/dev/null | |
| 110 | ok "bun installed" | |
| 111 | else | |
| 112 | ok "bun already installed" | |
| 113 | fi | |
| 114 | export PATH="/root/.bun/bin:$PATH" | |
| 115 | ok "bun version: $(/root/.bun/bin/bun --version 2>/dev/null || echo unknown)" | |
| 116 | ||
| 117 | # ──────────────────────────────────────────────────────────────────────────── | |
| 118 | # 4. Local Postgres DB + user | |
| 119 | # ──────────────────────────────────────────────────────────────────────────── | |
| 120 | say "[4/11] Creating local Postgres DB + user (idempotent)" | |
| 121 | if sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" | grep -q 1; then | |
| 122 | ok "postgres user '${DB_USER}' already exists" | |
| 123 | else | |
| 124 | sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';" | |
| 125 | ok "postgres user '${DB_USER}' created" | |
| 126 | fi | |
| 127 | if sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1; then | |
| 128 | ok "postgres database '${DB_NAME}' already exists" | |
| 129 | else | |
| 130 | sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};" | |
| 131 | ok "postgres database '${DB_NAME}' created" | |
| 132 | fi | |
| 133 | ||
| 134 | # Read existing DB password if env file already has one (don't overwrite) | |
| 135 | EXISTING_DB_URL="" | |
| 136 | if [ -f /etc/gluecron.env ] && grep -q '^DATABASE_URL=' /etc/gluecron.env; then | |
| 137 | EXISTING_DB_URL=$(grep '^DATABASE_URL=' /etc/gluecron.env | cut -d= -f2- | tr -d '"') | |
| 138 | ok "reusing existing DATABASE_URL from /etc/gluecron.env" | |
| 139 | fi | |
| 140 | DATABASE_URL="${EXISTING_DB_URL:-postgresql://${DB_USER}:${DB_PASS}@127.0.0.1:5432/${DB_NAME}}" | |
| 141 | ||
| 142 | # If we're using a NEW password we just generated, sync it to the DB user | |
| 143 | if [ -z "$EXISTING_DB_URL" ]; then | |
| 144 | sudo -u postgres psql -c "ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';" | |
| 145 | fi | |
| 146 | ||
| 147 | # ──────────────────────────────────────────────────────────────────────────── | |
| 148 | # 5. Clone or update /opt/gluecron | |
| 149 | # ──────────────────────────────────────────────────────────────────────────── | |
| 150 | say "[5/11] Setting up /opt/gluecron source tree" | |
| 151 | mkdir -p /opt | |
| 152 | if [ ! -d /opt/gluecron/.git ]; then | |
| 153 | git clone https://github.com/ccantynz-alt/Gluecron.com.git /opt/gluecron | |
| 154 | ok "cloned gluecron" | |
| 155 | else | |
| 156 | cd /opt/gluecron | |
| 157 | git fetch --prune origin main | |
| 158 | git reset --hard origin/main | |
| 159 | ok "pulled latest main" | |
| 160 | fi | |
| 161 | cd /opt/gluecron | |
| 162 | ||
| 163 | # ──────────────────────────────────────────────────────────────────────────── | |
| 164 | # 6. Write /etc/gluecron.env (preserve existing where present) | |
| 165 | # ──────────────────────────────────────────────────────────────────────────── | |
| 166 | say "[6/11] Writing /etc/gluecron.env" | |
| 167 | mkdir -p /data/repos | |
| 168 | chmod 755 /data/repos | |
| 169 | umask 077 | |
| 170 | cat > /etc/gluecron.env <<EOF | |
| 171 | DATABASE_URL=${DATABASE_URL} | |
| 172 | APP_BASE_URL=https://${SITE_DOMAIN} | |
| 173 | SITE_ADMIN_USERNAME=${SITE_ADMIN_USERNAME:-ccantynz} | |
| 174 | GIT_REPOS_PATH=/data/repos | |
| 175 | PORT=${PORT} | |
| 176 | DEMO_SEED_ON_BOOT=1 | |
| 177 | EMAIL_PROVIDER=log | |
| 178 | EMAIL_FROM="gluecron <no-reply@${SITE_DOMAIN}>" | |
| 179 | NODE_ENV=production | |
| 180 | EOF | |
| 181 | chmod 600 /etc/gluecron.env | |
| 182 | umask 022 | |
| 183 | ok "/etc/gluecron.env written (chmod 600)" | |
| 184 | ||
| 185 | # ──────────────────────────────────────────────────────────────────────────── | |
| 186 | # 7. Bun install + run migrations | |
| 187 | # ──────────────────────────────────────────────────────────────────────────── | |
| 188 | say "[7/11] bun install + db:migrate" | |
| 189 | /root/.bun/bin/bun install --frozen-lockfile | |
| 190 | set -a; source /etc/gluecron.env; set +a | |
| 191 | /root/.bun/bin/bun run src/db/migrate.ts || warn "migrations command failed — may already be applied" | |
| 192 | ok "deps + migrations done" | |
| 193 | ||
| 194 | # ──────────────────────────────────────────────────────────────────────────── | |
| 195 | # 8. systemd unit for gluecron | |
| 196 | # ──────────────────────────────────────────────────────────────────────────── | |
| f764c07 | 197 | # Block N2 — `Type=notify` so `systemctl restart gluecron` blocks until the |
| 198 | # new process has called sd_notify(READY=1) (wired in src/lib/systemd-notify.ts | |
| 199 | # from src/index.ts). That eliminates the post-restart sleep-and-poll loop in | |
| 200 | # the deploy workflow. | |
| 201 | # | |
| 202 | # Block N2 — Bun-compiled single-binary path. If `/opt/gluecron/.next/gluecron-server` | |
| 203 | # exists (built by the deploy workflow), prefer it (~10x faster cold start vs | |
| 204 | # `bun run src/index.ts`). Falls back to interpreted Bun for safety so a stale | |
| 205 | # bootstrap still produces a runnable unit on a brand-new box. | |
| fc1e337 | 206 | say "[8/11] Writing /etc/systemd/system/gluecron.service" |
| 207 | BUN_BIN=/root/.bun/bin/bun | |
| f764c07 | 208 | COMPILED_BIN=/opt/gluecron/.next/gluecron-server |
| 209 | mkdir -p /opt/gluecron/.next | |
| 210 | if [ -x "$COMPILED_BIN" ]; then | |
| 211 | EXEC_START="$COMPILED_BIN" | |
| 212 | else | |
| 213 | EXEC_START="${BUN_BIN} run src/index.ts" | |
| 214 | fi | |
| fc1e337 | 215 | cat > /etc/systemd/system/gluecron.service <<EOF |
| 216 | [Unit] | |
| 217 | Description=Gluecron — AI-native code intelligence platform | |
| 218 | After=network-online.target postgresql.service | |
| 219 | Wants=network-online.target | |
| 220 | ||
| 221 | [Service] | |
| f764c07 | 222 | Type=notify |
| 223 | NotifyAccess=main | |
| fc1e337 | 224 | User=root |
| 225 | WorkingDirectory=/opt/gluecron | |
| 226 | EnvironmentFile=/etc/gluecron.env | |
| f764c07 | 227 | ExecStart=${EXEC_START} |
| fc1e337 | 228 | Restart=always |
| 229 | RestartSec=5 | |
| f764c07 | 230 | # Block N2 — give the new process up to 30s to call sd_notify(READY=1). |
| 231 | # Default is 90s; we lower it so a hung-on-startup deploy fails fast. | |
| 232 | TimeoutStartSec=30 | |
| fc1e337 | 233 | StandardOutput=journal |
| 234 | StandardError=journal | |
| 235 | SyslogIdentifier=gluecron | |
| 236 | LimitNOFILE=65536 | |
| 237 | ||
| 238 | [Install] | |
| 239 | WantedBy=multi-user.target | |
| 240 | EOF | |
| 241 | systemctl daemon-reload | |
| 242 | systemctl enable gluecron >/dev/null | |
| 243 | systemctl restart gluecron | |
| f764c07 | 244 | ok "gluecron systemd unit installed + started (Type=notify, ExecStart=${EXEC_START})" |
| fc1e337 | 245 | |
| 246 | # ──────────────────────────────────────────────────────────────────────────── | |
| 247 | # 9. Append gluecron + www.gluecron to Caddyfile if missing | |
| 248 | # ──────────────────────────────────────────────────────────────────────────── | |
| 249 | say "[9/11] Caddy reverse-proxy config for ${SITE_DOMAIN}" | |
| 250 | CADDYFILE=/etc/caddy/Caddyfile | |
| 251 | touch "$CADDYFILE" | |
| 252 | if grep -qE "^${SITE_DOMAIN}\s*\{" "$CADDYFILE"; then | |
| 253 | ok "${SITE_DOMAIN} block already in Caddyfile — leaving as-is" | |
| 254 | else | |
| 255 | cat >> "$CADDYFILE" <<EOF | |
| 256 | ||
| 257 | ${SITE_DOMAIN} { | |
| 258 | encode zstd gzip | |
| 259 | reverse_proxy localhost:${PORT} | |
| 260 | } | |
| 261 | ||
| 262 | www.${SITE_DOMAIN} { | |
| 263 | redir https://${SITE_DOMAIN}{uri} permanent | |
| 264 | } | |
| 265 | EOF | |
| 266 | ok "appended ${SITE_DOMAIN} block to Caddyfile" | |
| 267 | fi | |
| 268 | ||
| 269 | if caddy validate --config "$CADDYFILE" --adapter caddyfile 2>&1 | grep -qi "valid"; then | |
| 270 | systemctl reload caddy || systemctl restart caddy | |
| 271 | ok "caddy reloaded" | |
| 272 | else | |
| 273 | warn "caddy validate warned — check: caddy validate --config $CADDYFILE" | |
| 274 | fi | |
| 275 | ||
| 276 | # ──────────────────────────────────────────────────────────────────────────── | |
| 277 | # 10. Wait + smoke test | |
| 278 | # ──────────────────────────────────────────────────────────────────────────── | |
| 279 | say "[10/11] Waiting for /healthz to come up (60s)" | |
| 280 | for i in $(seq 1 12); do | |
| 281 | code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/healthz" || echo 000) | |
| 282 | if [ "$code" = "200" ]; then | |
| 283 | ok "gluecron is live on http://localhost:${PORT} (attempt $i)" | |
| 284 | break | |
| 285 | fi | |
| 286 | sleep 5 | |
| 287 | done | |
| 288 | if [ "$code" != "200" ]; then | |
| 289 | warn "healthz did not respond 200 after 60s. Logs: journalctl -u gluecron -n 50 --no-pager" | |
| 290 | fi | |
| 291 | ||
| 292 | # ──────────────────────────────────────────────────────────────────────────── | |
| 293 | # 11. Summary | |
| 294 | # ──────────────────────────────────────────────────────────────────────────── | |
| 295 | say "[11/11] DONE" | |
| 296 | echo "" | |
| 297 | echo "============================================================" | |
| 298 | echo " GLUECRON BOOTSTRAP COMPLETE" | |
| 299 | echo "============================================================" | |
| 300 | echo " systemd unit: gluecron.service" | |
| 301 | echo " port: ${PORT}" | |
| 302 | echo " env file: /etc/gluecron.env (chmod 600)" | |
| 303 | echo " bare repos: /data/repos" | |
| 304 | echo " source: /opt/gluecron" | |
| 305 | echo "" | |
| 306 | echo " Test from your laptop:" | |
| 307 | echo " ssh root@\$(hostname -I | awk '{print \$1}') 'systemctl status gluecron --no-pager | head -5'" | |
| 308 | echo "" | |
| 309 | echo " Public URL (once DNS is correct + Caddy issued cert):" | |
| 310 | echo " https://${SITE_DOMAIN}" | |
| 311 | echo " https://${SITE_DOMAIN}/api/version" | |
| 312 | echo "" | |
| 313 | echo " Useful commands:" | |
| 314 | echo " systemctl status gluecron" | |
| 315 | echo " journalctl -u gluecron -f" | |
| 316 | echo " bash /opt/gluecron/scripts/bootstrap-hetzner.sh # safe to re-run" | |
| 317 | echo "============================================================" |