Plan: Deploy Vault to brain.ehq.tech

Status: Draft Owner: @bilal Last Updated: 2026-02-15 Objective: Publish the EHQ Obsidian vault as an auth-gated static site at brain.ehq.tech using Quartz, Cloudflare Pages, Cloudflare Access, and Terraform


Architecture

Obsidian vault (docs/)
  ↓  Quartz reads directly via --directory docs
Quartz repo (envo-docs/)
  ↓  GitHub Actions
Cloudflare Pages (static hosting, edge CDN, auto SSL)
  ↓  CNAME
brain.ehq.tech
  ↓  Cloudflare Access (Zero Trust)
Auth gate — team-only, email OTP, 7-day sessions

Why These Choices

DecisionRationale
QuartzPurpose-built for Obsidian vaults, supports wikilinks, graph view, search
Cloudflare PagesFree tier (500 builds/mo), edge CDN, auto SSL, git-triggered deploys
Cloudflare AccessZero Trust auth gate, free for <50 users, email OTP — no public exposure
TerraformIaC-managed infra, reproducible, Cloudflare provider is first-party
brain.ehq.techAvoids collision with future docs.ehq.tech (API docs) or app.ehq.tech

IaC Tooling: Terraform + Cloudflare Provider

Terraform is the pragmatic choice — the Cloudflare provider is official and well-documented. State lives locally initially, can move to Terraform Cloud or S3 backend later if needed.


Phase 1: Repository Setup

1.1 Create envo-docs repo

git clone https://github.com/jackyzha0/quartz.git envo-docs
cd envo-docs
npm i

1.2 Content layout

Vault content lives in docs/. Quartz reads it directly via the --directory docs CLI flag — no symlinks, no copying.

envo-docs/
├── docs/                      # vault content (Quartz reads via --directory docs)
│   ├── 00-Vision/
│   ├── 01-Architecture/
│   ├── 02-Workstreams/
│   ├── 03-Ops/
│   ├── 04-Team/
│   ├── 05-Specs/
│   └── index.md               # landing page
├── infra/                     # Terraform (Pages + DNS + Access)
├── quartz.config.ts
├── quartz.layout.ts
├── .github/workflows/
└── ...

1.3 Configure Quartz

Edit quartz.config.ts:

const config: QuartzConfig = {
  configuration: {
    pageTitle: "EHQ Brain",
    baseUrl: "brain.ehq.tech",
    ignorePatterns: ["Templates", "private", ".obsidian"],
    // ...
  },
  // ...
}

1.4 Content sync

Vault content lives in docs/ within the repo. Vault repo (ehq) triggers envo-docs rebuild via repository_dispatch. GitHub Action clones vault content into docs/ at build time. Vault stays private, envo-docs is the build/deploy repo.


Phase 2: IaC — Terraform

2.1 Directory structure

envo-docs/
├── infra/
│   ├── main.tf               # Pages + DNS + Cloudflare Access
│   ├── variables.tf
│   ├── outputs.tf
│   ├── terraform.tfvars      # (gitignored — contains API token)
│   └── .terraform.lock.hcl
├── docs/                      # vault content (Quartz reads via --directory docs)
├── quartz.config.ts
├── .github/
│   └── workflows/
│       └── deploy.yml
└── ...

2.2 Terraform config

# infra/main.tf
 
terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5.0"
    }
  }
}
 
provider "cloudflare" {
  api_token = var.cloudflare_api_token
}
 
# --- Data: look up existing zone ---
data "cloudflare_zone" "ehq" {
  filter = {
    name = "ehq.tech"
  }
}
 
# =============================================
# Cloudflare Pages — static site hosting
# =============================================
 
resource "cloudflare_pages_project" "brain" {
  account_id        = var.cloudflare_account_id
  name              = "ehq-brain"
  production_branch = "main"
 
  build_config = {
    build_command   = "npx quartz build --directory docs"
    destination_dir = "public"
  }
 
  source = {
    type = "github"
    config = {
      owner             = var.github_owner
      repo_name         = "envo-docs"
      production_branch = "main"
    }
  }
 
  deployment_configs = {
    production = {
      environment_variables = {}
    }
  }
}
 
# =============================================
# DNS — CNAME brain.ehq.tech -> Pages
# =============================================
 
resource "cloudflare_dns_record" "brain" {
  zone_id = data.cloudflare_zone.ehq.zone_id
  name    = "brain"
  type    = "CNAME"
  content = "${cloudflare_pages_project.brain.name}.pages.dev"
  proxied = true    # must be proxied for Access to work
}
 
resource "cloudflare_pages_domain" "brain" {
  account_id   = var.cloudflare_account_id
  project_name = cloudflare_pages_project.brain.name
  domain       = "brain.ehq.tech"
}
 
# =============================================
# Cloudflare Access (Zero Trust) — auth gate
# =============================================
# Every request to brain.ehq.tech hits the Access login
# before reaching the Pages site. Free for up to 50 users.
 
resource "cloudflare_zero_trust_access_application" "brain" {
  zone_id                   = data.cloudflare_zone.ehq.zone_id
  name                      = "EHQ Brain"
  domain                    = "brain.ehq.tech"
  type                      = "self_hosted"
  session_duration          = "168h"        # 7 days before re-auth
  auto_redirect_to_identity = false         # show login page
  app_launcher_visible      = true
}
 
# Policy: allow specific team emails
resource "cloudflare_zero_trust_access_policy" "brain_team" {
  zone_id        = data.cloudflare_zone.ehq.zone_id
  application_id = cloudflare_zero_trust_access_application.brain.id
  name           = "EHQ team"
  precedence     = 1
  decision       = "allow"
 
  include = [{
    email_list = {
      emails = var.team_emails
    }
  }]
}
 
# Policy: deny everyone else (explicit, defence in depth)
resource "cloudflare_zero_trust_access_policy" "brain_deny_all" {
  zone_id        = data.cloudflare_zone.ehq.zone_id
  application_id = cloudflare_zero_trust_access_application.brain.id
  name           = "Deny all others"
  precedence     = 2
  decision       = "deny"
 
  include = [{
    everyone = {}
  }]
}
# infra/variables.tf
 
variable "cloudflare_api_token" {
  type      = string
  sensitive = true
}
 
variable "cloudflare_account_id" {
  type = string
}
 
variable "github_owner" {
  type    = string
  default = "bilal-tariq" # adjust to your GitHub username/org
}
 
variable "team_emails" {
  type        = list(string)
  description = "Email addresses allowed to access brain.ehq.tech"
  default = [
    "bilal@ehq.tech",
    "deen@ehq.tech",
    "danny@ehq.tech",
  ]
}
# infra/outputs.tf
 
output "pages_url" {
  value = "https://${cloudflare_pages_project.brain.name}.pages.dev"
}
 
output "custom_domain" {
  value = "https://brain.ehq.tech"
}
 
output "access_app_id" {
  value = cloudflare_zero_trust_access_application.brain.id
}

2.3 Secrets

# Create a Cloudflare API token with these permissions:
#   - Zone:DNS:Edit (for ehq.tech zone)
#   - Account:Cloudflare Pages:Edit
#   - Account:Account Settings:Read
#   - Account:Access: Apps and Policies:Edit
#   - Account:Access: Organizations, Identity Providers, and Groups:Edit
 
# infra/terraform.tfvars (gitignored!)
cloudflare_api_token  = "your-token-here"
cloudflare_account_id = "your-account-id"

2.4 Lock down the .pages.dev bypass

Cloudflare Access only protects the custom domain (brain.ehq.tech). The default ehq-brain.pages.dev URL bypasses Access and is publicly reachable. To close this hole:

Option A: Terraform (preferred, if supported by provider version)

# Not yet supported in all provider versions — check docs
# If available, add to cloudflare_pages_project:
#   preview_deployment_setting = "none"

Option B: _headers file in Quartz build output

Add a static/_headers file to the Quartz project:

https://:project.pages.dev/*
  X-Robots-Tag: noindex

This doesn’t block access but prevents indexing. True lockdown requires the dashboard toggle or Wrangler:

# Via Wrangler CLI (can be run in CI after deploy)
npx wrangler pages project update ehq-brain --production-branch main

Option C: Cloudflare Dashboard (manual fallback)

Pages project → Settings → General → Disable pages.dev subdomain

Do this once after the first deploy. Not IaC-managed yet, but closes the gap.

2.5 Apply

cd envo-docs/infra
terraform init
terraform plan     # review: should show Pages project + DNS + Access app + policies
terraform apply    # creates everything

Phase 3: CI/CD — GitHub Actions

3.1 Deploy workflow

# .github/workflows/deploy.yml
name: Deploy to Cloudflare Pages
 
on:
  push:
    branches: [main]
  repository_dispatch:
    types: [vault-updated]
 
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
 
      - name: Install dependencies
        run: npm ci
 
      - name: Build Quartz
        run: npx quartz build --directory docs
 
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy public --project-name=ehq-brain

3.2 Vault sync trigger

Add to the vault repo (ehq) to auto-rebuild when vault content is pushed:

# .github/workflows/trigger-site.yml
name: Trigger site rebuild
on:
  push:
    branches: [main]
jobs:
  trigger:
    runs-on: ubuntu-latest
    steps:
      - uses: peter-evans/repository-dispatch@v3
        with:
          token: ${{ secrets.SITE_REPO_PAT }}
          repository: your-org/envo-docs
          event-type: vault-updated

3.3 GitHub Actions secrets to configure

SecretWhereValue
CLOUDFLARE_API_TOKENenvo-docs repoSame token as Terraform
CLOUDFLARE_ACCOUNT_IDenvo-docs repoYour CF account ID
SITE_REPO_PATehq vault repoGitHub PAT with repo scope for envo-docs

Checklist

Prerequisites

  • Cloudflare account with ehq.tech zone active
  • Cloudflare API token created (DNS + Pages + Access permissions)
  • Terraform installed (brew install terraform)
  • GitHub repo envo-docs created
  • ehq.tech email addresses set up (for Access OTP) — or use personal emails initially

Phase 1: Repo

  • Clone Quartz into envo-docs
  • Set up docs/ directory with vault content
  • Configure quartz.config.ts (baseUrl, ignorePatterns)
  • Test locally: npx quartz build --serve --directory docs

Phase 2: Terraform

  • Write infra/main.tf, variables.tf, outputs.tf
  • Create terraform.tfvars (gitignored)
  • terraform init && terraform plan
  • terraform apply — creates Pages + DNS + Access
  • Disable .pages.dev subdomain (dashboard or CLI)

Phase 3: CI/CD

  • Add GitHub Actions secrets to envo-docs
  • Push deploy workflow, verify auto-deploy
  • Add vault sync trigger to ehq repo
  • Verify end-to-end: vault push → site rebuild → deploy

Verification

  • brain.ehq.tech shows Cloudflare Access login gate
  • Team emails receive OTP and can authenticate
  • After auth, Quartz site renders with vault content
  • ehq-brain.pages.dev is disabled or inaccessible
  • SSL working on custom domain

Post-deploy

  • Update 03-Ops/Domain & Email Setup.md with brain.ehq.tech
  • Update 03-Ops/Infrastructure.md with Cloudflare Pages + Access entry
  • Update 04-Team/Ways of Working.md — publishing section is now live

Decisions Still Needed

DecisionOptionsDefault
Terraform state backendLocal / Terraform Cloud / S3Local initially
Excluded foldersWhich vault folders to hide from siteTemplates, .obsidian
GitHub owner/orgPersonal account or org for envo-docsTBD
Access auth methodEmail OTP / Google / GitHubEmail OTP (simplest)
.pages.dev lockdownTerraform / Wrangler CLI / DashboardDashboard (one-time)

Future: Vercel for envo-dashboard

This plan is only for the vault site. The envo-dashboard (Next.js) should go to Vercel separately. When that time comes:

  • Terraform has a Vercel provider
  • Can live in envo-dashboard/infra/ or a shared infra/ repo
  • app.ehq.tech or dashboard.ehq.tech → Vercel
  • brain.ehq.tech → Cloudflare Pages (this plan)

Both managed via Terraform, same IaC pattern.