This document provides step-by-step procedures for rotating all tokens and secrets used in the ghost-stack infrastructure. Regular rotation is critical for maintaining security hygiene.
- Overview
- Token Inventory
- CI/CD Tokens
- Ghost Application Secrets (Infisical)
- Claude Code Integration
- Verification Procedures
Secrets in this project are stored in three primary locations:
| Location | Purpose | Access Method |
|---|---|---|
| Infisical | Ghost application secrets fetched at boot | Infisical CLI / UI; instance uses machine identity |
| Bitwarden Secrets Manager | Runtime secrets for OpenTofu and scripts | bws CLI via infra-shell.sh |
| GitHub Secrets | CI/CD workflow secrets | GitHub Actions environment variables |
GitHub secrets are scoped at two levels:
| Scope | Usage | Naming Convention |
|---|---|---|
| Repository-level | PR workflows (cannot access environment secrets) | SECRET_NAME_DEV suffix |
| Environment-scoped (dev) | Deploy workflows (environment: dev) |
SECRET_NAME (no suffix) |
| Environment-scoped (dev-ci) | PR workflows (environment: dev-ci) |
SECRET_NAME (no suffix) |
Important: When a secret is environment-scoped, you must update it in the GitHub environment settings (Settings → Environments → dev or dev-ci), not in the repository secrets. Secrets shared between dev and dev-ci must be updated in both environments.
| Token | Source | GitHub Secret | Env Scope | Expiration |
|---|---|---|---|---|
| GHCR RW Token | GitHub PAT | GHCR_TOKEN |
Repository | 30 days |
| BWS Access Token¹ | Bitwarden | BWS_ACCESS_TOKEN | Environment (dev) | 30 days |
| Cloudflare API Token | Cloudflare | N/A | N/A | Configurable |
| Cloudflare Token Creator | Cloudflare | N/A | N/A | 30 days recommended |
| Cloudflare Bootstrap Token | Cloudflare | N/A | N/A | 30 days recommended |
| R2 Access Key ID | Cloudflare R2 | N/A | N/A | 30 days |
| R2 Secret Access Key | Cloudflare R2 | N/A | N/A | 30 days |
| Vultr API Key | Vultr | N/A | N/A | 30 days |
| Tailscale API Key | Tailscale | N/A | N/A | 90 days default |
| Tailscale Auth Key | Tailscale (OpenTofu) | N/A | N/A | One-time (single use) |
| PagerDuty Client ID | PagerDuty | N/A | N/A | Never |
| PagerDuty Client Secret | PagerDuty | N/A | N/A | Never |
| PagerDuty User Token | PagerDuty | N/A | N/A | Never |
| Grafana Cloud Token | Grafana | N/A | N/A | 30 days |
| Grafana Cloud SA Token | Grafana | N/A | N/A | 30 days |
| TinyBird Workspace Admin | TinyBird | N/A (instance) | N/A | Never |
| TinyBird Tracker Token | TinyBird | N/A (instance) | N/A | Never |
| R2 Backup Access Key | Cloudflare R2 / Infisical | N/A (instance) | N/A | 90 days |
| R2 Backup Secret Access Key | Cloudflare R2 / Infisical | N/A (instance) | N/A | 90 days |
| Health Check Token | Infisical | HEALTH_CHECK_TOKEN | Environment (dev) | N/A |
| Admin IP | N/A | ADMIN_IP | Environment (dev) | N/A |
| Cloudflare Zone ID | N/A | CLOUDFLARE_ZONE_ID | Environment (dev) | N/A |
| Claude MCP Token | GitHub PAT | N/A (local) | N/A | Configurable |
| Linear API Token | Linear | N/A (local) | N/A | Never |
¹ BWS Access Tokens expire after 30 days; rotate before expiry.
These tokens are used by GitHub Actions workflows, OpenTofu infrastructure provisioning, and supporting infrastructure services. Most are stored in Bitwarden Secrets Manager and retrieved at runtime by infra-shell.sh.
Purpose: Authenticate to GitHub Container Registry to pull the ghost-stack-shell image in CI/CD workflows.
Scope: Repository-level (used by both PR and deploy workflows)
Expiration: 30 days
-
Generate new token:
- Go to GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)
- Click "Generate new token (classic)"
- Name:
ghost-stack-ghcr-rw - Expiration: 30 days
- Scopes:
repo(includesrepo:status,repo_deployment,public_repo,repo:invite,security_events),write:packages,read:packages - Click "Generate token"
- Copy the token immediately
-
Update GitHub Secret:
- Go to
github.com/noahwhite/ghost-stack→ Settings → Secrets and variables → Actions - Find
GHCR_TOKENunder Repository secrets - Click "Update"
- Paste the new token
- Click "Update secret"
- Go to
-
Verify:
- Trigger a workflow that uses GHCR (e.g., create a draft PR)
- Confirm the "Log in to GHCR" step succeeds
Purpose: Authenticate to Bitwarden Secrets Manager to retrieve runtime secrets in CI/CD.
Scope: Environment-scoped (dev and dev-ci environments)
devenvironment: Used by deploy workflowsdev-cienvironment: Used by PR workflows (shadow environment for validation)
Expiration: 30 days (set at creation time)
-
Generate new token:
- Log into Bitwarden web vault
- Go to Organizations → Machine accounts
- Select the
github-actionsmachine account - Go to Access tokens tab
- Click "Create access token"
- Name:
gha-bw-tok-MMDDYY(where MMDDYY is the expiration date, e.g.,gha-bw-tok-032626) - Set expiration to 30 days
- Copy the token immediately (shown only once)
-
Update GitHub Secrets (both environments):
- Go to
github.com/noahwhite/ghost-stack→ Settings → Environments → dev - Find
BWS_ACCESS_TOKEN→ Update → paste new token - Repeat for Environments → dev-ci
- Go to
-
Revoke old token:
- In Bitwarden, delete the old access token from the machine account
-
Verify:
- Trigger a deploy workflow
- Confirm secrets retrieval succeeds in the logs
Purpose: Manage Cloudflare resources (DNS, Page Rules) via OpenTofu.
Bitwarden Secret ID: 59624245-6a0c-4fde-9d6d-b39c014882a6
Expiration: 30 days
-
Generate new token:
- Log into Cloudflare dashboard (dev account)
- Go to My Profile → API Tokens → Create Token
- Use "Edit zone DNS" template or custom:
- Zone: DNS: Edit
- Zone: Zone: Read
- Zone Resources: Include specific zone or all zones
- Do not set IP restrictions — GitHub runner IPs are too numerous and dynamic to whitelist
- Set TTL: 30 days
- Create token and copy immediately
-
Update Bitwarden:
- Log into Bitwarden web vault
- Find secret with ID
59624245-6a0c-4fde-9d6d-b39c014882a6 - Update the value with the new token
- Save
-
Revoke old token:
- In Cloudflare, go to My Profile → API Tokens
- Find the old token and click "Revoke"
-
Verify:
- Run
./opentofu/scripts/tofu.sh dev plan - Confirm no authentication errors
- Run
Purpose: Create other scoped Cloudflare API tokens programmatically.
Storage: Bitwarden Secrets Manager
Expiration: 30 days recommended
-
Generate new token:
- Log into Cloudflare dashboard (dev account)
- Go to My Profile → API Tokens → Create Token
- Select template: "Create Additional Tokens"
- Permissions: User, API Tokens, Edit
- Do not set IP restrictions
- Set TTL: 30 days
- Create and copy token
-
Update Bitwarden:
- Log into Bitwarden web vault
- Find secret with ID
6bc45446-1aa1-4e2c-804e-b39b002c32ea - Update the value with the new token
- Save
-
Revoke old token:
- In Cloudflare, revoke the previous token creator
Purpose: Provision R2 bucket and DNS zone during initial bootstrap.
Storage: Bitwarden Secrets Manager
Expiration: 30 days recommended
-
Generate new token:
- Use the token creator script (recommended):
./opentofu/bootstrap/scripts/generate-bootstrap-token.sh
- Or manually create in Cloudflare:
-
Go to My Profile → API Tokens → Create Token → Create Custom Token
-
Name:
bootstrap-dev-token -
Add the following permission rows:
Resource Type Permission Access Account Workers R2 Storage Read Account Workers R2 Storage Edit Account Email Routing Addresses Read Account Email Routing Addresses Edit Zone Zone Settings Read Zone Zone Settings Edit Zone Zone Read Zone Zone Edit Zone DNS Read Zone DNS Edit Zone Email Routing Rules Read Zone Email Routing Rules Edit -
Account Resources: Set to Include → Noah@separationofconcerns.dev's Account
-
Zone Resources: Set to Include → Specific zone → separationofconcerns.dev
-
IP Address Filtering: Set Operator to is in, then click Use my IP (assuming you are on the dev workstation — otherwise enter the dev workstation's public IP)
-
TTL: Set start date to today and end date to 30 days in the future
-
Click Continue to Summary, review all details, and if correct click Create Token
-
Copy the token immediately (shown only once)
-
- Use the token creator script (recommended):
-
Update Bitwarden:
- Log into Bitwarden web vault
- Find secret with ID
bde8e810-8be6-4090-aa1d-b39b002c9eb8 - Update the value with the new token
- Save
Purpose: Access R2 buckets for OpenTofu state storage and sysext image storage.
Bitwarden Secret IDs:
- Access Key ID:
9dfdf110-5a84-48c3-ad7e-b39b002afd6b - Secret Access Key:
f5d9794d-fd45-4dcb-9994-b39b002b5056
Expiration: 30 days
-
Generate new credentials:
- Log into Cloudflare dashboard
- Go to Storage & Databases → R2 Object Storage → Overview
- Click the Manage button next to API tokens in the Account Details section
- Click Create Account API token
- Name:
ghost-stack-r2-YYYY-MM - Permissions: Object Read & Write
- Specify bucket(s):
ghost-stack-dev-state,ghost-dev-sysext-images - TTL: 30 days
- Client IP Address Filtering: leave blank — GitHub runner IPs are too numerous and dynamic to whitelist
- Create and copy both Access Key ID and Secret Access Key
-
Update Bitwarden:
- Update secret
9dfdf110-5a84-48c3-ad7e-b39b002afd6bwith new Access Key ID - Update secret
f5d9794d-fd45-4dcb-9994-b39b002b5056with new Secret Access Key
- Update secret
-
Revoke old credentials:
- In Cloudflare R2, delete the old API token
-
Verify:
- Run
./opentofu/scripts/tofu.sh dev plan - Confirm state can be read/written
- Run
Purpose: Manage Vultr compute instances, firewalls, and block storage.
Bitwarden Secret ID: d68b6562-0d9e-424c-b2c5-b39c013ae34d
Expiration: 30 days
-
Generate new key:
- Log into Vultr (dev account)
- Go to Account → API
- Click "Enable API" if not already enabled
- Set expiration to 30 days
- Copy the API key (or regenerate if rotating)
Note: Vultr only supports one API key per account. Regenerating creates a new key and invalidates the old one immediately.
IP Access Control: The API page has a separate IP access control section that applies to all API keys account-wide. It is currently configured with the management workstation IP and a catch-all Any IPv4 entry. The catch-all is required because GitHub Actions runner IPs cannot be whitelisted. Do not remove either entry.
-
Update Bitwarden:
- Update secret
d68b6562-0d9e-424c-b2c5-b39c013ae34dwith new key
- Update secret
-
Verify:
- Run
./opentofu/scripts/tofu.sh dev plan - Confirm Vultr resources are accessible
- Run
Purpose: Register/deregister Tailscale devices via OpenTofu.
Bitwarden Secret IDs:
- API Key:
34b620b7-edf6-4d06-9792-b39b00317467 - Tailnet:
a8f07ce5-ed4d-42bb-b012-b39b00311d41
Expiration: 30 days
-
Generate new key:
- Log into Tailscale admin console
- Go to Settings → Keys
- Click "Generate access token..."
- Description:
ghost-stack-tofu-YYYY-MM - Expiry: 30 days
- Copy the key
-
Update Bitwarden:
- Update secret
34b620b7-edf6-4d06-9792-b39b00317467with new key
- Update secret
-
Revoke old key:
- In Tailscale, delete the old API key
-
Verify:
- Run
./opentofu/scripts/tofu.sh dev plan - Confirm Tailscale provider initializes
- Run
Purpose: Authenticate new Flatcar instances to join the Tailnet during first boot.
Storage: Generated dynamically by OpenTofu via tailscale_tailnet_key resource
Expiration: One-time use (key is invalidated after first use)
Important: This project uses one-time auth keys for security. The key is generated automatically by OpenTofu during tofu apply and is configured with reusable = false, meaning it's invalidated after a single use. This prevents key reuse if exposed in OpenTofu state or logs.
| Key Type | Risk if Exposed | Mitigation |
|---|---|---|
| Reusable | Attacker can register unlimited rogue devices | Manual revocation required |
| One-time | Key already invalidated after legitimate use | Automatic - no action needed |
The auth key is managed entirely by OpenTofu:
- Key Generation: The
tailscale_tailnet_keyresource inopentofu/modules/tailscale/main.tofucreates a new auth key - Key Configuration:
reusable = false- One-time use onlyephemeral = false- Device persists after disconnectpreauthorized = true- No admin approval needed- Tagged with
tag:ghost-dev
- Key Injection: The key is passed to the Flatcar instance via Ignition userdata
- Key Consumption: On first boot,
tailscale-auth.serviceuses the key to join the Tailnet - Key Invalidation: Tailscale automatically invalidates the key after first use
Before running tofu apply that will create or recreate an instance:
- Remove the old Tailscale device from the admin console (see
docs/runbooks/tailscale-device-cleanup.md) - Run
tofu apply- OpenTofu will generate a fresh one-time auth key automatically - Verify the device appears in Tailscale admin console after instance boots
tofu apply
│
├─► tailscale_tailnet_key resource creates new one-time key
│
├─► Key embedded in Ignition userdata (stored in tofu state)
│
├─► Vultr instance created with Ignition config
│
└─► Instance boots
│
├─► tailscale-auth.service runs on first boot
│
├─► tailscale up --authkey=<key> --ssh
│
└─► Key is consumed and invalidated by Tailscale
Subsequent tofu apply (no instance change):
│
└─► Same key reference in state, but key is already invalidated
(This is fine - the device is already registered)
After instance provisioning:
# SSH to the new instance
tailscale ssh core@ghost-dev-01
# Verify Tailscale is connected
tailscale statusTo verify the key was invalidated:
- Go to Tailscale admin console → Settings → Keys
- The auth key should show as "Used" or no longer appear in active keys
- The new device should appear in the Machines list
- Instance fails to join Tailnet: The auth key may have been consumed by a previous failed provisioning attempt. Run
tofu applyagain to generate a fresh key. - Device named
ghost-dev-01-1: The old device wasn't removed from Tailscale before reprovisioning. Remove it and reprovision. Seedocs/runbooks/tailscale-device-cleanup.md - Key in state but device not registered: The instance may have failed to boot or the tailscale-auth.service failed. Check instance console and
journalctl -u tailscale-auth.service
- State file exposure: The auth key is stored in OpenTofu state. Use encrypted state backend (R2 with server-side encryption) and restrict state access.
- One-time mitigation: Even if state is compromised, the key cannot be reused after the legitimate instance has consumed it.
- Key rotation: Each
tofu applythat recreates the instance generates a fresh key automatically.
Purpose: Configure PagerDuty integrations via OpenTofu.
Bitwarden Secret IDs:
- Client ID:
7d51661b-736a-43ff-b01f-b39c013fe49b - Client Secret:
b15575c0-0d28-459d-b92d-b39c01403a38
Expiration: No expiry (cannot be set on PagerDuty App registrations) — rotate every 30 days
-
Create a new App:
- Log into PagerDuty
- Go to Integrations → App Registrations
- Click New App
- Name:
ghost-stack-terraform-#(increment # from the current app's name) - Description:
Opentofu integration - Under Functionality, check OAuth 2.0
- Click Next
-
Configure OAuth permissions:
- In the Authorization section, leave Scoped OAuth selected
- In the Permission Scope table, check Read Access and Write Access in the Resource row — this selects all Read and Write checkboxes
- Click Register App
- Copy the generated Client ID and Client Secret immediately
-
Update Bitwarden:
- Update secret
7d51661b-736a-43ff-b01f-b39c013fe49bwith the new Client ID - Update secret
b15575c0-0d28-459d-b92d-b39c01403a38with the new Client Secret
- Update secret
-
Verify:
- Run
./opentofu/scripts/tofu.sh dev plan - Confirm PagerDuty provider initializes
- Run
-
Delete old App:
- Go to Integrations → App Registrations
- Find the old
ghost-stack-terraform-#app and delete it
Purpose: User-level API access for PagerDuty operations.
Bitwarden Secret ID: 02805292-4311-4290-9b6e-b39c01554ae6
Expiration: No expiry — rotate every 30 days
-
Generate new token:
- Log into PagerDuty
- Go to Integrations → API Access Keys
- Click Create New API Key
- Description:
ghost-dev-pd-api-key - Leave Read-only API Key unchecked
- Click Create Key
- Copy the API key immediately (shown only once)
-
Update Bitwarden:
- Update secret
02805292-4311-4290-9b6e-b39c01554ae6with the new token - Also update the secret's Note field with the new key's ID, which is visible on the API Access Keys page
- Update secret
-
Revoke old token:
- Delete the old API key on the Integrations → API Access Keys page
Purpose: Configure Grafana Cloud observability via OpenTofu.
Bitwarden Secret: grafana_cloud_access_token
Bitwarden Secret ID: bfc8dd06-bd97-499a-98f8-b3a101570606
Expiration: 30 days
-
Generate new token:
- Log into Grafana Cloud
- Go to Administration → Users and access → Cloud access policies
- Find the access policy named
ghost-stack-dev-terraform-token - Click "Add token"
- Name:
soc-dev-grafana-cloud-access-tok-DD-MM-YYYY(use expiration date) - Set expiry to 30 days
- Click "Create"
- Copy the token immediately
-
Update Bitwarden:
- Update the secret
grafana_cloud_access_tokenwith the new token value - Update the notes field with the new expiration date
- Update the secret
-
Revoke old token:
- In Grafana Cloud, delete the old token from the access policy
-
Verify the token before saving:
read -s BEARER_TOKEN curl -H "Authorization: Bearer ${BEARER_TOKEN}" https://grafana.com/api/instances unset BEARER_TOKEN
- Should return a JSON response with your stacks
- If you get 401 Unauthorized, the token is invalid or truncated
-
Verify after updating Bitwarden:
- Run
./opentofu/scripts/tofu.sh dev plan - Confirm Grafana Cloud provider initializes
- Run
- Token appears truncated: Grafana cloud access tokens are base64-encoded and often end with
=or==. Ensure the entire token was copied including any trailing characters. - Token format: Valid cloud access tokens are typically 50+ characters and follow the pattern
glc_xxxx...xxxx==. If significantly shorter or missing the==suffix, it was likely truncated during copy. - 401 Unauthorized after rotation: Always verify the token works with the curl command above before saving to Bitwarden. If curl fails, regenerate the token.
- Copy issues: When copying tokens, use the copy button in the Grafana UI rather than manual selection to avoid truncation.
Purpose: Service account for Grafana Cloud Terraform provider (soc-dev project).
Bitwarden Secret: grafana_cloud_soc_dev_terraform_sa
Bitwarden Secret ID: 3ebc4398-f4fa-448c-b2c1-b3a6006c063d
Expiration: 30 days
-
Generate new token:
- Log into Grafana Cloud
- Go to Administration → Users and access → Service Accounts
- Click on
sa-1-extsvc-grafana-terraform-appto open the service account details page - Click "Add service account token"
- Keep the auto-generated name
- Set expiration to 30 days
- Click "Generate token"
- Copy the token immediately
-
Update Bitwarden:
- Update the secret
grafana_cloud_soc_dev_terraform_sawith the new token value - Update the comment with the new token name (auto-generated)
- Update the notes field with the new expiration date
- Update the secret
-
Delete old token:
- In Grafana Cloud, remove the old token from the service account
-
Verify the token before saving:
read -s BEARER_TOKEN curl -H "Authorization: Bearer ${BEARER_TOKEN}" \ https://separationofconcerns0dev.grafana.net/api/folders unset BEARER_TOKEN
- Should return a JSON response with folders
- If you get 401 Unauthorized, the token is invalid or truncated
-
Verify after updating Bitwarden:
- Run
./opentofu/scripts/tofu.sh dev plan - Confirm Grafana resources are accessible
- Run
- Token appears truncated: Service account tokens are base64-encoded and often end with
=or==. Ensure the entire token was copied including any trailing characters. - Token format: Valid service account tokens are typically 50+ characters and follow the pattern
glsa_xxxx...xxxx==. If significantly shorter or missing the==suffix, it was likely truncated during copy. - 401 Unauthorized after rotation: Always verify the token works with the curl command above before saving to Bitwarden. If curl fails, regenerate the token.
- Wrong service account: Ensure you're creating the token under
sa-1-extsvc-grafana-terraform-app, not a different service account. - Copy issues: When copying tokens, use the copy button in the Grafana UI rather than manual selection to avoid truncation.
These secrets are stored in Infisical and fetched by the Ghost instance at boot time via infisical-secrets-fetch.service. For initial provisioning and an overview of the rotation approach, see Infisical Secret Provisioning and Rotation.
Purpose: JWT signing for Ghost Stats dashboard queries and administrative TinyBird operations.
Storage: /var/mnt/storage/ghost-compose/.env.secrets on the Ghost instance
Expiration: No expiry — rotate every 60 days
| Token Type | Name in TinyBird UI | Works for API Calls | Works for JWT Signing |
|---|---|---|---|
| Workspace admin token | "Workspace admin token" | ✓ | ✓ |
| Personal admin token | "Admin your@email.com" | ✓ | ✗ |
Ghost generates JWTs signed with the admin token for browser-side TinyBird API calls. TinyBird validates these signatures only against the Workspace admin token. Using a personal admin token will result in:
- Direct API calls work (e.g.,
curlwith the token) - Ghost Stats dashboard shows 403 Forbidden errors
- JWT signature verification fails
- Log into TinyBird dashboard
- Go to Tokens section
- Look for the token named exactly "Workspace admin token"
- Do NOT use tokens named "Admin your@email.com" - these are personal tokens
-
Refresh the token in TinyBird:
- Log into TinyBird console and select the
soc_devworkspace - Click Tokens in the left sidebar
- Find "Workspace admin token" and click the refresh button — TinyBird will display the CLI command
- Run the command locally:
tb --cloud token refresh "workspace admin token" - Copy the new token value from the command output
- Log into TinyBird console and select the
-
Update Infisical:
- Log into app.infisical.com → Ghost Stack → dev → Secrets
- Find
TINYBIRD_ADMIN_TOKENand update the value
Alternative (CLI):
read -s SECRET_VALUE; export SECRET_VALUE infisical secrets set TINYBIRD_ADMIN_TOKEN="$SECRET_VALUE" \ --projectId ghost-stack \ --env dev unset SECRET_VALUE
-
Update
.env.secretson the instance and restart Ghost:tailscale ssh core@ghost-dev-01 read -s NEW_VALUE sudo sed -i "s|^TINYBIRD_ADMIN_TOKEN=.*|TINYBIRD_ADMIN_TOKEN=${NEW_VALUE}|" \ /var/mnt/storage/ghost-compose/.env.secrets unset NEW_VALUE sudo docker restart ghost-compose-ghost-1
-
Verify Ghost Stats is functional:
- Go to Ghost Admin → Analytics
- Confirm analytics data loads without errors
Symptom: Ghost Stats shows "Unable to load data" or network requests return 403.
Diagnosis:
# Check what token Ghost has
docker exec ghost-compose-ghost-1 sh -c 'echo "${tinybird__adminToken:0:10}..."'
# Test JWT signing by decoding a Ghost-generated token
# (Get token from Chrome DevTools Network tab on Stats page)
echo "JWT_TOKEN_HERE" | cut -d. -f2 | base64 -d | jq .Common causes:
- Using personal admin token instead of Workspace admin token
- Token has trailing whitespace or newlines
- Token was regenerated in TinyBird (invalidates all existing JWTs)
Purpose: Authenticate page view tracking events sent to TinyBird via traffic-analytics proxy.
Storage: /var/mnt/storage/ghost-compose/.env.generated (auto-generated by tinybird-provision.service)
Expiration: No expiry — rotate every 60 days
The tracker token is automatically extracted during provisioning:
tinybird-provision.serviceruns on boot- Uses the admin token to query
tb token ls - Extracts the token named "tracker"
- Writes it to
.env.generated
Note: On instance recreation,
tinybird-provision.serviceautomatically fetches the current tracker token from TinyBird and writes it to.env.generated. Manual rotation is only needed if the token is compromised on a running instance.
-
Refresh the token in TinyBird:
- Log into TinyBird console and select the
soc_devworkspace - Click Tokens in the left sidebar
- Find "tracker" and click the refresh button — TinyBird will display the CLI command
- Run the command locally:
tb --cloud token refresh "tracker" - Copy the new token value from the command output
- Log into TinyBird console and select the
-
Update
.env.generatedon the instance:tailscale ssh core@ghost-dev-01 read -s NEW_VALUE sudo sed -i "s|^TINYBIRD_TRACKER_TOKEN=.*|TINYBIRD_TRACKER_TOKEN=${NEW_VALUE}|" \ /var/mnt/storage/ghost-compose/.env.generated unset NEW_VALUE
-
Restart the stack:
sudo systemctl restart ghost-compose
Purpose: Cloudflare R2 API credentials used by ghost-backup.service for nightly block storage backups.
Storage: Infisical (ghost-stack/dev); fetched to block storage at boot as 0600 root-only files:
/var/mnt/storage/ghost-compose/secrets/ghost_dev_bckup_r2_access_key_id/var/mnt/storage/ghost-compose/secrets/ghost_dev_bckup_r2_secret_access_key
Expiration: 90 days — set this TTL when creating the R2 API token in Cloudflare Dashboard.
Impact: Rotating these credentials does not require restarting any containers. The backup script reads the secret files directly at runtime. No downtime — the next nightly backup will use the new credentials.
-
Create a new R2 API token in Cloudflare Dashboard:
- Navigate to Storage & Databases → R2 Object Storage → Overview
- Click Manage in the Account Details section
- Click Create Account API token
- Name:
ghost-stack-backup-dev-YYYY-MM - Permissions: Object Read & Write scoped to
ghost-backups-dev-separationofconcerns-devonly - TTL: 90 days
- Click Create API Token
- Cloudflare shows three values — save all three before leaving this page: Token value (not used by this integration but save for records), Access Key ID, and Secret Access Key
-
Update Infisical:
Preferred — UI:
- Log into app.infisical.com
- Navigate to the Ghost Stack project → All Environments
- Update
GHOST_DEV_BCKUP_R2_ACCESS_KEY_IDwith the new Access Key ID (Environments = Development) - Update
GHOST_DEV_BCKUP_R2_SECRET_ACCESS_KEYwith the new Secret Access Key (Environments = Development)
Alternative — CLI:
read -s SECRET_VALUE; export SECRET_VALUE infisical secrets set GHOST_DEV_BCKUP_R2_ACCESS_KEY_ID="$SECRET_VALUE" \ --projectId ghost-stack \ --env dev read -s SECRET_VALUE; export SECRET_VALUE infisical secrets set GHOST_DEV_BCKUP_R2_SECRET_ACCESS_KEY="$SECRET_VALUE" \ --projectId ghost-stack \ --env dev unset SECRET_VALUE
-
Update the secret files on the running instance:
tailscale ssh core@ghost-dev-01 read -s NEW_KEY printf '%s' "$NEW_KEY" | sudo tee /var/mnt/storage/ghost-compose/secrets/ghost_dev_bckup_r2_access_key_id > /dev/null unset NEW_KEY read -s NEW_SECRET printf '%s' "$NEW_SECRET" | sudo tee /var/mnt/storage/ghost-compose/secrets/ghost_dev_bckup_r2_secret_access_key > /dev/null unset NEW_SECRET sudo chmod 0600 /var/mnt/storage/ghost-compose/secrets/ghost_dev_bckup_r2_access_key_id sudo chmod 0600 /var/mnt/storage/ghost-compose/secrets/ghost_dev_bckup_r2_secret_access_key
-
Revoke the old R2 API token in Cloudflare Dashboard.
-
Verify the next backup succeeds:
sudo systemctl start ghost-backup.service & journalctl -u ghost-backup -f # Expect: "Running rclone sync...", "Backup complete."
Secret name: HEALTH_CHECK_TOKEN (managed in Infisical)
Purpose: Caddy uses this token to authenticate health check requests from GitHub Actions and manual curl checks.
Impact: Rotating this token invalidates all existing health check calls. Update the GitHub Secret HEALTH_CHECK_TOKEN in the dev environment and on the instance at the same time.
-
Generate a new token (random, URL-safe):
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
-
Update Infisical:
- Log into app.infisical.com → Ghost Stack → dev → Secrets
- Find
HEALTH_CHECK_TOKENand update the value
Alternative (CLI):
read -s SECRET_VALUE; export SECRET_VALUE infisical secrets set HEALTH_CHECK_TOKEN="$SECRET_VALUE" \ --projectId ghost-stack \ --env dev unset SECRET_VALUE
-
Update GitHub Secret:
- Go to
github.com/noahwhite/ghost-stack→ Settings → Environments →dev - Update
HEALTH_CHECK_TOKEN
- Go to
-
Update
.env.secretson the instance and restart Caddy:tailscale ssh core@ghost-dev-01 read -s NEW_VALUE sudo sed -i "s|^HEALTH_CHECK_TOKEN=.*|HEALTH_CHECK_TOKEN=${NEW_VALUE}|" \ /var/mnt/storage/ghost-compose/.env.secrets unset NEW_VALUE sudo docker restart ghost-compose-caddy-1
-
Verify health check works with the new token (run from the dev workstation or another whitelisted IP):
read -s NEW_VALUE curl -sI -H "X-Health-Check-Token: ${NEW_VALUE}" https://separationofconcerns.dev unset NEW_VALUE # Should return HTTP 200
Secret name: mail__options__auth__pass (managed in Infisical)
Purpose: SMTP password for transactional email via Mailgun (password resets, staff invites).
Impact: Rotating this breaks outbound email until Ghost is restarted with the new value.
-
Reset the SMTP password in Mailgun:
- Log into Mailgun
- Navigate to Sending → Domain settings in the left-hand sidebar
- Select your domain:
mg.separationofconcerns.dev - Click the Reset password button next to
postmaster@mg.separationofconcerns.dev - Copy the new password shown — it will not be displayed again
-
Update Infisical:
- Log into app.infisical.com → Ghost Stack → dev → Secrets
- Find
mail__options__auth__passand update the value
Alternative (CLI):
read -s SECRET_VALUE; export SECRET_VALUE infisical secrets set "mail__options__auth__pass"="$SECRET_VALUE" \ --projectId ghost-stack \ --env dev unset SECRET_VALUE
-
Update
.env.secretson the instance and restart Ghost:tailscale ssh core@ghost-dev-01 read -s NEW_VALUE sudo sed -i "s|^mail__options__auth__pass=.*|mail__options__auth__pass=${NEW_VALUE}|" \ /var/mnt/storage/ghost-compose/.env.secrets unset NEW_VALUE sudo docker restart ghost-compose-ghost-1
-
Verify email delivery:
- Log into the Ghost admin dashboard at
https://admin.separationofconcerns.dev/ghost - Click Welcome emails under Membership in the left-hand sidebar
- Click the Separation of Concerns field in the Free Members section
- Click the Test button
- Set the email address to send the test to and click Send
- Verify the test email was received
- Log into the Ghost admin dashboard at
Both MySQL secrets are managed in Infisical but also require a MySQL ALTER USER statement — updating Infisical alone does not change the password stored in MySQL's data directory.
Purpose: Password for the ghost MySQL user. Used by Ghost to connect to its database.
Impact: This rotation requires coordinating a MySQL password change with an Infisical update. If they get out of sync, Ghost will fail to connect to the database.
-
Generate a new password:
cat /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 128 ; echo
-
Update MySQL and
.env.secretson the instance:tailscale ssh core@ghost-dev-01 # Read passwords securely — no echo, no history read -s NEW_PASSWORD # enter the new ghost user password generated in step 1 read -s ROOT_PASSWORD # enter the current DATABASE_ROOT_PASSWORD # Apply ALTER USER — SQL piped via stdin, root auth via env var # Neither value appears in shell history or MySQL history printf "ALTER USER 'ghost'@'%%' IDENTIFIED BY '%s'; FLUSH PRIVILEGES;\n" "${NEW_PASSWORD}" | \ sudo docker exec -i -e MYSQL_PWD="${ROOT_PASSWORD}" ghost-compose-db-1 mysql -u root unset ROOT_PASSWORD # Update .env.secrets with the new password so containers pick it up on restart sudo sed -i "s|^DATABASE_PASSWORD=.*|DATABASE_PASSWORD=${NEW_PASSWORD}|" \ /var/mnt/storage/ghost-compose/.env.secrets unset NEW_PASSWORD
-
Update Infisical with the new password:
- Log into app.infisical.com → Ghost Stack → dev → Secrets
- Find
DATABASE_PASSWORDand update the value
Alternative (CLI):
read -s SECRET_VALUE; export SECRET_VALUE infisical secrets set DATABASE_PASSWORD="$SECRET_VALUE" \ --projectId ghost-stack \ --env dev unset SECRET_VALUE
-
Restart Ghost containers:
tailscale ssh core@ghost-dev-01 sudo systemctl restart ghost-compose
-
Verify Ghost is running and can connect to the database:
docker logs ghost-compose-ghost-1 2>&1 | tail -20 # Should show no database connection errors
Purpose: MySQL root password. Used for administrative database operations only — Ghost uses DATABASE_PASSWORD (ghost user), not the root password.
Impact: This only affects administrative access to MySQL, not Ghost's normal operation.
-
Generate a new root password:
cat /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 128 ; echo
-
Update MySQL and
.env.secretson the instance:tailscale ssh core@ghost-dev-01 # Read passwords securely — no echo, no history read -s NEW_ROOT_PASSWORD # enter the new root password generated in step 1 read -s CURRENT_ROOT_PASSWORD # enter the current DATABASE_ROOT_PASSWORD # Apply ALTER USER — SQL piped via stdin, current root auth via env var # Neither value appears in shell history or MySQL history printf "ALTER USER 'root'@'%%' IDENTIFIED BY '%s'; ALTER USER 'root'@'localhost' IDENTIFIED BY '%s'; FLUSH PRIVILEGES;\n" \ "${NEW_ROOT_PASSWORD}" "${NEW_ROOT_PASSWORD}" | \ sudo docker exec -i -e MYSQL_PWD="${CURRENT_ROOT_PASSWORD}" ghost-compose-db-1 mysql -u root unset CURRENT_ROOT_PASSWORD # Update .env.secrets with the new root password so containers pick it up on restart sudo sed -i "s|^DATABASE_ROOT_PASSWORD=.*|DATABASE_ROOT_PASSWORD=${NEW_ROOT_PASSWORD}|" \ /var/mnt/storage/ghost-compose/.env.secrets unset NEW_ROOT_PASSWORD
-
Update Infisical with the new password:
- Log into app.infisical.com → Ghost Stack → dev → Secrets
- Find
DATABASE_ROOT_PASSWORDand update the value
Alternative (CLI):
read -s SECRET_VALUE; export SECRET_VALUE infisical secrets set DATABASE_ROOT_PASSWORD="$SECRET_VALUE" \ --projectId ghost-stack \ --env dev unset SECRET_VALUE
-
Restart MySQL to pick up the new root password:
tailscale ssh core@ghost-dev-01 sudo docker restart ghost-compose-db-1 # Wait for MySQL to be ready, then restart Ghost sleep 15 sudo docker restart ghost-compose-ghost-1 -
Verify MySQL is healthy:
docker logs ghost-compose-db-1 2>&1 | tail -10 docker logs ghost-compose-ghost-1 2>&1 | tail -10
These tokens are used locally by Claude Code for GitHub and Linear integration. They are not stored in Bitwarden or GitHub Secrets.
Purpose: Allows Claude Code to interact with GitHub via MCP (Model Context Protocol) for issue management, PR creation, etc.
Scope: Local development only (not stored in GitHub)
Expiration: Configurable (recommend 90 days)
-
Generate new token:
- Go to GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens
- Click "Generate new token"
- Name:
claude-mcp-access - Expiration: 90 days
- Repository access: Select repositories → choose
ghost-stack,alloy-sysext-build - Permissions:
- Contents: Read and write
- Issues: Read and write
- Pull requests: Read and write
- Metadata: Read-only
- Click "Generate token"
-
Update local configuration:
- Update your Claude Code MCP configuration with the new token
- Location varies by setup (typically
~/.config/claude/mcp.jsonor similar)
-
Verify:
- Use Claude Code to list issues or create a test comment
Purpose: Claude Code integration with Linear for issue tracking.
Storage: Local Claude Code MCP configuration
Expiration: No expiry — rotate every 90 days
-
Log into Linear.
-
Open your account settings:
- Click NO noahwhite (avatar/initials) in the bottom-left corner
- Select Settings from the dropdown menu
-
Navigate to Security & access.
-
Generate a new key:
- Under Personal API keys, click New API Key
- Name it something like
claude-mcp-linear-key-2(increment the suffix each rotation) - Click Only select permissions... and select Read and Write
- Under Team access, select All teams you have access to
- Click Create
- Copy the token immediately — it is only shown once
-
Update local configuration:
- Update your Claude Code MCP configuration with the new token
-
Test the new key:
- Open a Claude dev container session and verify the Linear MCP integration responds correctly
-
Revoke the old key:
- Back on the Security & access screen, find the old key in the list
- Click the ... button next to it
- Select Revoke API key
After rotating any token, perform the following verifications:
-
PR Workflow:
- Create a draft PR with a minor change
- Verify all workflow steps pass:
- Log in to GHCR
- Retrieve secrets from Bitwarden
- OpenTofu plan executes successfully
-
Deploy Workflow:
- Trigger a manual workflow run or merge a PR
- Verify deployment completes successfully
-
OpenTofu:
source docker/scripts/infra-shell.sh ./opentofu/scripts/tofu.sh dev planConfirm no authentication errors.
-
Bitwarden:
bws secret list
Confirm secrets are accessible.
| Service | Verification Command/Action |
|---|---|
| Cloudflare | curl -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" -H "Authorization: Bearer $TOKEN" |
| Vultr | curl -H "Authorization: Bearer $VULTR_API_KEY" https://api.vultr.com/v2/account |
| Tailscale | Check admin console for API key status |
| PagerDuty | OpenTofu plan with PagerDuty resources |
| Grafana | OpenTofu plan with Grafana resources |
| TinyBird | Ghost Admin → Analytics shows data without 403 errors |
| Token | Recommended Rotation | Priority |
|---|---|---|
| GHCR Token | Every 30 days | High |
| Cloudflare API Tokens | Every 30 days | High |
| BWS Access Tokens | Every 30 days (before expiry) | High |
| R2 Credentials (state/sysext) | Every 30 days | High |
| R2 Backup Credentials | Every 90 days | High |
| Vultr API Key | Every 30 days (before expiry) | High |
| Tailscale API Key | Every 30 days | High |
| PagerDuty Tokens | Every 30 days | High |
| Tailscale Auth Key | Before each instance provisioning | Medium |
| Grafana Tokens | Every 30 days (expiry enforced) | Medium |
| TinyBird Tokens | Every 60 days | Medium |
| Linear API Token | Every 90 days | Low |
If a token is suspected to be compromised:
- Immediately revoke the compromised token at its source
- Generate a new token following the steps above
- Update all storage locations (Bitwarden, GitHub Secrets)
- Audit logs for unauthorized access
- Document the incident and review access patterns
This document lives at docs/token-rotation-runbook.md in the repository.