Skip to content

Commit 3e48e1d

Browse files
authored
docs(tofu): close end-to-end gaps so a fresh apply works from the docs (#57)
A real run against the provisioning-with-tofu doc surfaced four gaps: 1. terraform.tfvars.example was missing slots for ghcr_username, ghcr_token, superuser_email, and superuser_password — all declared in variables.tf and wired through main.tf, but invisible to an operator following the docs. 2. LinkedIn OAuth was half-wired: the API has a full LinkedIn provider (apps/api/src/lib/oauth/providers/linkedin.ts) and apps/api/.env.example exposes LINKEDIN_OAUTH_CLIENT_{ID,SECRET}, but the tofu side was missing — no variable declarations, no module wiring, no template rendering. tfvars.example had commented entries that would emit "undeclared variable" warnings if uncommented. Wired through the same pattern as Google/GitHub (root variables.tf → bootstrap module variables.tf → main.tf passthrough → compose.env.tftpl render). 3. PageIntro said "configures Cloudflare DNS firewalls" — reads like Cloudflare's paid DNS Firewall product. Tweaked to "Cloudflare DNS and a Hetzner firewall scoped to Cloudflare's IP ranges". 4. Sample output claimed "Resources: 5 added" — actual count is 9 (server, SSH key, firewall, apex A/AAAA, www CNAME, two zone settings, redirect ruleset). Two pieces of operator guidance also added: - `cloud-init status: done` only means cloud-init's own steps ran — it doesn't reflect bootstrap.sh failures inside the script. Added a callout pointing at /var/log/cloud-init-output.log + the right docker compose commands to find the actual problem. - "Site returns 525" on first apply is normal for 2–5 minutes while DNS propagates and ACME issues the first cert. Expanded the troubleshooting entry to set expectations + suggest the dig + Traefik log commands operators actually need. Also fixed a stray empty description on github_oauth_client_secret. tofu fmt + validate clean. Docs build clean (67 pages).
1 parent a4ad0b9 commit 3e48e1d

7 files changed

Lines changed: 129 additions & 48 deletions

File tree

apps/docs/src/content/docs/topics/provisioning-with-tofu.mdx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import FaqItem from "../../../components/FaqItem.tsx";
2121
{ value: "5 min", label: "Provisioning time" },
2222
]}
2323
>
24-
One declarative `tofu apply` provisions a Hetzner VPS, configures Cloudflare
25-
DNS firewalls, installs Docker runtime, and bootstraps your full-stack
26-
docker-compose environment.
24+
One declarative `tofu apply` provisions a Hetzner VPS, configures
25+
Cloudflare DNS and a Hetzner firewall scoped to Cloudflare's IP ranges,
26+
installs Docker runtime, and bootstraps your full-stack docker-compose
27+
environment.
2728
</PageIntro>
2829

2930
BoringStack's [Deployment](/topics/deployment/) path is manual: SSH into a VPS, install Docker, clone the monorepo, `compose pull && compose up -d`. Some operators prefer this.
@@ -100,7 +101,7 @@ flowchart LR
100101
{ tone: "ok", text: "Success! The configuration is valid." },
101102
{
102103
tone: "ok",
103-
text: "Apply complete! Resources: 5 added, 0 changed, 0 destroyed.",
104+
text: "Apply complete! Resources: 9 added, 0 changed, 0 destroyed.",
104105
},
105106
]}
106107
/>
@@ -118,6 +119,20 @@ Bootstrap (Docker install, monorepo clone, GHCR image pulls, first `compose up -
118119
output={[{ tone: "ok", text: "status: done" }]}
119120
/>
120121

122+
<DocCallout type="note" title="`status: done` reflects cloud-init, not bootstrap.sh">
123+
`cloud-init status` only knows whether its own steps ran — it goes
124+
`done` even if the bootstrap script underneath failed mid-way (GHCR
125+
pull error, `compose up` crash, etc.). If `curl /health` doesn't
126+
return 200 after a few minutes, the real story is in:
127+
128+
```bash
129+
ssh root@$(tofu output -raw vps_ipv4)
130+
tail -200 /var/log/cloud-init-output.log # bootstrap.sh stdout/stderr
131+
docker compose -f /opt/boringstack/infra/compose/compose/docker-compose.yml ps
132+
docker compose logs traefik api ui # whichever isn't healthy
133+
```
134+
</DocCallout>
135+
121136
## Verify
122137

123138
<CommandRun
@@ -350,8 +365,14 @@ Wipes the Hetzner server, removes the Cloudflare records, deletes the firewall a
350365
Origin not responding: check `docker compose logs traefik api` on the
351366
server.
352367
</FaqItem>
353-
<FaqItem title="Site returns 525 from Cloudflare">
354-
TLS handshake failed; ACME has not issued yet: wait or check Traefik logs.
368+
<FaqItem title="Site returns 525 from Cloudflare on first apply">
369+
Expected for the first 2–5 minutes after `apply`. Traefik needs DNS
370+
to propagate globally before Let's Encrypt can complete the HTTP-01
371+
challenge; until the cert lands, Cloudflare can't validate the
372+
origin and serves 525. Traefik retries on its own — confirm with
373+
`docker compose logs traefik | grep -i acme`. If it's still
374+
failing past ~10 min, check that the apex A/AAAA records resolve
375+
publicly (`dig +short @1.1.1.1 <domain>`).
355376
</FaqItem>
356377
<FaqItem title="Unexpected attribute errors in the editor">
357378
Stale OpenTofu language-server cache. Run `tofu init` once and re-open.

infra/bootstrap/main.tf

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,22 @@ module "bootstrap" {
5757
acme_email = var.acme_email
5858

5959
# Optional integrations
60-
email_provider = var.email_provider
61-
email_from = var.email_from
62-
cloudflare_account_id = var.cloudflare_account_id
63-
cloudflare_email_api_token = var.cloudflare_email_api_token
64-
resend_api_key = var.resend_api_key
65-
sendgrid_api_key = var.sendgrid_api_key
66-
google_oauth_client_id = var.google_oauth_client_id
67-
google_oauth_client_secret = var.google_oauth_client_secret
68-
github_oauth_client_id = var.github_oauth_client_id
69-
github_oauth_client_secret = var.github_oauth_client_secret
70-
stripe_secret_key = var.stripe_secret_key
71-
stripe_webhook_secret = var.stripe_webhook_secret
72-
sentry_dsn = var.sentry_dsn
73-
vite_sentry_dsn = var.vite_sentry_dsn
60+
email_provider = var.email_provider
61+
email_from = var.email_from
62+
cloudflare_account_id = var.cloudflare_account_id
63+
cloudflare_email_api_token = var.cloudflare_email_api_token
64+
resend_api_key = var.resend_api_key
65+
sendgrid_api_key = var.sendgrid_api_key
66+
google_oauth_client_id = var.google_oauth_client_id
67+
google_oauth_client_secret = var.google_oauth_client_secret
68+
github_oauth_client_id = var.github_oauth_client_id
69+
github_oauth_client_secret = var.github_oauth_client_secret
70+
linkedin_oauth_client_id = var.linkedin_oauth_client_id
71+
linkedin_oauth_client_secret = var.linkedin_oauth_client_secret
72+
stripe_secret_key = var.stripe_secret_key
73+
stripe_webhook_secret = var.stripe_webhook_secret
74+
sentry_dsn = var.sentry_dsn
75+
vite_sentry_dsn = var.vite_sentry_dsn
7476

7577
# Backups
7678
backups_enabled = var.backups_enabled

infra/bootstrap/modules/bootstrap/main.tf

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -33,33 +33,35 @@ locals {
3333
ui_image_name = "${local.repo_name}-ui"
3434

3535
compose_env_values = {
36-
stack = "prod"
37-
public_ui_host = var.domain
38-
acme_email = var.acme_email
39-
image_owner = local.image_owner
40-
api_image_name = local.api_image_name
41-
ui_image_name = local.ui_image_name
42-
postgres_password = var.postgres_password
43-
valkey_password = var.valkey_password
44-
jwt_secret = var.jwt_secret
45-
email_provider = var.email_provider
46-
email_from = var.email_from
47-
cloudflare_account_id = var.cloudflare_account_id
48-
cloudflare_email_api_token = var.cloudflare_email_api_token
49-
resend_api_key = var.resend_api_key
50-
sendgrid_api_key = var.sendgrid_api_key
51-
google_oauth_client_id = var.google_oauth_client_id
52-
google_oauth_client_secret = var.google_oauth_client_secret
53-
github_oauth_client_id = var.github_oauth_client_id
54-
github_oauth_client_secret = var.github_oauth_client_secret
55-
stripe_secret_key = var.stripe_secret_key
56-
stripe_webhook_secret = var.stripe_webhook_secret
57-
sentry_dsn = var.sentry_dsn
58-
vite_sentry_dsn = var.vite_sentry_dsn
59-
superuser_email = var.superuser_email
60-
superuser_password = var.superuser_password
61-
ghcr_username = var.ghcr_username
62-
ghcr_token = var.ghcr_token
36+
stack = "prod"
37+
public_ui_host = var.domain
38+
acme_email = var.acme_email
39+
image_owner = local.image_owner
40+
api_image_name = local.api_image_name
41+
ui_image_name = local.ui_image_name
42+
postgres_password = var.postgres_password
43+
valkey_password = var.valkey_password
44+
jwt_secret = var.jwt_secret
45+
email_provider = var.email_provider
46+
email_from = var.email_from
47+
cloudflare_account_id = var.cloudflare_account_id
48+
cloudflare_email_api_token = var.cloudflare_email_api_token
49+
resend_api_key = var.resend_api_key
50+
sendgrid_api_key = var.sendgrid_api_key
51+
google_oauth_client_id = var.google_oauth_client_id
52+
google_oauth_client_secret = var.google_oauth_client_secret
53+
github_oauth_client_id = var.github_oauth_client_id
54+
github_oauth_client_secret = var.github_oauth_client_secret
55+
linkedin_oauth_client_id = var.linkedin_oauth_client_id
56+
linkedin_oauth_client_secret = var.linkedin_oauth_client_secret
57+
stripe_secret_key = var.stripe_secret_key
58+
stripe_webhook_secret = var.stripe_webhook_secret
59+
sentry_dsn = var.sentry_dsn
60+
vite_sentry_dsn = var.vite_sentry_dsn
61+
superuser_email = var.superuser_email
62+
superuser_password = var.superuser_password
63+
ghcr_username = var.ghcr_username
64+
ghcr_token = var.ghcr_token
6365
}
6466

6567
# Docker Compose treats single-quoted .env values literally, avoiding

infra/bootstrap/modules/bootstrap/templates/compose.env.tftpl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ GOOGLE_OAUTH_CLIENT_ID=${google_oauth_client_id}
5050
GOOGLE_OAUTH_CLIENT_SECRET=${google_oauth_client_secret}
5151
GITHUB_OAUTH_CLIENT_ID=${github_oauth_client_id}
5252
GITHUB_OAUTH_CLIENT_SECRET=${github_oauth_client_secret}
53+
LINKEDIN_OAUTH_CLIENT_ID=${linkedin_oauth_client_id}
54+
LINKEDIN_OAUTH_CLIENT_SECRET=${linkedin_oauth_client_secret}
5355

5456
# Billing
5557
STRIPE_SECRET_KEY=${stripe_secret_key}

infra/bootstrap/modules/bootstrap/variables.tf

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ variable "github_oauth_client_secret" {
7474
sensitive = true
7575
}
7676

77+
variable "linkedin_oauth_client_id" {
78+
type = string
79+
}
80+
81+
variable "linkedin_oauth_client_secret" {
82+
type = string
83+
sensitive = true
84+
}
85+
7786
variable "stripe_secret_key" {
7887
type = string
7988
sensitive = true

infra/bootstrap/terraform.tfvars.example

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,28 @@ acme_email = "ops@example.com" # real address; Let's Encrypt rejects exa
4848

4949
# monorepo_repo = "https://github.com/boringstack-xyz/boringstack"
5050

51+
# ----------------------------------------------------------------------------
52+
# Private GHCR auth (optional; required if your fork pushes images to a
53+
# private GitHub Container Registry — leave empty for public images.)
54+
#
55+
# GitHub PAT (classic) with `read:packages` scope. Set both or neither;
56+
# the bootstrap renders a `docker login ghcr.io` only when both are set.
57+
# ----------------------------------------------------------------------------
58+
59+
# ghcr_username = ""
60+
# ghcr_token = ""
61+
62+
# ----------------------------------------------------------------------------
63+
# First-boot superuser seed (optional)
64+
#
65+
# When both are set, the api-migrate job creates this user as an admin on
66+
# first boot. Skip both to register through the UI yourself after apply.
67+
# Rotate the password from the UI's password-reset flow once you're in.
68+
# ----------------------------------------------------------------------------
69+
70+
# superuser_email = ""
71+
# superuser_password = "" # min length enforced by the API env validator
72+
5173
# ----------------------------------------------------------------------------
5274
# Email provider (optional; leave empty to disable)
5375
#

infra/bootstrap/variables.tf

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ variable "github_oauth_client_id" {
313313

314314
variable "github_oauth_client_secret" {
315315
type = string
316-
description = ""
316+
description = "GitHub OAuth — paired with github_oauth_client_id."
317317
default = ""
318318
sensitive = true
319319

@@ -323,6 +323,29 @@ variable "github_oauth_client_secret" {
323323
}
324324
}
325325

326+
variable "linkedin_oauth_client_id" {
327+
type = string
328+
description = "LinkedIn OAuth — leave empty to disable LinkedIn login."
329+
default = ""
330+
331+
validation {
332+
condition = length(regexall("[\r\n]", var.linkedin_oauth_client_id)) == 0
333+
error_message = "linkedin_oauth_client_id must fit on one line."
334+
}
335+
}
336+
337+
variable "linkedin_oauth_client_secret" {
338+
type = string
339+
description = "LinkedIn OAuth — paired with linkedin_oauth_client_id."
340+
default = ""
341+
sensitive = true
342+
343+
validation {
344+
condition = length(regexall("[\r\n]", var.linkedin_oauth_client_secret)) == 0
345+
error_message = "linkedin_oauth_client_secret must fit on one line."
346+
}
347+
}
348+
326349
variable "stripe_secret_key" {
327350
type = string
328351
description = "Stripe secret key. Leave empty to disable billing."

0 commit comments

Comments
 (0)