Skip to content

Documentation Hosting Setup

This page describes the deployment setup for ClearlyOps documentation.

Overview

ClearlyOps documentation is split into two sites:

Site URL Access Content
Public docs.clearlyops.com Public API docs, SDK reference, portfolio-safe content
Internal docs-internal.clearlyops.com Protected Deployment guides, runbooks, internal ops

Both sites are:

  • Built: GitHub Actions generates from code and docstrings
  • Hosted: Cloudflare Pages serves the static sites
  • Secured: Cloudflare Access protects internal docs; secrets fetched from GCP Secret Manager

Architecture

┌─────────────────┐      ┌──────────────────────────────┐
│  GitHub Actions │      │  GCP Secret Manager          │
│  (build/deploy) │      │  (cloudflare-pages-api-token)│
└────────┬────────┘      └──────────────┬───────────────┘
         │                              │
         │  OIDC                        │ gcloud secrets
         ▼                              ▼
┌─────────────────────────────────────────────────────────┐
│                    Deploy Job                           │
│  - Authenticate to GCP via Workload Identity (OIDC)    │
│  - Fetch Cloudflare token from Secret Manager          │
│  - Deploy public site to clearlyops-docs-public        │
│  - Deploy internal site to clearlyops-docs-internal    │
└─────────────────────────────────────────────────────────┘
         │                              │
         ▼                              ▼
┌─────────────────┐      ┌──────────────────────────────┐
│  Public Docs    │      │  Internal Docs               │
│  docs.clearlyops│      │  docs-internal.clearlyops    │
│  (no Access)    │      │  + Cloudflare Access         │
│                 │      │  + pages.dev protected       │
│                 │      │  + preview deploys disabled  │
└─────────────────┘      └──────────────────────────────┘

Agent-Managed Infrastructure

Infrastructure can be managed programmatically using scripts/setup_docs_infra.py:

# Plan changes (dry-run, no mutations)
python scripts/setup_docs_infra.py plan --config deploy/infra/docs.toml

# Apply changes (requires confirmation)
python scripts/setup_docs_infra.py apply --config deploy/infra/docs.toml --confirm docs-internal.clearlyops.com

# Check current status
python scripts/setup_docs_infra.py status --format json

The script: - Fetches Cloudflare API token from GCP Secret Manager (no local secrets) - Ensures Pages projects exist - Ensures custom domains are attached - Ensures DNS records are correct and proxied - Ensures Cloudflare Access protects internal docs (both custom domain AND *.pages.dev) - Outputs a JSON receipt for auditing

Configuration

Infrastructure configuration lives in deploy/infra/docs.toml:

[gcp]
project_id = "your-gcp-project-id"
secret_cloudflare_api_token = "cloudflare-pages-api-token"
workload_identity_provider = "projects/.../providers/..."
service_account_email = "[email protected]"

[cloudflare]
account_id = "your-cloudflare-account-id"
zone_id = "your-cloudflare-zone-id"

[docs.public]
pages_project_name = "clearlyops-docs-public"
custom_domain = "docs.clearlyops.com"

[docs.internal]
pages_project_name = "clearlyops-docs-internal"
custom_domain = "docs-internal.clearlyops.com"
preview_deployments = "disabled"

[access]
allowed_emails = ["[email protected]"]

See deploy/infra/docs.example.toml for a complete example.

GitHub Actions Workflow

The workflow (.github/workflows/docs.yml) uses GitHub OIDC → GCP authentication:

  1. Build job:
  2. Generate docs: python scripts/generate_docs.py
  3. Build public site: mkdocs build --config-file mkdocs.public.yml --site-dir site-public
  4. Build internal site: mkdocs build --config-file mkdocs.internal.yml --site-dir site-internal
  5. Upload artifacts

  6. Deploy job (push only):

  7. Authenticate to GCP using Workload Identity Federation
  8. Fetch Cloudflare token from GCP Secret Manager
  9. Deploy public site to clearlyops-docs-public
  10. Deploy internal site to clearlyops-docs-internal

Required GitHub Variables (not secrets)

These are stored as repository variables (Settings → Secrets and variables → Variables):

Variable Description
CLOUDFLARE_ACCOUNT_ID Cloudflare account ID
GCP_PROJECT_ID GCP project ID
GCP_WORKLOAD_IDENTITY_PROVIDER Workload Identity provider resource name
GCP_SERVICE_ACCOUNT Service account email for deployments

No Cloudflare API token in GitHub — it's fetched from GCP Secret Manager at runtime.

One-Time Bootstrap

1. Create Cloudflare API Token

  1. Cloudflare Dashboard → Profile → API Tokens
  2. Create Token with permissions:
  3. AccountCloudflare PagesEdit
  4. AccountAccess: Apps and PoliciesEdit
  5. ZoneDNSEdit (for your zone)
  6. Copy the token

2. Store Token in GCP Secret Manager

export GOOGLE_CLOUD_PROJECT=your-project-id

# Create the secret
gcloud secrets create cloudflare-pages-api-token \
  --replication-policy="automatic" \
  --project=$GOOGLE_CLOUD_PROJECT

# Add the token value
echo "YOUR_CLOUDFLARE_TOKEN" | gcloud secrets versions add cloudflare-pages-api-token \
  --data-file=- \
  --project=$GOOGLE_CLOUD_PROJECT

3. Set Up Workload Identity Federation

# Create Workload Identity Pool
gcloud iam workload-identity-pools create github-pool \
  --location="global" \
  --display-name="GitHub Actions Pool" \
  --project=$GOOGLE_CLOUD_PROJECT

# Create Provider
gcloud iam workload-identity-pools providers create-oidc github-provider \
  --location="global" \
  --workload-identity-pool="github-pool" \
  --display-name="GitHub Provider" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --project=$GOOGLE_CLOUD_PROJECT

# Create Service Account
gcloud iam service-accounts create docs-deployer \
  --display-name="Docs Deployer" \
  --project=$GOOGLE_CLOUD_PROJECT

# Grant secret access
gcloud secrets add-iam-policy-binding cloudflare-pages-api-token \
  --member="serviceAccount:docs-deployer@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com" \
  --role="roles/secretmanager.secretAccessor" \
  --project=$GOOGLE_CLOUD_PROJECT

# Allow GitHub to impersonate
gcloud iam service-accounts add-iam-policy-binding \
  docs-deployer@${GOOGLE_CLOUD_PROJECT}.iam.gserviceaccount.com \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/YOUR_ORG/clearlyops" \
  --project=$GOOGLE_CLOUD_PROJECT

4. Configure GitHub Repository Variables

Add these as repository variables (not secrets):

  • CLOUDFLARE_ACCOUNT_ID
  • GCP_PROJECT_ID
  • GCP_WORKLOAD_IDENTITY_PROVIDER (full resource name)
  • GCP_SERVICE_ACCOUNT (email)

5. Run Infrastructure Setup

python scripts/setup_docs_infra.py apply --config deploy/infra/docs.toml --confirm docs-internal.clearlyops.com

Bypass Prevention

The internal docs are protected against bypass:

  1. Custom domain protected: docs-internal.clearlyops.com requires Cloudflare Access login
  2. Pages.dev protected: clearlyops-docs-internal.pages.dev also requires login
  3. Preview deployments disabled: No PR preview URLs are generated for internal docs

Local Development

# Install dependencies
pip install -e ".[docs]"

# Generate dynamic content
python scripts/generate_docs.py

# Build public site
mkdocs build --strict --config-file mkdocs.public.yml --site-dir site-public

# Build internal site
mkdocs build --strict --config-file mkdocs.internal.yml --site-dir site-internal

# Serve public site locally
mkdocs serve --config-file mkdocs.public.yml
# Visit http://127.0.0.1:8000

# Serve internal site locally
mkdocs serve --config-file mkdocs.internal.yml
# Visit http://127.0.0.1:8000

Troubleshooting

Build fails

  • Check [docs] dependencies: pip install -e ".[docs]"
  • Run mkdocs build --strict locally to see errors
  • Check for broken links in markdown files

Deploy fails with auth error

  • Verify GCP Workload Identity is configured correctly
  • Check service account has secretmanager.secretAccessor role
  • Verify repository variable names match workflow

Access control not working

  • Use scripts/setup_docs_infra.py status to verify Access app exists
  • Check both custom domain and *.pages.dev are protected
  • Verify allowed emails in deploy/infra/docs.toml

Secret not found

  • Verify secret exists: gcloud secrets describe cloudflare-pages-api-token
  • Check service account permissions
  • Ensure GCP_PROJECT_ID is correct

Security Checklist

  • [ ] Cloudflare API token stored in GCP Secret Manager (not GitHub)
  • [ ] GitHub uses OIDC → GCP (no long-lived credentials)
  • [ ] Internal docs protected by Cloudflare Access
  • [ ] Both docs-internal.clearlyops.com AND *.pages.dev protected
  • [ ] Preview deployments disabled for internal project
  • [ ] Service account has minimal permissions (only secret access)
  • [ ] Regular rotation of Cloudflare API token

Reference