diff --git a/apps/docs/src/content/docs/topics/provisioning-with-tofu.mdx b/apps/docs/src/content/docs/topics/provisioning-with-tofu.mdx
index c64ec4bc..b11bb019 100644
--- a/apps/docs/src/content/docs/topics/provisioning-with-tofu.mdx
+++ b/apps/docs/src/content/docs/topics/provisioning-with-tofu.mdx
@@ -21,9 +21,10 @@ import FaqItem from "../../../components/FaqItem.tsx";
{ value: "5 min", label: "Provisioning time" },
]}
>
- One declarative `tofu apply` provisions a Hetzner VPS, configures Cloudflare
- DNS firewalls, installs Docker runtime, and bootstraps your full-stack
- docker-compose environment.
+ One declarative `tofu apply` provisions a Hetzner VPS, configures
+ Cloudflare DNS and a Hetzner firewall scoped to Cloudflare's IP ranges,
+ installs Docker runtime, and bootstraps your full-stack docker-compose
+ environment.
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
{ tone: "ok", text: "Success! The configuration is valid." },
{
tone: "ok",
- text: "Apply complete! Resources: 5 added, 0 changed, 0 destroyed.",
+ text: "Apply complete! Resources: 9 added, 0 changed, 0 destroyed.",
},
]}
/>
@@ -118,6 +119,20 @@ Bootstrap (Docker install, monorepo clone, GHCR image pulls, first `compose up -
output={[{ tone: "ok", text: "status: done" }]}
/>
+
+ `cloud-init status` only knows whether its own steps ran — it goes
+ `done` even if the bootstrap script underneath failed mid-way (GHCR
+ pull error, `compose up` crash, etc.). If `curl /health` doesn't
+ return 200 after a few minutes, the real story is in:
+
+ ```bash
+ ssh root@$(tofu output -raw vps_ipv4)
+ tail -200 /var/log/cloud-init-output.log # bootstrap.sh stdout/stderr
+ docker compose -f /opt/boringstack/infra/compose/compose/docker-compose.yml ps
+ docker compose logs traefik api ui # whichever isn't healthy
+ ```
+
+
## Verify
-
- TLS handshake failed; ACME has not issued yet: wait or check Traefik logs.
+
+ Expected for the first 2–5 minutes after `apply`. Traefik needs DNS
+ to propagate globally before Let's Encrypt can complete the HTTP-01
+ challenge; until the cert lands, Cloudflare can't validate the
+ origin and serves 525. Traefik retries on its own — confirm with
+ `docker compose logs traefik | grep -i acme`. If it's still
+ failing past ~10 min, check that the apex A/AAAA records resolve
+ publicly (`dig +short @1.1.1.1 `).
Stale OpenTofu language-server cache. Run `tofu init` once and re-open.
diff --git a/infra/bootstrap/main.tf b/infra/bootstrap/main.tf
index d9e5091d..b10e127d 100644
--- a/infra/bootstrap/main.tf
+++ b/infra/bootstrap/main.tf
@@ -57,20 +57,22 @@ module "bootstrap" {
acme_email = var.acme_email
# Optional integrations
- email_provider = var.email_provider
- email_from = var.email_from
- cloudflare_account_id = var.cloudflare_account_id
- cloudflare_email_api_token = var.cloudflare_email_api_token
- resend_api_key = var.resend_api_key
- sendgrid_api_key = var.sendgrid_api_key
- google_oauth_client_id = var.google_oauth_client_id
- google_oauth_client_secret = var.google_oauth_client_secret
- github_oauth_client_id = var.github_oauth_client_id
- github_oauth_client_secret = var.github_oauth_client_secret
- stripe_secret_key = var.stripe_secret_key
- stripe_webhook_secret = var.stripe_webhook_secret
- sentry_dsn = var.sentry_dsn
- vite_sentry_dsn = var.vite_sentry_dsn
+ email_provider = var.email_provider
+ email_from = var.email_from
+ cloudflare_account_id = var.cloudflare_account_id
+ cloudflare_email_api_token = var.cloudflare_email_api_token
+ resend_api_key = var.resend_api_key
+ sendgrid_api_key = var.sendgrid_api_key
+ google_oauth_client_id = var.google_oauth_client_id
+ google_oauth_client_secret = var.google_oauth_client_secret
+ github_oauth_client_id = var.github_oauth_client_id
+ github_oauth_client_secret = var.github_oauth_client_secret
+ linkedin_oauth_client_id = var.linkedin_oauth_client_id
+ linkedin_oauth_client_secret = var.linkedin_oauth_client_secret
+ stripe_secret_key = var.stripe_secret_key
+ stripe_webhook_secret = var.stripe_webhook_secret
+ sentry_dsn = var.sentry_dsn
+ vite_sentry_dsn = var.vite_sentry_dsn
# Backups
backups_enabled = var.backups_enabled
diff --git a/infra/bootstrap/modules/bootstrap/main.tf b/infra/bootstrap/modules/bootstrap/main.tf
index e316f991..b7fab90a 100644
--- a/infra/bootstrap/modules/bootstrap/main.tf
+++ b/infra/bootstrap/modules/bootstrap/main.tf
@@ -33,33 +33,35 @@ locals {
ui_image_name = "${local.repo_name}-ui"
compose_env_values = {
- stack = "prod"
- public_ui_host = var.domain
- acme_email = var.acme_email
- image_owner = local.image_owner
- api_image_name = local.api_image_name
- ui_image_name = local.ui_image_name
- postgres_password = var.postgres_password
- valkey_password = var.valkey_password
- jwt_secret = var.jwt_secret
- email_provider = var.email_provider
- email_from = var.email_from
- cloudflare_account_id = var.cloudflare_account_id
- cloudflare_email_api_token = var.cloudflare_email_api_token
- resend_api_key = var.resend_api_key
- sendgrid_api_key = var.sendgrid_api_key
- google_oauth_client_id = var.google_oauth_client_id
- google_oauth_client_secret = var.google_oauth_client_secret
- github_oauth_client_id = var.github_oauth_client_id
- github_oauth_client_secret = var.github_oauth_client_secret
- stripe_secret_key = var.stripe_secret_key
- stripe_webhook_secret = var.stripe_webhook_secret
- sentry_dsn = var.sentry_dsn
- vite_sentry_dsn = var.vite_sentry_dsn
- superuser_email = var.superuser_email
- superuser_password = var.superuser_password
- ghcr_username = var.ghcr_username
- ghcr_token = var.ghcr_token
+ stack = "prod"
+ public_ui_host = var.domain
+ acme_email = var.acme_email
+ image_owner = local.image_owner
+ api_image_name = local.api_image_name
+ ui_image_name = local.ui_image_name
+ postgres_password = var.postgres_password
+ valkey_password = var.valkey_password
+ jwt_secret = var.jwt_secret
+ email_provider = var.email_provider
+ email_from = var.email_from
+ cloudflare_account_id = var.cloudflare_account_id
+ cloudflare_email_api_token = var.cloudflare_email_api_token
+ resend_api_key = var.resend_api_key
+ sendgrid_api_key = var.sendgrid_api_key
+ google_oauth_client_id = var.google_oauth_client_id
+ google_oauth_client_secret = var.google_oauth_client_secret
+ github_oauth_client_id = var.github_oauth_client_id
+ github_oauth_client_secret = var.github_oauth_client_secret
+ linkedin_oauth_client_id = var.linkedin_oauth_client_id
+ linkedin_oauth_client_secret = var.linkedin_oauth_client_secret
+ stripe_secret_key = var.stripe_secret_key
+ stripe_webhook_secret = var.stripe_webhook_secret
+ sentry_dsn = var.sentry_dsn
+ vite_sentry_dsn = var.vite_sentry_dsn
+ superuser_email = var.superuser_email
+ superuser_password = var.superuser_password
+ ghcr_username = var.ghcr_username
+ ghcr_token = var.ghcr_token
}
# Docker Compose treats single-quoted .env values literally, avoiding
diff --git a/infra/bootstrap/modules/bootstrap/templates/compose.env.tftpl b/infra/bootstrap/modules/bootstrap/templates/compose.env.tftpl
index 66fc211e..ee1c78b1 100644
--- a/infra/bootstrap/modules/bootstrap/templates/compose.env.tftpl
+++ b/infra/bootstrap/modules/bootstrap/templates/compose.env.tftpl
@@ -50,6 +50,8 @@ GOOGLE_OAUTH_CLIENT_ID=${google_oauth_client_id}
GOOGLE_OAUTH_CLIENT_SECRET=${google_oauth_client_secret}
GITHUB_OAUTH_CLIENT_ID=${github_oauth_client_id}
GITHUB_OAUTH_CLIENT_SECRET=${github_oauth_client_secret}
+LINKEDIN_OAUTH_CLIENT_ID=${linkedin_oauth_client_id}
+LINKEDIN_OAUTH_CLIENT_SECRET=${linkedin_oauth_client_secret}
# Billing
STRIPE_SECRET_KEY=${stripe_secret_key}
diff --git a/infra/bootstrap/modules/bootstrap/variables.tf b/infra/bootstrap/modules/bootstrap/variables.tf
index e13178ca..60fa87f2 100644
--- a/infra/bootstrap/modules/bootstrap/variables.tf
+++ b/infra/bootstrap/modules/bootstrap/variables.tf
@@ -74,6 +74,15 @@ variable "github_oauth_client_secret" {
sensitive = true
}
+variable "linkedin_oauth_client_id" {
+ type = string
+}
+
+variable "linkedin_oauth_client_secret" {
+ type = string
+ sensitive = true
+}
+
variable "stripe_secret_key" {
type = string
sensitive = true
diff --git a/infra/bootstrap/terraform.tfvars.example b/infra/bootstrap/terraform.tfvars.example
index 3589a16e..61f7b074 100644
--- a/infra/bootstrap/terraform.tfvars.example
+++ b/infra/bootstrap/terraform.tfvars.example
@@ -48,6 +48,28 @@ acme_email = "ops@example.com" # real address; Let's Encrypt rejects exa
# monorepo_repo = "https://github.com/boringstack-xyz/boringstack"
+# ----------------------------------------------------------------------------
+# Private GHCR auth (optional; required if your fork pushes images to a
+# private GitHub Container Registry — leave empty for public images.)
+#
+# GitHub PAT (classic) with `read:packages` scope. Set both or neither;
+# the bootstrap renders a `docker login ghcr.io` only when both are set.
+# ----------------------------------------------------------------------------
+
+# ghcr_username = ""
+# ghcr_token = ""
+
+# ----------------------------------------------------------------------------
+# First-boot superuser seed (optional)
+#
+# When both are set, the api-migrate job creates this user as an admin on
+# first boot. Skip both to register through the UI yourself after apply.
+# Rotate the password from the UI's password-reset flow once you're in.
+# ----------------------------------------------------------------------------
+
+# superuser_email = ""
+# superuser_password = "" # min length enforced by the API env validator
+
# ----------------------------------------------------------------------------
# Email provider (optional; leave empty to disable)
#
diff --git a/infra/bootstrap/variables.tf b/infra/bootstrap/variables.tf
index 310ec3da..e70661cb 100644
--- a/infra/bootstrap/variables.tf
+++ b/infra/bootstrap/variables.tf
@@ -313,7 +313,7 @@ variable "github_oauth_client_id" {
variable "github_oauth_client_secret" {
type = string
- description = ""
+ description = "GitHub OAuth — paired with github_oauth_client_id."
default = ""
sensitive = true
@@ -323,6 +323,29 @@ variable "github_oauth_client_secret" {
}
}
+variable "linkedin_oauth_client_id" {
+ type = string
+ description = "LinkedIn OAuth — leave empty to disable LinkedIn login."
+ default = ""
+
+ validation {
+ condition = length(regexall("[\r\n]", var.linkedin_oauth_client_id)) == 0
+ error_message = "linkedin_oauth_client_id must fit on one line."
+ }
+}
+
+variable "linkedin_oauth_client_secret" {
+ type = string
+ description = "LinkedIn OAuth — paired with linkedin_oauth_client_id."
+ default = ""
+ sensitive = true
+
+ validation {
+ condition = length(regexall("[\r\n]", var.linkedin_oauth_client_secret)) == 0
+ error_message = "linkedin_oauth_client_secret must fit on one line."
+ }
+}
+
variable "stripe_secret_key" {
type = string
description = "Stripe secret key. Leave empty to disable billing."