Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

bootstrap-hetzner.shBlame317 lines · 1 contributor
fc1e337Claude1#!/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
27set -euo pipefail
28PUBKEY="${1:-}"
29PORT="${PORT:-3010}"
30SITE_DOMAIN="${SITE_DOMAIN:-gluecron.com}"
31DB_USER="gluecron"
32DB_NAME="gluecron"
33DB_PASS="$(openssl rand -hex 16)"
34
35say() { echo ""; echo "==> $*"; }
36ok() { echo " ✓ $*"; }
37warn() { echo " ⚠ $*"; }
38
39# ────────────────────────────────────────────────────────────────────────────
40# 1. Authorise SSH pubkey
41# ────────────────────────────────────────────────────────────────────────────
42say "[1/11] Authorising SSH key on root@"
43mkdir -p /root/.ssh
44chmod 700 /root/.ssh
45touch /root/.ssh/authorized_keys
46chmod 600 /root/.ssh/authorized_keys
47if [ -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
54else
55 warn "No PUBKEY argument — skipping SSH key install"
56fi
57
58# ────────────────────────────────────────────────────────────────────────────
59# 2. Re-enable PasswordAuthentication (safety fallback)
60# ────────────────────────────────────────────────────────────────────────────
61say "[2/11] Configuring sshd (allow both key + password as fallback)"
62sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
63sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
64sed -i 's/^#*PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
65mkdir -p /etc/ssh/sshd_config.d
66cat > /etc/ssh/sshd_config.d/99-gluecron-auth.conf <<EOF
67PasswordAuthentication yes
68PubkeyAuthentication yes
69PermitRootLogin yes
70EOF
71systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
72ok "sshd reloaded — both auth methods enabled"
73
74# ────────────────────────────────────────────────────────────────────────────
75# 3. Install dependencies if missing
76# ────────────────────────────────────────────────────────────────────────────
77say "[3/11] Ensuring git, curl, postgresql, caddy, bun are installed"
78export DEBIAN_FRONTEND=noninteractive
79apt-get update -qq
80
ee80a15Claude81for pkg in git curl ca-certificates gnupg debian-keyring debian-archive-keyring apt-transport-https openssl unzip xz-utils tar; do
fc1e337Claude82 dpkg -l | grep -qw "$pkg" || apt-get install -y -qq "$pkg"
83done
84ok "base packages installed"
85
86# Postgres
87if ! 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"
91else
92 ok "postgres already installed"
93fi
94
95# Caddy (skip install if already there — likely from crontech setup)
96if ! 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"
103else
104 ok "caddy already installed (likely from crontech)"
105fi
106
107# Bun
108if [ ! -x /root/.bun/bin/bun ]; then
109 curl -fsSL https://bun.sh/install | bash >/dev/null
110 ok "bun installed"
111else
112 ok "bun already installed"
113fi
114export PATH="/root/.bun/bin:$PATH"
115ok "bun version: $(/root/.bun/bin/bun --version 2>/dev/null || echo unknown)"
116
117# ────────────────────────────────────────────────────────────────────────────
118# 4. Local Postgres DB + user
119# ────────────────────────────────────────────────────────────────────────────
120say "[4/11] Creating local Postgres DB + user (idempotent)"
121if 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"
123else
124 sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';"
125 ok "postgres user '${DB_USER}' created"
126fi
127if 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"
129else
130 sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};"
131 ok "postgres database '${DB_NAME}' created"
132fi
133
134# Read existing DB password if env file already has one (don't overwrite)
135EXISTING_DB_URL=""
136if [ -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"
139fi
140DATABASE_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
143if [ -z "$EXISTING_DB_URL" ]; then
144 sudo -u postgres psql -c "ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';"
145fi
146
147# ────────────────────────────────────────────────────────────────────────────
148# 5. Clone or update /opt/gluecron
149# ────────────────────────────────────────────────────────────────────────────
150say "[5/11] Setting up /opt/gluecron source tree"
151mkdir -p /opt
152if [ ! -d /opt/gluecron/.git ]; then
153 git clone https://github.com/ccantynz-alt/Gluecron.com.git /opt/gluecron
154 ok "cloned gluecron"
155else
156 cd /opt/gluecron
157 git fetch --prune origin main
158 git reset --hard origin/main
159 ok "pulled latest main"
160fi
161cd /opt/gluecron
162
163# ────────────────────────────────────────────────────────────────────────────
164# 6. Write /etc/gluecron.env (preserve existing where present)
165# ────────────────────────────────────────────────────────────────────────────
166say "[6/11] Writing /etc/gluecron.env"
167mkdir -p /data/repos
168chmod 755 /data/repos
169umask 077
170cat > /etc/gluecron.env <<EOF
171DATABASE_URL=${DATABASE_URL}
172APP_BASE_URL=https://${SITE_DOMAIN}
173SITE_ADMIN_USERNAME=${SITE_ADMIN_USERNAME:-ccantynz}
174GIT_REPOS_PATH=/data/repos
175PORT=${PORT}
176DEMO_SEED_ON_BOOT=1
177EMAIL_PROVIDER=log
178EMAIL_FROM="gluecron <no-reply@${SITE_DOMAIN}>"
179NODE_ENV=production
180EOF
181chmod 600 /etc/gluecron.env
182umask 022
183ok "/etc/gluecron.env written (chmod 600)"
184
185# ────────────────────────────────────────────────────────────────────────────
186# 7. Bun install + run migrations
187# ────────────────────────────────────────────────────────────────────────────
188say "[7/11] bun install + db:migrate"
189/root/.bun/bin/bun install --frozen-lockfile
190set -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"
192ok "deps + migrations done"
193
194# ────────────────────────────────────────────────────────────────────────────
195# 8. systemd unit for gluecron
196# ────────────────────────────────────────────────────────────────────────────
f764c07Claude197# 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.
fc1e337Claude206say "[8/11] Writing /etc/systemd/system/gluecron.service"
207BUN_BIN=/root/.bun/bin/bun
f764c07Claude208COMPILED_BIN=/opt/gluecron/.next/gluecron-server
209mkdir -p /opt/gluecron/.next
210if [ -x "$COMPILED_BIN" ]; then
211 EXEC_START="$COMPILED_BIN"
212else
213 EXEC_START="${BUN_BIN} run src/index.ts"
214fi
fc1e337Claude215cat > /etc/systemd/system/gluecron.service <<EOF
216[Unit]
217Description=Gluecron — AI-native code intelligence platform
218After=network-online.target postgresql.service
219Wants=network-online.target
220
221[Service]
f764c07Claude222Type=notify
223NotifyAccess=main
fc1e337Claude224User=root
225WorkingDirectory=/opt/gluecron
226EnvironmentFile=/etc/gluecron.env
f764c07Claude227ExecStart=${EXEC_START}
fc1e337Claude228Restart=always
229RestartSec=5
f764c07Claude230# 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.
232TimeoutStartSec=30
fc1e337Claude233StandardOutput=journal
234StandardError=journal
235SyslogIdentifier=gluecron
236LimitNOFILE=65536
237
238[Install]
239WantedBy=multi-user.target
240EOF
241systemctl daemon-reload
242systemctl enable gluecron >/dev/null
243systemctl restart gluecron
f764c07Claude244ok "gluecron systemd unit installed + started (Type=notify, ExecStart=${EXEC_START})"
fc1e337Claude245
246# ────────────────────────────────────────────────────────────────────────────
247# 9. Append gluecron + www.gluecron to Caddyfile if missing
248# ────────────────────────────────────────────────────────────────────────────
249say "[9/11] Caddy reverse-proxy config for ${SITE_DOMAIN}"
250CADDYFILE=/etc/caddy/Caddyfile
251touch "$CADDYFILE"
252if grep -qE "^${SITE_DOMAIN}\s*\{" "$CADDYFILE"; then
253 ok "${SITE_DOMAIN} block already in Caddyfile — leaving as-is"
254else
255 cat >> "$CADDYFILE" <<EOF
256
257${SITE_DOMAIN} {
258 encode zstd gzip
259 reverse_proxy localhost:${PORT}
260}
261
262www.${SITE_DOMAIN} {
263 redir https://${SITE_DOMAIN}{uri} permanent
264}
265EOF
266 ok "appended ${SITE_DOMAIN} block to Caddyfile"
267fi
268
269if caddy validate --config "$CADDYFILE" --adapter caddyfile 2>&1 | grep -qi "valid"; then
270 systemctl reload caddy || systemctl restart caddy
271 ok "caddy reloaded"
272else
273 warn "caddy validate warned — check: caddy validate --config $CADDYFILE"
274fi
275
276# ────────────────────────────────────────────────────────────────────────────
277# 10. Wait + smoke test
278# ────────────────────────────────────────────────────────────────────────────
279say "[10/11] Waiting for /healthz to come up (60s)"
280for 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
287done
288if [ "$code" != "200" ]; then
289 warn "healthz did not respond 200 after 60s. Logs: journalctl -u gluecron -n 50 --no-pager"
290fi
291
292# ────────────────────────────────────────────────────────────────────────────
293# 11. Summary
294# ────────────────────────────────────────────────────────────────────────────
295say "[11/11] DONE"
296echo ""
297echo "============================================================"
298echo " GLUECRON BOOTSTRAP COMPLETE"
299echo "============================================================"
300echo " systemd unit: gluecron.service"
301echo " port: ${PORT}"
302echo " env file: /etc/gluecron.env (chmod 600)"
303echo " bare repos: /data/repos"
304echo " source: /opt/gluecron"
305echo ""
306echo " Test from your laptop:"
307echo " ssh root@\$(hostname -I | awk '{print \$1}') 'systemctl status gluecron --no-pager | head -5'"
308echo ""
309echo " Public URL (once DNS is correct + Caddy issued cert):"
310echo " https://${SITE_DOMAIN}"
311echo " https://${SITE_DOMAIN}/api/version"
312echo ""
313echo " Useful commands:"
314echo " systemctl status gluecron"
315echo " journalctl -u gluecron -f"
316echo " bash /opt/gluecron/scripts/bootstrap-hetzner.sh # safe to re-run"
317echo "============================================================"