CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
SELF_HOST.md
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 4992afa | 1 | # Gluecron self-host — eating our own dog food |
| 2 | ||
| 3 | > BLOCK W. The moment Gluecron's own source code stops living on GitHub | |
| 4 | > and starts living on Gluecron itself. | |
| 5 | ||
| 6 | ## Why | |
| 7 | ||
| 8 | Today every push to `ccantynz-alt/Gluecron.com` on GitHub fires the | |
| 9 | `hetzner-deploy.yml` workflow, which SSHes into the box and runs a deploy | |
| 10 | script. Every red Action means a manual SSH deploy. Every AI safeguard | |
| 11 | (auto-merge, AI review, gate enforcement) only applies to **Gluecron- | |
| 12 | hosted repos** — so the platform's own source has been the one repo where | |
| 13 | none of the safety net runs. | |
| 14 | ||
| 15 | After this block lands and the operator runs the bootstrap, `git push` | |
| 16 | fires the deploy directly via Gluecron's own post-receive hook in ~25 | |
| 17 | seconds. No GitHub Actions in the middle, no SSH retries, no missing AI | |
| 18 | safeguards. | |
| 19 | ||
| 20 | The pitch to customers writes itself: *"Gluecron deploys Gluecron with | |
| 21 | Gluecron."* | |
| 22 | ||
| 23 | ## Pre-flight (do this BEFORE the bootstrap) | |
| 24 | ||
| 25 | 1. **You have site-admin access.** Confirm `/admin/ops` renders for your | |
| 26 | account. | |
| 27 | 2. **You have root SSH to the production box.** The bootstrap must run on | |
| 28 | the box where `/opt/gluecron` lives, because it writes to | |
| 29 | `$GIT_REPOS_PATH` and to `/opt/gluecron/repos/<owner>/<repo>.git/hooks/`. | |
| 30 | 3. **`DATABASE_URL` points at the live Neon DB.** Source | |
| 31 | `/etc/gluecron.env` before running the script: `set -a && source | |
| 32 | /etc/gluecron.env && set +a`. | |
| 33 | 4. **`git` is on PATH and the box can reach `github.com`.** The bootstrap | |
| 34 | clones the GitHub source over HTTPS before push-mirroring it into the | |
| 35 | local bare repo. | |
| 36 | 5. **Disk: ~200 MB free** for the temp mirror clone + the bare repo | |
| 37 | itself. | |
| 38 | 6. **No SSH key required from your laptop yet.** Gluecron's git is HTTPS- | |
| 39 | only for now; cutover is `https://gluecron.com/<owner>/<repo>.git`. | |
| 40 | ||
| 41 | ## 1. Run the bootstrap | |
| 42 | ||
| 43 | On the box, as root: | |
| 44 | ||
| 45 | ```bash | |
| 46 | cd /opt/gluecron | |
| 47 | set -a; source /etc/gluecron.env; set +a | |
| 48 | bun run scripts/self-host-bootstrap.ts | |
| 49 | ``` | |
| 50 | ||
| 51 | Optional flags: | |
| 52 | ||
| 53 | ```bash | |
| 54 | bun run scripts/self-host-bootstrap.ts \ | |
| 55 | --owner=ccantynz \ | |
| 56 | --name=Gluecron.com \ | |
| 57 | --source=https://github.com/ccantynz-alt/Gluecron.com.git \ | |
| 58 | --dry-run # print what would happen, change nothing | |
| 59 | ``` | |
| 60 | ||
| 61 | What happens (every step prints v/x/!): | |
| 62 | ||
| 63 | 1. Looks up the operator — first row in `site_admins`, falling back to | |
| 64 | the oldest user (the bootstrap admin). | |
| 65 | 2. INSERTs `repositories(name='Gluecron.com', ownerId=<operator>, | |
| 66 | isPrivate=false, defaultBranch='main', diskPath=<computed>)`. Skips | |
| 67 | if a row already exists. | |
| 68 | 3. `git init --bare /opt/gluecron/repos/ccantynz/Gluecron.com.git` — | |
| 69 | skips if the bare repo already exists. | |
| 70 | 4. `git clone --mirror` the GitHub source into a temp dir, then `git | |
| 71 | push --mirror` into the bare repo. Every branch, tag, and commit is | |
| 72 | transferred. | |
| 73 | 5. Writes `hooks/post-receive` on the bare repo: a one-line bash script | |
| 74 | that invokes `scripts/self-deploy.sh` when ref is `refs/heads/main`. | |
| 75 | 6. Prints cutover instructions. | |
| 76 | ||
| 77 | The script is idempotent — re-run anytime. It only fails if a step | |
| 78 | truly cannot proceed (no users in DB, mirror push rejected, etc.). | |
| 79 | ||
| 80 | ## 2. Cutover | |
| 81 | ||
| 82 | ### On your laptop | |
| 83 | ||
| 84 | ```bash | |
| 85 | cd ~/code/Gluecron.com | |
| 86 | git remote set-url origin https://gluecron.com/ccantynz/Gluecron.com.git | |
| 87 | ``` | |
| 88 | ||
| 89 | ### On the production box | |
| 90 | ||
| 91 | ```bash | |
| 92 | cd /opt/gluecron | |
| 93 | git remote set-url origin https://gluecron.com/ccantynz/Gluecron.com.git | |
| 94 | ``` | |
| 95 | ||
| 96 | ### Flip the self-host env var | |
| 97 | ||
| 98 | Add to `/etc/gluecron.env`: | |
| 99 | ||
| 100 | ``` | |
| 101 | SELF_HOST_REPO=ccantynz/Gluecron.com | |
| 102 | ``` | |
| 103 | ||
| 104 | Then reload systemd so the live process picks it up: | |
| 105 | ||
| 106 | ```bash | |
| 107 | systemctl restart gluecron | |
| 108 | ``` | |
| 109 | ||
| 110 | ## 3. Verify | |
| 111 | ||
| 112 | 1. Open `/admin/self-host` in the browser. All three status pills should | |
| 113 | be green: **Mirrored**, **Installed**, **Set**. | |
| 114 | 2. From your laptop, push an empty commit: | |
| 115 | ||
| 116 | ```bash | |
| 117 | git commit --allow-empty -m "self-host smoke" | |
| 118 | git push | |
| 119 | ``` | |
| 120 | ||
| 121 | 3. Watch `/admin/deploys` — a new row appears with `source='self-deploy'` | |
| 122 | and streams through `git-pull → bun-install → db-migrate → build → | |
| 123 | restart-service → healthz → full-smoke`. | |
| 124 | 4. Tail the log on the box: `tail -f /var/log/gluecron-self-deploy.log`. | |
| 125 | ||
| 126 | Total wall-clock: push to live ≈ 20–30 seconds. | |
| 127 | ||
| 128 | ## 4. Rollback plan | |
| 129 | ||
| 130 | If a self-deploy breaks the site: | |
| 131 | ||
| 132 | - **Automatic:** `scripts/self-deploy.sh` runs `post-deploy-smoke.ts` | |
| 133 | after every restart. On failure it `git reset --hard <PREV_SHA>` and | |
| 134 | restarts. The previous-good SHA is captured *before* `git pull`, so | |
| 135 | rollback is reflog-safe. | |
| 136 | - **Manual fallback to GitHub:** change the remote back and re-fire the | |
| 137 | old workflow. | |
| 138 | ||
| 139 | ```bash | |
| 140 | # on laptop AND on the box | |
| 141 | git remote set-url origin https://github.com/ccantynz-alt/Gluecron.com.git | |
| 142 | ``` | |
| 143 | ||
| 144 | The GitHub mirror remains the canonical history for at least 30 days | |
| 145 | after cutover — we don't deprecate `hetzner-deploy.yml` until the | |
| 146 | self-host path has run a full week without intervention. | |
| 147 | ||
| 148 | ## 5. Deprecating GitHub Actions | |
| 149 | ||
| 150 | After 7 days of green self-deploys: | |
| 151 | ||
| 152 | 1. Set the workflow `on:` trigger to `workflow_dispatch:` only (no push | |
| 153 | trigger) so it stays available for emergencies but doesn't fire on | |
| 154 | every push. | |
| 155 | 2. After 30 days, remove the `.github/workflows/hetzner-deploy.yml` file | |
| 156 | entirely. | |
| 157 | ||
| 158 | Until then it's a free hot-spare deploy path. | |
| 159 | ||
| 160 | ## Operator footnotes | |
| 161 | ||
| 162 | - **Log rotation:** `/var/log/gluecron-self-deploy.log` is appended to | |
| 163 | forever. Add a `/etc/logrotate.d/gluecron-self-deploy` entry — TODO. | |
| 164 | - **Permissions:** `scripts/self-deploy.sh` runs as root (it has to, | |
| 165 | because of `systemctl restart`). The bare-repo post-receive hook | |
| 166 | inherits whatever user the HTTP receive-pack runs under (usually root | |
| 167 | in our setup); SSH receive-pack will eventually need a `git` user with | |
| 168 | passwordless `sudo systemctl restart gluecron`. | |
| 169 | - **Self-deploy is gated by `SELF_HOST_REPO`.** Customer repos named | |
| 170 | `Gluecron.com` are safe — only the exact `<owner>/<repo>` slug in the | |
| 171 | env var fires the local deploy. | |
| 172 | - **The optional `.gluecron/workflows/deploy.yml`** runs `self-deploy.sh | |
| 173 | --inline` on the in-process workflow runner if you want belt-and- | |
| 174 | braces. The post-receive hook is the primary path. |