tenseleyflow/shithub / f351e94

Browse files

deploy: SETUP-GUIDE.md — phase-by-phase first-deploy walkthrough

Authored by espadonne
SHA
f351e9461d00d319e80bd6e88dbabb0b087a22ca
Parents
6937b30
Tree
55d3627

1 changed file

StatusFile+-
A deploy/cutover/SETUP-GUIDE.md 616 0
deploy/cutover/SETUP-GUIDE.mdadded
@@ -0,0 +1,616 @@
1
+# shithub.sh — first-deploy setup guide
2
+
3
+This is the operator's running-order for taking shithub.sh from
4
+"Namecheap registration" to "live and serving signups." Walk it
5
+top-to-bottom, one step at a time. Each step has a verify-it-
6
+worked check. **Don't skip the verifications** — they're cheap
7
+and catch the wrong thing before it compounds.
8
+
9
+> **Time budget.** Total ~5–8 hours across 1–2 days, dominated
10
+> by DNS / Postmark verification waits (you can step away during
11
+> those).
12
+
13
+> **Money budget.** ~$3.50/day from Step 4 onwards
14
+> (~$105/mo all-in once Spaces buckets are provisioned). If you
15
+> have to pause for a day, fine; if you need to pause for a week,
16
+> destroy the droplets — the volume + Spaces + DNS persist.
17
+
18
+## Decisions baked in
19
+
20
+- Domain: **shithub.sh** (you registered this on Namecheap)
21
+- Region: **NYC3** (DO Spaces parity, codebase default)
22
+- DR region: **SFO3** (cross-region Spaces mirror)
23
+- Email: **Postmark** (free tier covers v0.1.0)
24
+- Mirror plan: **90-day GitHub mirror, then drop**
25
+- Version: **v0.1.0** (pre-1.0; honest about WIP; tag will be cut later)
26
+- On-call: **email-only alerts for week 1**, flip to phone after
27
+  noise calibration
28
+
29
+If any of these change, redo the steps that depend on them.
30
+
31
+---
32
+
33
+## Phase A — Accounts & DNS (do these first; they propagate while you wait)
34
+
35
+### A1. Postmark account
36
+
37
+1. Go to <https://account.postmarkapp.com/sign_up>.
38
+2. Sign up with the email you'll use for ops
39
+   (`ops@shithub.sh` is a good convention; you'll set up the
40
+   inbox later).
41
+3. After signup, Postmark drops you in a default Server.
42
+   Rename it: top-left dropdown → **Manage Servers** → click the
43
+   default → **Settings** → name it **shithub-prod**.
44
+4. Confirm the Server has the **Transactional** message stream
45
+   (default) — it does for new accounts.
46
+5. **Don't grab the API token yet** — we generate it after
47
+   verifying the domain.
48
+
49
+**Verify:** the Servers list shows one server named
50
+`shithub-prod`.
51
+
52
+### A2. Verify the sender domain in Postmark
53
+
54
+This is the slow step. Start it now; come back later.
55
+
56
+1. In the Postmark dashboard: top-right **Sender Signatures** →
57
+   **Domains** tab → **Add Domain**.
58
+2. Enter **`shithub.sh`** (the bare apex — DKIM applies to all
59
+   subdomains).
60
+3. Postmark presents three DNS records to add:
61
+   - **DKIM** — a `TXT` record at `<postmark-prefix>._domainkey`
62
+   - **Return-Path** — a `CNAME` at `pm-bounces` (for VERP-style
63
+     bounce handling)
64
+   - **(optional) DMARC** — a `TXT` at `_dmarc` — Postmark will
65
+     suggest one; we'll add it.
66
+4. Leave that tab open; we add the records in Namecheap next.
67
+
68
+### A3. Add Postmark DNS records in Namecheap
69
+
70
+1. <https://ap.www.namecheap.com/Domains/DomainControlPanel/shithub.sh/advancedns>
71
+2. **Advanced DNS** tab. Set **NameServers** to **Namecheap
72
+   BasicDNS** if it isn't already (the default after a fresh
73
+   registration).
74
+3. **Add a New Record** for each of the three Postmark records.
75
+   Match the exact `Host` and `Value` Postmark gave you. TTL =
76
+   1 min during setup (we'll relax it later).
77
+4. Also add an **SPF record** if there isn't one already:
78
+   - Type: `TXT`
79
+   - Host: `@`
80
+   - Value: `v=spf1 include:spf.mtasv.net ~all`
81
+   - (`spf.mtasv.net` is Postmark's relay; the `~all` is
82
+     soft-fail.)
83
+5. **DMARC record** (recommended; Postmark prompts you):
84
+   - Type: `TXT`
85
+   - Host: `_dmarc`
86
+   - Value: `v=DMARC1; p=none; rua=mailto:dmarc-rua@shithub.sh; pct=100`
87
+   - `p=none` means "report only, don't reject" — appropriate
88
+     for week 1 while we tune. Tighten to `p=quarantine` later.
89
+
90
+**Verify:** in Postmark's **Domains** tab, click **Verify**.
91
+DKIM may take 5–30 min to propagate. Other records refresh on
92
+their own. The verification turns green once all records are
93
+seen. Move on; come back to confirm.
94
+
95
+### A4. Generate Postmark API token
96
+
97
+After domain shows verified:
98
+
99
+1. Postmark → **Servers** → **shithub-prod** → **API Tokens**
100
+   tab.
101
+2. The default Server token shown there is what we'll use.
102
+3. **Copy it.** Keep in your password manager.
103
+4. **Sender from:** decide on the From address. Convention:
104
+   `shithub <noreply@shithub.sh>`. Postmark will accept any
105
+   address on the verified domain; no per-address signature
106
+   needed.
107
+
108
+### A5. Set up DNS for the app + docs subdomains
109
+
110
+Still in Namecheap **Advanced DNS** for shithub.sh. Add:
111
+
112
+- `A` record, Host `@`, Value `<APP-DROPLET-IP>` — **placeholder
113
+  for now**; we'll fill the real IP after creating the droplet
114
+  in Phase B. **Pin TTL to 1 min** for now.
115
+- `A` record, Host `www`, Value `<APP-DROPLET-IP>` — same.
116
+- `CNAME` record, Host `docs`, Value `shithub-docs.nyc3.cdn.digitaloceanspaces.com.`
117
+  (Spaces CDN — we'll create the bucket in Phase B; the CNAME
118
+  resolves once the bucket exists.) The trailing dot matters.
119
+
120
+Skip the records you don't have IPs for yet; they go in after
121
+Phase B step B3.
122
+
123
+### A6. Telegram bot for alerts (skipped — week-1 email only)
124
+
125
+We're using email-only alerts for week 1. Email goes via
126
+Postmark to your ops mailbox. When you flip to phone alerts:
127
+follow `docs/internal/runbooks/incidents.md` and add a Telegram
128
+bot — won't repeat that here.
129
+
130
+---
131
+
132
+## Phase B — DigitalOcean infrastructure
133
+
134
+### B1. DO project + SSH key
135
+
136
+1. <https://cloud.digitalocean.com> → **Projects** (left sidebar)
137
+   → **New Project**.
138
+2. Name: **shithub-prod**. Purpose: **Service or API**.
139
+   Environment: **Production**.
140
+3. **SSH key:** Account (top-right) → Settings → Security →
141
+   **Add SSH Key**. Paste the contents of
142
+   `~/.ssh/id_ed25519.pub` from your laptop. Name it after the
143
+   laptop ("macbook-pro").
144
+
145
+**Verify:** the project shows in the dropdown; the SSH key
146
+shows under Settings → Security.
147
+
148
+### B2. Create Spaces buckets (do these BEFORE droplets — they need to exist for the docs CNAME to resolve)
149
+
150
+1. Left sidebar → **Spaces Object Storage** → **Create Spaces
151
+   Bucket**.
152
+2. **Bucket #1 — primary backups + docs:**
153
+   - Region: **NYC3**
154
+   - Name: **shithub-prod** (this matches the inventory's
155
+     `s3_bucket: shithub-prod`)
156
+   - File listing: **Restrict** (private; presigned URLs only)
157
+   - CDN: **Enable** (for the docs subdomain)
158
+   - Project: **shithub-prod**
159
+3. **Bucket #2 — DR mirror:**
160
+   - Region: **SFO3**
161
+   - Name: **shithub-prod-dr**
162
+   - Same other settings.
163
+4. **Bucket #3 — docs site:**
164
+   - Region: **NYC3**
165
+   - Name: **shithub-docs**
166
+   - File listing: **Public** (it's the docs site; everyone
167
+     reads)
168
+   - CDN: **Enable**
169
+   - Project: **shithub-prod**
170
+5. **Generate Spaces access keys:** Account → API → **Spaces
171
+   Keys** → **Generate New Key**. Name it `shithub-prod-app`.
172
+   Copy the access key + secret — Postmark-style, the secret
173
+   is shown once.
174
+
175
+**Verify:** three buckets listed in Spaces, all in their
176
+respective regions. Endpoint URLs follow the pattern
177
+`<bucket>.<region>.digitaloceanspaces.com`.
178
+
179
+### B3. Create the four droplets
180
+
181
+Use the DO web UI for the first one to confirm the shape; then
182
+duplicate.
183
+
184
+1. Left sidebar → **Droplets** → **Create Droplet**.
185
+2. **Region:** NYC3 → Datacenter NYC3.
186
+3. **Image:** Marketplace? **No** — Distributions tab → Ubuntu
187
+   24.04 (LTS) x64.
188
+4. **Size:** Basic → Regular SSD → **2 vCPU / 4 GB RAM /
189
+   80 GB SSD** ($24/mo).
190
+5. **VPC Network:** default VPC for NYC3 (DO selects this
191
+   automatically). All four droplets must be in the same VPC so
192
+   they see each other on private IPs.
193
+6. **Authentication:** SSH Key → check the key you added in B1.
194
+7. **Hostname:** `shithub-app` (this is droplet #1).
195
+8. **Tags:** `shithub`, `shithub-app`.
196
+9. **Project:** shithub-prod.
197
+10. **Backups:** off (we have our own backup pipeline).
198
+11. **Monitoring:** **on** (DO's free agent — useful baseline
199
+    metrics in their UI).
200
+12. Create.
201
+
202
+Repeat for droplets #2–#4 with these differences:
203
+
204
+| # | Hostname           | Size                  | Tags                       |
205
+|---|--------------------|----------------------|----------------------------|
206
+| 2 | `shithub-db`       | s-2vcpu-4gb ($24/mo) | `shithub`, `shithub-db`    |
207
+| 3 | `shithub-backup`   | s-1vcpu-2gb ($12/mo) | `shithub`, `shithub-backup`|
208
+| 4 | `shithub-monitoring`| s-2vcpu-4gb ($24/mo)| `shithub`, `shithub-monitoring` |
209
+
210
+**Capture the IPs.** For each droplet, write down both the
211
+**public IPv4** (for SSH from your laptop, and for `shithub-app`
212
+to point DNS at) and the **private IPv4** (for inter-droplet
213
+traffic — appears under "Private IPv4" in the droplet detail).
214
+
215
+**Now go back to Phase A5** and update the `A` records for `@`
216
+and `www` to point at `shithub-app`'s public IPv4. The CNAME
217
+for `docs` you set already; it will start resolving once
218
+shithub-docs CDN is ready (~1 min).
219
+
220
+### B4. Create + attach the block volume
221
+
222
+1. Left sidebar → **Volumes** → **Create Volume**.
223
+2. **Region:** NYC3.
224
+3. **Size:** **100 GB** ($10/mo).
225
+4. **Filesystem format:** Ext4. **Mount options:** automatic.
226
+5. **Attach to droplet:** `shithub-app`.
227
+6. **Mount point:** **`/mnt/shithub_data`** (DO's default for the
228
+   volume name).
229
+7. Create + attach.
230
+
231
+**Verify (after SSHing in B5):**
232
+```sh
233
+df -h /mnt/shithub_data    # should show ~100 GB Ext4 mounted
234
+```
235
+
236
+We'll move the mount to `/data` (where the playbook expects it)
237
+during Phase C bind-mount step.
238
+
239
+### B5. SSH-bootstrap — confirm you can reach all four droplets
240
+
241
+From your laptop:
242
+
243
+```sh
244
+# Replace IPs with the public IPv4 of each droplet.
245
+ssh root@<APP-IP>          # shithub-app
246
+ssh root@<DB-IP>           # shithub-db
247
+ssh root@<BACKUP-IP>       # shithub-backup
248
+ssh root@<MONITORING-IP>   # shithub-monitoring
249
+```
250
+
251
+If `Permission denied (publickey)`, your SSH key didn't get
252
+attached at create time; add it via DO console (Droplet →
253
+Access → Reset Root Password is the only fallback that works
254
+without an existing key).
255
+
256
+**Verify:** `whoami` returns `root` on each.
257
+
258
+### B6. Bootstrap inter-droplet SSH
259
+
260
+Ansible will use shithub-app as its control node (we install it
261
+there in Phase C). For Ansible to reach the other three over
262
+the private network, shithub-app needs an SSH key authorized on
263
+each.
264
+
265
+On **shithub-app**:
266
+
267
+```sh
268
+ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""
269
+cat /root/.ssh/id_ed25519.pub
270
+```
271
+
272
+Copy that public key. On **shithub-db**, **shithub-backup**, and
273
+**shithub-monitoring**:
274
+
275
+```sh
276
+mkdir -p /root/.ssh
277
+cat >> /root/.ssh/authorized_keys <<'EOF'
278
+<paste shithub-app's pubkey here>
279
+EOF
280
+chmod 600 /root/.ssh/authorized_keys
281
+```
282
+
283
+**Verify (from shithub-app):**
284
+```sh
285
+ssh root@<DB-PRIVATE-IP> hostname           # → shithub-db
286
+ssh root@<BACKUP-PRIVATE-IP> hostname       # → shithub-backup
287
+ssh root@<MONITORING-PRIVATE-IP> hostname   # → shithub-monitoring
288
+```
289
+
290
+If asked about host key fingerprints, say yes.
291
+
292
+---
293
+
294
+## Phase C — Hand Claude Code the keyboard
295
+
296
+### C1. Install Claude Code on shithub-app
297
+
298
+On **shithub-app**:
299
+
300
+```sh
301
+apt-get update
302
+apt-get install -y curl ca-certificates
303
+curl -fsSL https://claude.ai/install.sh | sh
304
+```
305
+
306
+Then run `claude` and authenticate with the same Anthropic
307
+account you're using on your laptop (browser flow on your
308
+laptop, paste the code into the SSH terminal).
309
+
310
+### C2. Install build dependencies on shithub-app
311
+
312
+These are needed to build the `shithubd` binary on the droplet:
313
+
314
+```sh
315
+apt-get install -y \
316
+  git make build-essential \
317
+  ansible \
318
+  golang-go
319
+# Verify Go ≥ 1.22 (the project targets 1.26).
320
+go version
321
+```
322
+
323
+If the apt Go is too old (Ubuntu 24.04 ships ~1.22 which is
324
+fine), skip the next part. If you need a newer Go, install via
325
+the tarball method:
326
+
327
+```sh
328
+curl -LO https://go.dev/dl/go1.22.5.linux-amd64.tar.gz
329
+rm -rf /usr/local/go
330
+tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz
331
+echo 'export PATH=$PATH:/usr/local/go/bin' >> /root/.bashrc
332
+source /root/.bashrc
333
+go version
334
+```
335
+
336
+### C3. Clone the source
337
+
338
+```sh
339
+mkdir -p /root/src && cd /root/src
340
+git clone https://github.com/tenseleyFlow/shithub.git
341
+cd shithub
342
+git log --oneline -3      # confirm latest commit
343
+```
344
+
345
+### C4. Move the volume mount to /data
346
+
347
+The Ansible playbook expects `/data` as the data root. We could
348
+edit the inventory, but it's cleaner to bind-mount the
349
+DO-attached volume to `/data`:
350
+
351
+```sh
352
+mkdir -p /data
353
+mount --bind /mnt/shithub_data /data
354
+echo '/mnt/shithub_data /data none bind 0 0' >> /etc/fstab
355
+
356
+# Verify the bind mount survives the next reboot test:
357
+mount | grep -E '/(data|mnt/shithub_data)'
358
+```
359
+
360
+### C5. Hand off to Claude Code
361
+
362
+From your SSH session on shithub-app, run:
363
+
364
+```sh
365
+cd /root/src/shithub
366
+claude
367
+```
368
+
369
+When Claude is up, paste this priming message:
370
+
371
+> This is the shithub deploy you've been planning. The repo is at
372
+> github.com/tenseleyFlow/shithub. You wrote the sprint specs at
373
+> .docs/sprints/, especially S37-deploy.md and S40-launch.md.
374
+> We are at Phase D of `deploy/cutover/SETUP-GUIDE.md` — please
375
+> read that file and the prerequisite docs, then walk me through
376
+> Phase D with my hands on the keyboard. Domain is shithub.sh.
377
+> Postmark is set up. Spaces buckets are: shithub-prod (NYC3,
378
+> private), shithub-prod-dr (SFO3, private), shithub-docs (NYC3,
379
+> public). Volume bind-mounted at /data. Other droplets reachable
380
+> via private IPs.
381
+
382
+Claude will pick up from there. **The rest of this guide is
383
+written for Claude (or you, if you'd rather drive yourself).**
384
+
385
+---
386
+
387
+## Phase D — Inventory + secrets
388
+
389
+### D1. Copy the production inventory template
390
+
391
+```sh
392
+cd /root/src/shithub
393
+cp deploy/ansible/inventory/production.example deploy/ansible/inventory/production
394
+```
395
+
396
+The bare name `production` is gitignored so secrets stay out of
397
+the repo.
398
+
399
+### D2. Generate the cryptographic secrets
400
+
401
+```sh
402
+# Session signing key (cookie MAC).
403
+openssl rand -base64 32 > /tmp/session_key
404
+# TOTP AEAD key (encrypts 2FA secrets at rest).
405
+openssl rand -base64 32 > /tmp/totp_key
406
+# Postgres passwords.
407
+openssl rand -base64 24 > /tmp/db_password
408
+openssl rand -base64 24 > /tmp/hook_password
409
+# WireGuard private keys (one per host).
410
+for h in app db backup monitoring; do
411
+  wg genkey > /tmp/wg_${h}.key
412
+  wg pubkey < /tmp/wg_${h}.key > /tmp/wg_${h}.pub
413
+done
414
+```
415
+
416
+### D3. Fill in the inventory
417
+
418
+```sh
419
+$EDITOR deploy/ansible/inventory/production
420
+```
421
+
422
+Fill in:
423
+- `app_host`, `db_host`, `backup_host`, `monitoring_host` — the
424
+  **private IPv4** of each droplet.
425
+- `domain: shithub.sh`
426
+- `caddy_email: ops@shithub.sh` (Let's Encrypt notifications)
427
+- `db_password`, `hook_password` — paste from `/tmp/`.
428
+- `session_key`, `totp_key` — paste from `/tmp/`.
429
+- `s3_endpoint: nyc3.digitaloceanspaces.com`
430
+- `s3_region: us-east-1` (Spaces uses this for SigV4)
431
+- `s3_bucket: shithub-prod`
432
+- `s3_access_key_id`, `s3_secret_access_key` — from B2 step 5.
433
+- `email_backend: postmark`
434
+- `postmark_server_token` — from A4.
435
+- `email_from: shithub <noreply@shithub.sh>`
436
+- `auth_base_url: https://shithub.sh`
437
+- WireGuard peer keys from `/tmp/wg_*.{key,pub}`.
438
+
439
+After filling in:
440
+
441
+```sh
442
+chmod 600 deploy/ansible/inventory/production
443
+shred -u /tmp/session_key /tmp/totp_key /tmp/db_password \
444
+        /tmp/hook_password /tmp/wg_*.key
445
+```
446
+
447
+(Keep the public WireGuard keys in case you need to add a peer
448
+later; the private keys are now only in the inventory.)
449
+
450
+---
451
+
452
+## Phase E — Deploy
453
+
454
+### E1. Dry-run
455
+
456
+```sh
457
+cd /root/src/shithub
458
+make deploy-check ANSIBLE_INVENTORY=production
459
+```
460
+
461
+Read the diff. Expect every host to be `changed`. If any host
462
+shows `unreachable`, the SSH bootstrap from B6 missed a droplet.
463
+
464
+### E2. Build the binary with the version stamp
465
+
466
+We're not cutting the v0.1.0 tag yet — that's a launch-day
467
+ceremony. For this first deploy, the binary will stamp the
468
+short commit + build time, which is fine; the soft-launch
469
+window catches surprises before we tag.
470
+
471
+```sh
472
+make build
473
+./bin/shithubd version
474
+# expect:
475
+#   Version: <short-commit-or-dev>
476
+#   Commit:  <short-commit>
477
+#   Built:   <today, UTC>
478
+```
479
+
480
+### E3. Apply
481
+
482
+```sh
483
+make deploy ANSIBLE_INVENTORY=production
484
+```
485
+
486
+Expect ~15–30 min on the first run. The roles run in this order:
487
+**base → postgres → shithubd → caddy → wireguard → backup →
488
+monitoring-client.** Caddy will obtain real Let's Encrypt certs
489
+on first request; `caddy_use_acme_staging` should be `false` in
490
+the inventory so we get a real cert.
491
+
492
+If a role fails, **stop**. Re-running with `--limit` and the
493
+specific role tag is the surgical path. Read the journal of the
494
+failing service before retrying:
495
+```sh
496
+journalctl -u <service> -n 200
497
+```
498
+
499
+### E4. Bootstrap the admin (you)
500
+
501
+```sh
502
+ssh root@<APP-PRIVATE-IP>
503
+sudo -u shithub /usr/local/bin/shithubd admin bootstrap-admin \
504
+  --email you@your-current-email.com
505
+```
506
+
507
+The CLI prints a one-time password-reset link. Open it in a
508
+browser, set a password, **immediately enable 2FA**.
509
+
510
+### E5. Smoke
511
+
512
+```sh
513
+deploy/cutover/smoke.sh https://shithub.sh
514
+```
515
+
516
+If everything is green, the soft launch is live. Signups are
517
+still gated (we set `SHITHUB_AUTH__SIGNUP_DISABLED=true` in the
518
+inventory by default for soft launch); you're the only user.
519
+
520
+---
521
+
522
+## Phase F — Soft launch (24–48h)
523
+
524
+You're now using shithub yourself. Things to do:
525
+
526
+1. **Push the project's source.** Create the `shithub` org,
527
+   create the `shithub` repo under it, push from this checkout.
528
+2. **Walk every flow.** Signup (toggle the gate, verify, gate
529
+   again), password reset, 2FA, SSH key add (the SSH transport
530
+   isn't shipped, so this just stores the key — fine), PAT
531
+   create, repo create, push, issue, PR, review, merge, search.
532
+3. **Note every rough edge.** File issues against
533
+   `shithub/shithub` itself.
534
+4. **Watch the dashboards.** Grafana on the monitoring host.
535
+5. **First daily backup runs at the next 03:00 UTC.** Confirm
536
+   it landed in Spaces the morning after.
537
+6. **First restore drill.** When the second daily backup
538
+   exists, run the drill.
539
+
540
+Day 2 morning: you have a real instance with real data + a real
541
+backup. You're as ready as you'll ever be.
542
+
543
+---
544
+
545
+## Phase G — Public launch
546
+
547
+### G1. Tag v0.1.0
548
+
549
+```sh
550
+cd /root/src/shithub
551
+git tag -a v0.1.0 -m "v0.1.0 — initial public release"
552
+git push origin v0.1.0
553
+```
554
+
555
+### G2. Build and re-deploy with the tagged version
556
+
557
+```sh
558
+git fetch --tags
559
+git checkout v0.1.0
560
+make build
561
+./bin/shithubd version    # Version: v0.1.0
562
+make deploy ANSIBLE_INVENTORY=production
563
+```
564
+
565
+Now the home page's Version field reads `v0.1.0`. Verify in a
566
+browser.
567
+
568
+### G3. Open signups
569
+
570
+Edit the inventory:
571
+```sh
572
+$EDITOR deploy/ansible/inventory/production
573
+# Change: signup_disabled: false
574
+make deploy ANSIBLE_INVENTORY=production ANSIBLE_TAGS=app
575
+```
576
+
577
+### G4. Update the status page
578
+
579
+```sh
580
+$EDITOR docs/public/status.md
581
+# Update timestamp + "All systems normal."
582
+make docs                       # local mdBook build
583
+deploy/docs-site/sync-to-spaces.sh
584
+```
585
+
586
+### G5. Post the announcement
587
+
588
+`docs/blog/v0.1.0-launch.md` is the copy. Submit to:
589
+- Hacker News
590
+- /r/programming, /r/selfhosted
591
+- lobste.rs
592
+- Mastodon
593
+
594
+You're live. Read `docs/internal/runbooks/day-one.md` next.
595
+
596
+---
597
+
598
+## When something goes wrong
599
+
600
+- **Caddy won't get a cert.** DNS hasn't fully propagated, or
601
+  port 80 is blocked. Check both. Last resort: flip
602
+  `caddy_use_acme_staging=true`, redeploy, get the staging cert
603
+  to confirm everything else works, then flip back when DNS is
604
+  good.
605
+- **`/readyz` returns 503.** DB or storage unreachable. Check
606
+  `journalctl -u shithubd-web` for the specific error.
607
+- **Postmark says emails go through but they don't arrive.**
608
+  Check spam, then check Postmark's Activity tab — every send
609
+  is logged. DKIM may not be propagated yet; first 24h is rough
610
+  on cold-start deliverability.
611
+- **Ansible reports "host unreachable" mid-run.** SSH from
612
+  shithub-app to the failing host's private IP; if that doesn't
613
+  work, you're missing the key in B6.
614
+
615
+Anything else: read `docs/internal/runbooks/incidents.md` and
616
+the `troubleshooting.md` in self-host docs.