diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index b15e994..3322b90 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -157,6 +157,7 @@ jobs: TF_VAR_SOC_DEV_TERRAFORM_SA_TOK: ${{ env.TF_VAR_SOC_DEV_TERRAFORM_SA_TOK }} TF_BACKEND_BUCKET: ${{ env.TF_BACKEND_BUCKET }} TF_VAR_admin_subnets: ${{ env.TF_VAR_admin_subnets }} + TF_VAR_admin_ip: ${{ env.TF_VAR_admin_ip }} TF_VAR_ssh_public_key: ${{ env.TF_VAR_ssh_public_key }} TF_VAR_cloudflare_zone_id: ${{ env.TF_VAR_cloudflare_zone_id }} run: | @@ -178,6 +179,7 @@ jobs: -e TF_VAR_SOC_DEV_TERRAFORM_SA_TOK \ -e TF_BACKEND_BUCKET \ -e TF_VAR_admin_subnets \ + -e TF_VAR_admin_ip \ -e TF_VAR_ssh_public_key \ -e TF_VAR_cloudflare_zone_id \ ghcr.io/noahwhite/ghost-stack-shell:latest \ @@ -233,6 +235,7 @@ jobs: TF_VAR_SOC_DEV_TERRAFORM_SA_TOK: ${{ env.TF_VAR_SOC_DEV_TERRAFORM_SA_TOK }} TF_BACKEND_BUCKET: ${{ env.TF_BACKEND_BUCKET }} TF_VAR_admin_subnets: ${{ env.TF_VAR_admin_subnets }} + TF_VAR_admin_ip: ${{ env.TF_VAR_admin_ip }} TF_VAR_ssh_public_key: ${{ env.TF_VAR_ssh_public_key }} TF_VAR_cloudflare_zone_id: ${{ env.TF_VAR_cloudflare_zone_id }} run: | @@ -254,6 +257,7 @@ jobs: -e TF_VAR_SOC_DEV_TERRAFORM_SA_TOK \ -e TF_BACKEND_BUCKET \ -e TF_VAR_admin_subnets \ + -e TF_VAR_admin_ip \ -e TF_VAR_ssh_public_key \ -e TF_VAR_cloudflare_zone_id \ ghcr.io/noahwhite/ghost-stack-shell:latest \ diff --git a/.github/workflows/pr-tofu-plan-develop.yml b/.github/workflows/pr-tofu-plan-develop.yml index 2a2d6db..2d92cff 100644 --- a/.github/workflows/pr-tofu-plan-develop.yml +++ b/.github/workflows/pr-tofu-plan-develop.yml @@ -138,6 +138,7 @@ jobs: TF_VAR_SOC_DEV_TERRAFORM_SA_TOK: ${{ env.TF_VAR_SOC_DEV_TERRAFORM_SA_TOK }} TF_BACKEND_BUCKET: ${{ env.TF_BACKEND_BUCKET }} TF_VAR_admin_subnets: ${{ env.TF_VAR_admin_subnets }} + TF_VAR_admin_ip: ${{ env.TF_VAR_admin_ip }} TF_VAR_ssh_public_key: ${{ env.TF_VAR_ssh_public_key }} TF_VAR_cloudflare_zone_id: ${{ env.TF_VAR_cloudflare_zone_id }} run: | @@ -159,6 +160,7 @@ jobs: -e TF_VAR_SOC_DEV_TERRAFORM_SA_TOK \ -e TF_BACKEND_BUCKET \ -e TF_VAR_admin_subnets \ + -e TF_VAR_admin_ip \ -e TF_VAR_ssh_public_key \ -e TF_VAR_cloudflare_zone_id \ ghcr.io/noahwhite/ghost-stack-shell:latest \ diff --git a/CLAUDE.md b/CLAUDE.md index 25f39d3..c3af90e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,10 +214,127 @@ docker logs ghost-compose-ghost-1 docker exec ghost-compose-caddy-1 caddy reload --config /etc/caddy/Caddyfile docker restart ghost-compose-caddy-1 -# Ghost compose directory -cd /var/mnt/storage/ghost-compose +# Ghost compose directory (ephemeral config) +cd /etc/ghost-compose + +# Secrets directory (persistent on block storage) +ls -la /var/mnt/storage/ghost-compose/.env.secrets +``` + +## Ghost Compose Architecture + +The Ghost Docker Compose stack uses a **hybrid ephemeral/persistent** approach: + +### Ephemeral Components (Deployed via Ignition) + +These files are deployed to `/etc/ghost-compose/` at boot time and are versioned in the repository: + +| File | Purpose | +|------|---------| +| `compose.yml` | Docker Compose service definitions | +| `.env.config` | Non-secret configuration (domains, paths, mail settings) | +| `caddy/Caddyfile` | Reverse proxy configuration with `{$VAR}` placeholders | +| `caddy/snippets/*` | Caddy configuration snippets (Logging, SecurityHeaders, etc.) | +| `mysql-init/*.sh` | Database initialization scripts | + +### Persistent Components (On Block Storage) + +These files persist across instance recreations on `/var/mnt/storage/`: + +| Path | Purpose | +|------|---------| +| `/var/mnt/storage/ghost-compose/.env.secrets` | Secrets only (passwords, tokens) | +| `/var/mnt/storage/ghost/upload-data/` | Ghost content uploads | +| `/var/mnt/storage/mysql/data/` | MySQL database files | +| `/var/mnt/storage/caddy/certs/` | Cloudflare origin certificates | + +### Configuration Flow + +``` +OpenTofu Variables → env.config.tftpl → Ignition → /etc/ghost-compose/.env.config + ↓ +Block Storage (manual) ────────────────→ /var/mnt/storage/ghost-compose/.env.secrets + ↓ + Docker Compose sources both files ``` +## Ghost Compose Secrets Management + +### File Split Strategy + +**`.env.config` (ephemeral - safe for version control):** +- Domain names (DOMAIN, ADMIN_DOMAIN) +- Admin IP for Caddy ACL (ADMIN_IP) +- Mail settings (host, user - not password) +- Data paths (UPLOAD_LOCATION, MYSQL_DATA_LOCATION) + +**`.env.secrets` (persistent - manual management):** +- DATABASE_PASSWORD +- DATABASE_ROOT_PASSWORD +- HEALTH_CHECK_TOKEN +- mail__options__auth__pass + +### Security Model + +| Risk | Mitigation | +|------|------------| +| Vultr userdata exposure | No secrets in Ignition - only `.env.config` | +| OpenTofu state exposure | No secrets passed through OpenTofu variables | +| Block storage access | File permissions (0600), Tailscale-only SSH | +| Instance compromise | Secrets isolated to single file, easier to rotate | + +### Modifying Configuration + +**Non-secret changes** (domains, paths, mail settings): +1. Update OpenTofu variables in `opentofu/envs/dev/main.tofu` +2. Run `tofu plan` and `tofu apply` +3. Instance will be recreated with new config + +**Secret changes** (passwords, tokens): +1. SSH to instance: `tailscale ssh core@ghost-dev-01` +2. Edit secrets file: `sudo vim /var/mnt/storage/ghost-compose/.env.secrets` +3. Restart containers: `cd /etc/ghost-compose && sudo docker compose restart` + +## Updating Ghost Docker Images + +The Ghost Docker stack is based on [TryGhost/ghost-docker](https://github.com/TryGhost/ghost-docker). + +### Current Image Versions + +Check `opentofu/modules/vultr/instance/userdata/ghost-compose/compose.yml.tftpl` for current versions: +- Caddy: `caddy:2.10.2-alpine@sha256:...` +- MySQL: `mysql:8.0.44@sha256:...` +- Ghost: `ghost:6-alpine` (unpinned, uses latest 6.x) + +### Upstream Sync Workflow + +1. **Watch for updates**: Star/watch [TryGhost/ghost-docker](https://github.com/TryGhost/ghost-docker) for Renovate PRs + +2. **Check for updates**: + ```bash + # Compare with upstream + curl -sL https://raw.githubusercontent.com/TryGhost/ghost-docker/main/compose.yml | diff - opentofu/modules/vultr/instance/userdata/ghost-compose/compose.yml.tftpl + ``` + +3. **Update templates**: + - Edit `compose.yml.tftpl` with new image tags and SHA256 digests + - Update Caddyfile or snippets if upstream changed them + +4. **Deploy**: + ```bash + ./opentofu/scripts/tofu.sh dev plan + ./opentofu/scripts/tofu.sh dev apply + ``` + +### Files to Monitor + +| Upstream File | Local Template | What Changes | +|---------------|----------------|--------------| +| `compose.yml` | `compose.yml.tftpl` | Image tags, SHA digests, new services | +| `caddy/Caddyfile` | `caddy/Caddyfile` | Proxy rules, new features | +| `caddy/snippets/*` | `caddy/snippets/*` | Snippet updates | +| `.env.example` | Reference only | New environment variables | + ## Branch Naming Convention **All feature branches must follow the `feature/**` pattern** (e.g., `feature/GHO-XX-description`). diff --git a/docker/scripts/infra-shell.sh b/docker/scripts/infra-shell.sh index 63a1477..6a7c5be 100755 --- a/docker/scripts/infra-shell.sh +++ b/docker/scripts/infra-shell.sh @@ -283,6 +283,8 @@ if [[ "$CI_MODE" == "true" ]]; then fi TF_VAR_admin_subnets="$(printf '[{"subnet":"%s","subnet_size":32}]' "$MYIP")" export_var "TF_VAR_admin_subnets" "${TF_VAR_admin_subnets}" + # Also export the raw IP for Caddy access control + export_var "TF_VAR_admin_ip" "${MYIP}" else # Workstation mode: Detect public IP dynamically MYIP="$(curl -fsS https://checkip.amazonaws.com | tr -d '\r\n')" @@ -293,6 +295,8 @@ else echo "Restricting SSH to your IP: ${MYIP}/32" TF_VAR_admin_subnets="$(printf '[{"subnet":"%s","subnet_size":32}]' "$MYIP")" export_var "TF_VAR_admin_subnets" "${TF_VAR_admin_subnets}" + # Also export the raw IP for Caddy access control + export_var "TF_VAR_admin_ip" "${MYIP}" fi # Set SSH public key from repo (same for both workstation and CI modes) diff --git a/docs/runbooks/env-secrets-migration.md b/docs/runbooks/env-secrets-migration.md new file mode 100644 index 0000000..9581fbb --- /dev/null +++ b/docs/runbooks/env-secrets-migration.md @@ -0,0 +1,442 @@ +# Runbook: Migrate to Ephemeral Ghost Compose Configuration + +## Overview + +This runbook documents the one-time migration process to transition from a fully manual Ghost Docker Compose deployment to the hybrid ephemeral/persistent model. After migration, configuration files will be deployed via Ignition at boot time, while secrets remain on block storage. + +## Background + +### Current State (Before Migration) + +All Ghost Compose files are manually managed on block storage: + +| File | Location | Contains | +|------|----------|----------| +| `compose.yml` | `/var/mnt/storage/ghost-compose/` | Service definitions | +| `.env` | `/var/mnt/storage/ghost-compose/` | All config + secrets | +| `caddy/Caddyfile` | `/var/mnt/storage/ghost-compose/caddy/` | Hardcoded tokens | +| `caddy/snippets/*` | `/var/mnt/storage/ghost-compose/caddy/snippets/` | Static config | +| `mysql-init/*` | `/var/mnt/storage/ghost-compose/mysql-init/` | Init scripts | + +### Target State (After Migration) + +| Component | Location | Source | Contains | +|-----------|----------|--------|----------| +| `compose.yml` | `/etc/ghost-compose/` | Ignition | Service definitions | +| `.env.config` | `/etc/ghost-compose/` | Ignition | Non-secret config | +| `.env.secrets` | `/var/mnt/storage/ghost-compose/` | Block storage | **Secrets only** | +| `caddy/Caddyfile` | `/etc/ghost-compose/caddy/` | Ignition | Uses `{$VAR}` placeholders | +| `caddy/snippets/*` | `/etc/ghost-compose/caddy/snippets/` | Ignition | Static config | +| `mysql-init/*` | `/etc/ghost-compose/mysql-init/` | Ignition | Init scripts | + +## Prerequisites + +- SSH access to the Ghost instance via Tailscale +- Admin access to Tailscale admin console (for device cleanup) +- The OpenTofu changes for ephemeral compose are ready in a feature branch (PR will be created in Step 8) + +## Procedure + +### Step 1: Backup Current Configuration + +SSH to the instance and create a backup: + +```bash +# SSH to instance +tailscale ssh core@ghost-dev-01 + +# Create backup directory with timestamp +BACKUP_DIR="/var/mnt/storage/backups/ghost-compose-$(date +%Y%m%d-%H%M%S)" +sudo mkdir -p "$BACKUP_DIR" + +# Backup all current config (including hidden files like .env) +sudo cp -a /var/mnt/storage/ghost-compose/. "$BACKUP_DIR/" + +# Verify backup +ls -la "$BACKUP_DIR/" +cat "$BACKUP_DIR/.env" # Verify .env was captured +``` + +### Step 2: Identify Secrets in Current .env + +Review the current `.env` file to identify which values are secrets: + +```bash +# View current .env +sudo cat /var/mnt/storage/ghost-compose/.env +``` + +**Secrets to extract** (these go in `.env.secrets`): +- `DATABASE_PASSWORD` - MySQL ghost user password +- `DATABASE_ROOT_PASSWORD` - MySQL root password +- `HEALTH_CHECK_TOKEN` - Token for health check authentication (may be in Caddyfile) +- `mail__options__auth__pass` - SMTP password for transactional email + +**Non-secrets** (will be managed by Ignition in `.env.config`): +- `DOMAIN` / domain names +- `ADMIN_DOMAIN` / admin domain +- `ADMIN_IP` / workstation IP for ACL +- `UPLOAD_LOCATION` / data paths +- `MYSQL_DATA_LOCATION` / data paths +- `mail__transport` / mail config +- `mail__options__host` / mail host +- `mail__options__port` / mail port +- `mail__options__secure` / mail TLS setting +- `mail__options__auth__user` / mail username (not password) + +### Step 3: Extract Health Check Token from Caddyfile + +The health check token may be hardcoded in the Caddyfile: + +```bash +# Check Caddyfile for hardcoded token +sudo grep -A1 "X-Health-Check-Token" /var/mnt/storage/ghost-compose/caddy/Caddyfile +``` + +Expected output format: +``` +header X-Health-Check-Token "your-token-value-here" +``` + +Copy this token value - it will go in `.env.secrets`. + +### Step 4: Create .env.secrets File + +Create the secrets-only file on block storage: + +```bash +# Create the secrets file +sudo tee /var/mnt/storage/ghost-compose/.env.secrets << 'EOF' +# Ghost Compose Secrets +# This file contains sensitive credentials only. +# Non-secret configuration is deployed via Ignition to /etc/ghost-compose/.env.config + +# MySQL Credentials +DATABASE_PASSWORD=REPLACE_WITH_ACTUAL_PASSWORD +DATABASE_ROOT_PASSWORD=REPLACE_WITH_ACTUAL_ROOT_PASSWORD + +# Health Check Token (used by Caddy to authenticate health check requests) +HEALTH_CHECK_TOKEN=REPLACE_WITH_ACTUAL_TOKEN + +# Mail Credentials (SMTP password for transactional email) +mail__options__auth__pass=REPLACE_WITH_ACTUAL_SMTP_PASSWORD +EOF +``` + +**Important:** Now edit the file to replace placeholders with actual values: + +```bash +sudo vim /var/mnt/storage/ghost-compose/.env.secrets +``` + +Replace each `REPLACE_WITH_...` placeholder with the corresponding value from: +- The backup `.env` file (for database passwords and mail password) +- The Caddyfile (for health check token) + +### Step 5: Set Correct File Permissions + +Secure the secrets file: + +```bash +# Set restrictive permissions (owner read/write only) +sudo chmod 0600 /var/mnt/storage/ghost-compose/.env.secrets + +# Verify permissions +ls -la /var/mnt/storage/ghost-compose/.env.secrets +# Expected: -rw------- 1 root root ... .env.secrets +``` + +### Step 6: Verify Secrets File Contents + +Confirm the file has the correct format and values: + +```bash +# Check file contents (be careful in shared terminals) +sudo cat /var/mnt/storage/ghost-compose/.env.secrets + +# Check line endings are Unix-style (LF, not CRLF) +sudo cat -A /var/mnt/storage/ghost-compose/.env.secrets | head -5 +# Lines should end with $ not ^M$ +``` + +### Step 6a: Secure Original .env File + +After verifying `.env.secrets` contains the correct values, you should remove the secrets from the original `.env` file to avoid having secrets in two locations: + +**Option 1: Remove original .env (recommended after successful deployment)** + +Wait until after Step 9 verification completes successfully, then: +```bash +sudo rm /var/mnt/storage/ghost-compose/.env +``` + +**Option 2: Keep .env but redact secrets (safer during migration)** + +If you want to keep the file structure for reference but remove secrets: +```bash +# Create a redacted version +sudo sed -i \ + -e 's/^DATABASE_ROOT_PASSWORD=.*/DATABASE_ROOT_PASSWORD=REDACTED/' \ + -e 's/^DATABASE_PASSWORD=.*/DATABASE_PASSWORD=REDACTED/' \ + -e 's/^mail__options__auth__pass=.*/mail__options__auth__pass=REDACTED/' \ + /var/mnt/storage/ghost-compose/.env +``` + +**Note:** The backup directory still contains the original `.env` with secrets. Keep this backup secure or delete it after confirming the migration is successful. + +**About the Caddyfile token:** The original Caddyfile at `/var/mnt/storage/ghost-compose/caddy/Caddyfile` contains the hardcoded health check token. After migration, this file is no longer used - the new Caddyfile at `/etc/ghost-compose/caddy/Caddyfile` reads the token from the `HEALTH_CHECK_TOKEN` environment variable. The old Caddyfile (and entire `/var/mnt/storage/ghost-compose/` directory except `.env.secrets`) can be deleted during post-migration cleanup. + +### Step 7: Clean Up Tailscale Device + +Before applying infrastructure changes, remove the old device from Tailscale: + +1. Navigate to https://login.tailscale.com/admin/machines +2. Find `ghost-dev-01` +3. Click "..." menu > "Remove device" +4. Confirm removal + +See [Tailscale Device Cleanup Runbook](./tailscale-device-cleanup.md) for details. + +### Step 8: Deploy via CI + +Infrastructure changes are applied through the CI/CD pipeline, not manually. + +1. **Create a PR** with the ephemeral compose changes targeting `develop` branch + ```bash + git checkout -b feature/GHO-XX-ephemeral-ghost-compose + git add -A + git commit -m "feat: make Ghost Docker Compose stack ephemeral" + git push -u origin feature/GHO-XX-ephemeral-ghost-compose + gh pr create --base develop --title "Make Ghost Docker Compose stack ephemeral" + ``` + +2. **Review the PR plan** - The `pr-tofu-plan-develop.yml` workflow runs automatically + - Check the workflow output for the plan + - Verify it shows `vultr_instance.ghost will be replaced` + - All other resources should be unchanged or updated in-place + +3. **Merge the PR** - After approval, merge to `develop` + +4. **Monitor deployment** - The `deploy-dev.yml` workflow triggers automatically + - Requires manual approval via GitHub environment protection + - Compares fresh plan with PR plan (drift detection) + - Applies changes and runs health checks + +5. **Watch for completion** - The workflow will: + - Recreate the instance with new Ignition config + - Run health checks against `https://separationofconcerns.dev` + - Report success or failure in the workflow summary + +### Step 9: Post-Migration Verification + +After the new instance is running: + +```bash +# SSH to new instance +tailscale ssh core@ghost-dev-01 + +# 1. Verify ephemeral config files exist +ls -la /etc/ghost-compose/ +ls -la /etc/ghost-compose/caddy/snippets/ + +# 2. Verify .env.config has no secrets +cat /etc/ghost-compose/.env.config +# Should contain: DOMAIN, ADMIN_DOMAIN, ADMIN_IP, paths, mail config (except password) + +# 3. Verify .env.secrets exists on block storage +ls -la /var/mnt/storage/ghost-compose/.env.secrets +# Should show -rw------- permissions + +# 4. Check Docker Compose is running +docker compose -f /etc/ghost-compose/compose.yml ps +# All services should be "running" + +# 5. Check container logs for errors +docker logs ghost-compose-caddy-1 2>&1 | tail -20 +docker logs ghost-compose-ghost-1 2>&1 | tail -20 +docker logs ghost-compose-db-1 2>&1 | tail -20 + +# 6. Verify Ghost responds (using health check token since direct access is restricted) +curl -sI -H "X-Health-Check-Token: YOUR_TOKEN_VALUE" https://separationofconcerns.dev +# Should return HTTP 200 +``` + +### Step 10: Verify Health Check Works + +Test that the health check token is correctly passed from `.env.secrets`: + +```bash +# From your workstation - with token (should succeed) +curl -sI -H "X-Health-Check-Token: YOUR_TOKEN_VALUE" https://separationofconcerns.dev +# Should return HTTP 200 + +# From your workstation - without token (should be blocked) +curl -sI https://separationofconcerns.dev +# Should return HTTP 403 (unless your IP is the allowed admin IP going through Cloudflare) + +# Verify token in Caddy config uses environment variable +tailscale ssh core@ghost-dev-01 \ + "grep -A1 'X-Health-Check-Token' /etc/ghost-compose/caddy/Caddyfile" +# Should show: header X-Health-Check-Token "{$HEALTH_CHECK_TOKEN}" +# NOT a hardcoded value +``` + +**Note:** The site is restricted to requests that either have the health check token OR come from the admin IP via Cloudflare (`Cf-Connecting-IP` header). Direct requests without the token will receive `403 Access Denied`. + +### Step 11: Enable Alloy Service (If Needed) + +Due to a known timing issue, Alloy may not auto-start after instance recreation: + +```bash +# Check if Alloy is running +systemctl status alloy.service + +# If not running, enable it manually +sudo systemctl enable --now alloy.service + +# Verify it started +systemctl status alloy.service +journalctl -u alloy.service -n 20 +``` + +## Rollback Procedure + +If migration fails and you need to revert: + +### Option 1: Quick Rollback (Restore Manual Config) + +```bash +# SSH to instance +tailscale ssh core@ghost-dev-01 + +# Stop current services +sudo docker compose -f /etc/ghost-compose/compose.yml down + +# Restore backup to original location +BACKUP_DIR="/var/mnt/storage/backups/ghost-compose-YYYYMMDD-HHMMSS" # Use actual timestamp +sudo cp -r "$BACKUP_DIR"/* /var/mnt/storage/ghost-compose/ + +# Override systemd service to use old location temporarily +sudo mkdir -p /etc/systemd/system/ghost-compose.service.d/ +sudo tee /etc/systemd/system/ghost-compose.service.d/override.conf << 'EOF' +[Service] +WorkingDirectory=/var/mnt/storage/ghost-compose +EOF + +sudo systemctl daemon-reload +sudo systemctl restart ghost-compose.service + +# Verify +docker compose -f /var/mnt/storage/ghost-compose/compose.yml ps +``` + +### Option 2: Full Rollback (Revert OpenTofu Changes) + +1. Revert the OpenTofu changes in git: + ```bash + git checkout develop + git pull + git revert + ``` + +2. Create a PR for the revert and merge to `develop`: + ```bash + git push -u origin revert-ephemeral-compose + gh pr create --base develop --title "Revert: Make Ghost Docker Compose stack ephemeral" + # Get PR reviewed, approved, and merged + ``` + +3. Clean up Tailscale device (see Step 7) before the deployment workflow runs + +4. Approve the deployment in GitHub Actions when prompted + +5. Manually restore `.env` from backup after instance recreation: + ```bash + tailscale ssh core@ghost-dev-01 + sudo cp /var/mnt/storage/backups/ghost-compose-*/. /var/mnt/storage/ghost-compose/ -a + ``` + +## Troubleshooting + +### Ghost Container Fails to Start + +**Symptom:** `ghost-compose-ghost-1` container exits or restarts repeatedly + +**Check:** +```bash +docker logs ghost-compose-ghost-1 2>&1 | tail -50 +``` + +**Common Causes:** +- Database password mismatch - verify `DATABASE_PASSWORD` in `.env.secrets` matches existing MySQL +- Missing environment variables - check both `.env.config` and `.env.secrets` are sourced + +### Caddy Returns 403 for Health Checks + +**Symptom:** Health check requests fail with 403 + +**Check:** +```bash +docker logs ghost-compose-caddy-1 2>&1 | grep -i "health" +``` + +**Common Causes:** +- `HEALTH_CHECK_TOKEN` not set or incorrect in `.env.secrets` +- Environment variable not expanded - Caddy needs `{$VAR}` syntax (not `${VAR}`) + +### Database Connection Errors + +**Symptom:** Ghost shows "Error establishing database connection" + +**Check:** +```bash +docker logs ghost-compose-db-1 2>&1 | tail -20 +docker exec ghost-compose-ghost-1 env | grep DATABASE +``` + +**Common Causes:** +- `DATABASE_PASSWORD` doesn't match what MySQL has stored +- MySQL data directory permissions changed + +### Mail Sending Fails + +**Symptom:** Ghost admin shows mail errors, password reset emails not sent + +**Check:** +```bash +docker exec ghost-compose-ghost-1 env | grep mail +``` + +**Common Causes:** +- `mail__options__auth__pass` missing or incorrect in `.env.secrets` +- Special characters in password not properly escaped + +## Post-Migration Cleanup + +After confirming the migration is successful (wait at least 24-48 hours): + +```bash +# 1. Remove orphaned config files (now deployed via Ignition to /etc/ghost-compose/) +# Keep only .env.secrets which is still used +sudo rm /var/mnt/storage/ghost-compose/.env +sudo rm /var/mnt/storage/ghost-compose/compose.yml +sudo rm -rf /var/mnt/storage/ghost-compose/caddy/ # Contains hardcoded token +sudo rm -rf /var/mnt/storage/ghost-compose/mysql-init/ + +# 2. Verify only .env.secrets remains +ls -la /var/mnt/storage/ghost-compose/ +# Should show only: .env.secrets + +# 3. Remove old backup directory (contains secrets!) +# Only do this after you're confident the migration is stable +sudo rm -rf /var/mnt/storage/backups/ghost-compose-* +``` + +**Security note:** The backup directory and old Caddyfile contain plaintext secrets (`.env` passwords and health check token). Delete them after confirming the migration is stable, or ensure restrictive permissions (`chmod 0700`). + +## Related Documentation + +- [CLAUDE.md - Ghost Compose Architecture](../../CLAUDE.md#ghost-compose-architecture) +- [CLAUDE.md - Ghost Compose Secrets Management](../../CLAUDE.md#ghost-compose-secrets-management) +- [Tailscale Device Cleanup Runbook](./tailscale-device-cleanup.md) +- [Token Rotation Runbook](../token-rotation-runbook.md) diff --git a/opentofu/envs/dev/main.tofu b/opentofu/envs/dev/main.tofu index dc49835..a86fcc7 100644 --- a/opentofu/envs/dev/main.tofu +++ b/opentofu/envs/dev/main.tofu @@ -114,6 +114,12 @@ module "vm" { ghost_url = var.ghost_url locksmith_mask = var.locksmith_mask tailscale_authkey = module.tailscale.tailscale_auth_key + + # Ghost Compose configuration (non-secret) + ghost_domain = var.ghost_domain + ghost_admin_domain = var.ghost_admin_domain + admin_ip = var.admin_ip + mail_smtp_user = var.mail_smtp_user } module "block_storage" { diff --git a/opentofu/envs/dev/variables.tofu b/opentofu/envs/dev/variables.tofu index 3237655..da40069 100644 --- a/opentofu/envs/dev/variables.tofu +++ b/opentofu/envs/dev/variables.tofu @@ -102,3 +102,30 @@ variable "cloudflare_zone_id" { type = string default = "" } + +# ========================================================================== +# Ghost Compose Configuration +# ========================================================================== + +variable "ghost_domain" { + description = "Ghost site domain" + type = string + default = "separationofconcerns.dev" +} + +variable "ghost_admin_domain" { + description = "Ghost admin panel domain" + type = string + default = "admin.separationofconcerns.dev" +} + +variable "admin_ip" { + description = "Admin workstation IP for Caddy access control (from Bitwarden)" + type = string +} + +variable "mail_smtp_user" { + description = "SMTP username for transactional email" + type = string + default = "postmaster@mg.separationofconcerns.dev" +} diff --git a/opentofu/modules/vultr/instance/main.tofu b/opentofu/modules/vultr/instance/main.tofu index a13faf3..4ed372c 100644 --- a/opentofu/modules/vultr/instance/main.tofu +++ b/opentofu/modules/vultr/instance/main.tofu @@ -20,6 +20,33 @@ locals { ghost_service_b64 = filebase64("${path.module}/userdata/ghost-compose.service") # stringify bool for the template condition (templatefile only supports strings) locksmith_mask = var.locksmith_mask ? "true" : "false" + + # ========================================================================== + # Ghost Compose Configuration Files (Ephemeral via Ignition) + # ========================================================================== + # These files are deployed to /etc/ghost-compose/ at boot time. + # Template files (.tftpl) are processed with templatefile() for variable interpolation. + # Static files are read directly with file(). + + # Template files - processed with OpenTofu variables + ghost_compose_yml = base64encode(templatefile("${path.module}/userdata/ghost-compose/compose.yml.tftpl", {})) + + ghost_env_config = base64encode(templatefile("${path.module}/userdata/ghost-compose/env.config.tftpl", { + ghost_domain = var.ghost_domain + ghost_admin_domain = var.ghost_admin_domain + ghost_version = var.ghost_version + admin_ip = var.admin_ip + mail_smtp_host = var.mail_smtp_host + mail_smtp_user = var.mail_smtp_user + })) + + # Static files - no variable interpolation needed + ghost_caddyfile = filebase64("${path.module}/userdata/ghost-compose/caddy/Caddyfile") + ghost_caddy_activitypub = filebase64("${path.module}/userdata/ghost-compose/caddy/snippets/ActivityPub") + ghost_caddy_logging = filebase64("${path.module}/userdata/ghost-compose/caddy/snippets/Logging") + ghost_caddy_security_headers = filebase64("${path.module}/userdata/ghost-compose/caddy/snippets/SecurityHeaders") + ghost_caddy_traffic_analytics = filebase64("${path.module}/userdata/ghost-compose/caddy/snippets/TrafficAnalytics") + ghost_mysql_init = filebase64("${path.module}/userdata/ghost-compose/mysql-init/create-multiple-databases.sh") } # (The data source will fail if not exists; so we just create the resource unconditionally.) @@ -33,6 +60,16 @@ data "ct_config" "ghost" { ghost_service = local.ghost_service_b64, locksmith_mask = local.locksmith_mask, tailscale_authkey = var.tailscale_authkey + + # Ghost Compose configuration files (ephemeral) + ghost_compose_yml = local.ghost_compose_yml + ghost_env_config = local.ghost_env_config + ghost_caddyfile = local.ghost_caddyfile + ghost_caddy_activitypub = local.ghost_caddy_activitypub + ghost_caddy_logging = local.ghost_caddy_logging + ghost_caddy_security_headers = local.ghost_caddy_security_headers + ghost_caddy_traffic_analytics = local.ghost_caddy_traffic_analytics + ghost_mysql_init = local.ghost_mysql_init }) strict = true } diff --git a/opentofu/modules/vultr/instance/userdata/ghost-compose.service b/opentofu/modules/vultr/instance/userdata/ghost-compose.service index 76efb00..a100cb0 100644 --- a/opentofu/modules/vultr/instance/userdata/ghost-compose.service +++ b/opentofu/modules/vultr/instance/userdata/ghost-compose.service @@ -7,7 +7,7 @@ RequiresMountsFor=/var/mnt/storage [Service] Type=oneshot RemainAfterExit=yes -WorkingDirectory=/var/mnt/storage/ghost-compose +WorkingDirectory=/etc/ghost-compose ExecStart=/usr/bin/docker compose up -d ExecStop=/usr/bin/docker compose down TimeoutStartSec=0 diff --git a/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/Caddyfile b/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/Caddyfile new file mode 100644 index 0000000..9dfd354 --- /dev/null +++ b/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/Caddyfile @@ -0,0 +1,75 @@ +{ + auto_https disable_certs +} + +{$DOMAIN} { + + import snippets/Logging + + tls /certs/cloudflare-origin.crt /certs/cloudflare-origin.key + + # Health check authentication via token header + # Token is sourced from .env.secrets via Docker Compose env_file + @health_check { + header X-Health-Check-Token "{$HEALTH_CHECK_TOKEN}" + path / + } + + handle @health_check { + reverse_proxy ghost:2368 + } + + # Allow traffic from admin workstation IP + # IP is sourced from .env.config via Docker Compose env_file + @allowed_ip { + header_regexp Cf-Connecting-IP ^{$ADMIN_IP}$ + } + + handle @allowed_ip { + reverse_proxy ghost:2368 + } + + handle { + respond "Access Denied" 403 + } +} + +# Separate admin domain +# Ghost Admin on a dedicated subdomain (recommended) +{$ADMIN_DOMAIN} { + import snippets/Logging + + # Optional: Add security headers + import snippets/SecurityHeaders + + tls /certs/cloudflare-origin.crt /certs/cloudflare-origin.key + + # Traffic Analytics service (uncomment when enabled) + #import snippets/TrafficAnalytics + + # ActivityPub Service (uncomment when enabled) + #import snippets/ActivityPub + + # Allow traffic from admin workstation IP + @allowed_ip { + header_regexp Cf-Connecting-IP ^{$ADMIN_IP}$ + } + + handle @allowed_ip { + reverse_proxy ghost:2368 + } + + handle { + respond "Access Denied" 403 + } + + # Optional: Enable gzip compression + encode gzip +} + +# Redirect www -> root domain +# Note: You must have DNS setup correctly for both domains for this to work +www.{$DOMAIN} { + import snippets/Logging + redir https://{$DOMAIN}{uri} +} diff --git a/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/ActivityPub b/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/ActivityPub new file mode 100644 index 0000000..9aea4fe --- /dev/null +++ b/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/ActivityPub @@ -0,0 +1,13 @@ +# ActivityPub +# Proxy activitypub requests /.ghost/activitypub/ +handle /.ghost/activitypub/* { + reverse_proxy {$ACTIVITYPUB_TARGET} +} + +handle /.well-known/webfinger { + reverse_proxy {$ACTIVITYPUB_TARGET} +} + +handle /.well-known/nodeinfo { + reverse_proxy {$ACTIVITYPUB_TARGET} +} diff --git a/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/Logging b/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/Logging new file mode 100644 index 0000000..763acbc --- /dev/null +++ b/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/Logging @@ -0,0 +1,11 @@ +# Log all requests (with sensitive headers redacted) +log { + output stdout + format filter { + wrap json + fields { + request>headers>X-Health-Check-Token replace [REDACTED] + } + } + level INFO +} diff --git a/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/SecurityHeaders b/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/SecurityHeaders new file mode 100644 index 0000000..f579b8a --- /dev/null +++ b/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/SecurityHeaders @@ -0,0 +1,12 @@ +header { + # Enable HSTS + Strict-Transport-Security max-age=31536000; + # Enable XSS protection + X-XSS-Protection "1; mode=block" + # Prevent MIME sniffing + X-Content-Type-Options nosniff + # Referrer policy + Referrer-Policy strict-origin-when-cross-origin + # Prevent embedding in external iframes + Content-Security-Policy "frame-ancestors 'self' {$ADMIN_DOMAIN:}" +} diff --git a/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/TrafficAnalytics b/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/TrafficAnalytics new file mode 100644 index 0000000..b26be0c --- /dev/null +++ b/opentofu/modules/vultr/instance/userdata/ghost-compose/caddy/snippets/TrafficAnalytics @@ -0,0 +1,6 @@ +# Proxy analytics requests with any prefix (e.g. /.ghost/analytics/ or /blog/.ghost/analytics/) +@analytics_paths path_regexp analytics_match ^(.*)/\.ghost/analytics(.*)$ +handle @analytics_paths { + rewrite * {re.analytics_match.2} + reverse_proxy traffic-analytics:3000 +} diff --git a/opentofu/modules/vultr/instance/userdata/ghost-compose/compose.yml.tftpl b/opentofu/modules/vultr/instance/userdata/ghost-compose/compose.yml.tftpl new file mode 100644 index 0000000..cce451f --- /dev/null +++ b/opentofu/modules/vultr/instance/userdata/ghost-compose/compose.yml.tftpl @@ -0,0 +1,225 @@ +--- +# yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-spec/main/schema/compose-spec.json +# +# Ghost Docker Compose Stack +# Based on: https://github.com/TryGhost/ghost-docker +# +# This file is deployed via Ignition at boot time. Configuration is split: +# - .env.config (ephemeral): Non-secret config deployed via Ignition +# - .env.secrets (persistent): Secrets stored on block storage +# +services: + caddy: + image: caddy:2.10.2-alpine@sha256:953131cfea8e12bfe1c631a36308e9660e4389f0c3dfb3be957044d3ac92d446 + restart: always + ports: + - "80:80" + - "443:443" + env_file: + - .env.config + - /var/mnt/storage/ghost-compose/.env.secrets + environment: + DOMAIN: $${DOMAIN:?DOMAIN environment variable is required} + ADMIN_DOMAIN: $${ADMIN_DOMAIN:-} + ADMIN_IP: $${ADMIN_IP:-} + # ACTIVITYPUB_TARGET: $${ACTIVITYPUB_TARGET:-https://ap.ghost.org} + volumes: + - ./caddy:/etc/caddy + - caddy_data:/data + - caddy_config:/config + - /var/mnt/storage/caddy/certs:/certs:ro + depends_on: + - ghost + networks: + - ghost_network + + ghost: + # Do not alter this without updating the Tinybird Sync container as well + image: ghost:$${GHOST_VERSION:-6-alpine} + restart: always + env_file: + - .env.config + - /var/mnt/storage/ghost-compose/.env.secrets + environment: + NODE_ENV: production + url: https://$${DOMAIN:?DOMAIN environment variable is required} + admin__url: $${ADMIN_DOMAIN:+https://$${ADMIN_DOMAIN}} + database__client: mysql + database__connection__host: db + database__connection__user: $${DATABASE_USER:-ghost} + database__connection__password: $${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required} + database__connection__database: ghost + #tinybird__tracker__endpoint: https://$${DOMAIN:?DOMAIN environment variable is required}/.ghost/analytics/api/v1/page_hit + #tinybird__adminToken: $${TINYBIRD_ADMIN_TOKEN:-} + #tinybird__workspaceId: $${TINYBIRD_WORKSPACE_ID:-} + #tinybird__tracker__datasource: analytics_events + #tinybird__stats__endpoint: $${TINYBIRD_API_URL:-https://api.tinybird.co} + volumes: + - $${UPLOAD_LOCATION:-./data/ghost}:/var/lib/ghost/content + depends_on: + db: + condition: service_healthy + #tinybird-sync: + #condition: service_completed_successfully + #required: false + #tinybird-deploy: + #condition: service_completed_successfully + #required: false + #activitypub: + #condition: service_started + #required: false + networks: + - ghost_network + + db: + image: mysql:8.0.44@sha256:f37951fc3753a6a22d6c7bf6978c5e5fefcf6f31814d98c582524f98eae52b21 + restart: always + expose: + - "3306" + env_file: + - .env.config + - /var/mnt/storage/ghost-compose/.env.secrets + environment: + MYSQL_ROOT_PASSWORD: $${DATABASE_ROOT_PASSWORD:?DATABASE_ROOT_PASSWORD environment variable is required} + MYSQL_USER: $${DATABASE_USER:-ghost} + MYSQL_PASSWORD: $${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required} + MYSQL_DATABASE: ghost + MYSQL_MULTIPLE_DATABASES: activitypub + volumes: + - $${MYSQL_DATA_LOCATION:-./data/mysql}:/var/lib/mysql + - ./mysql-init:/docker-entrypoint-initdb.d + healthcheck: + test: mysqladmin ping -p$$MYSQL_ROOT_PASSWORD -h 127.0.0.1 + interval: 1s + start_period: 30s + start_interval: 10s + retries: 120 + networks: + - ghost_network + + #traffic-analytics: + #image: ghost/traffic-analytics:1.0.12@sha256:13ce027954b25aba6b3018297e1ca048229d73b78cb5d2f483da3b04cc8d9e68 + #restart: always + #expose: + # - "3000" + #volumes: + # - traffic_analytics_data:/data + #environment: + # NODE_ENV: production + # PROXY_TARGET: $${TINYBIRD_API_URL:-https://api.tinybird.co}/v0/events + # SALT_STORE_TYPE: $${SALT_STORE_TYPE:-file} + # SALT_STORE_FILE_PATH: /data/salts.json + # TINYBIRD_TRACKER_TOKEN: $${TINYBIRD_TRACKER_TOKEN:-} + # LOG_LEVEL: debug + #profiles: [analytics] + #networks: + # - ghost_network + + #activitypub: + #image: ghcr.io/tryghost/activitypub:1.1.0@sha256:39c212fe23603b182d68e67d555c6b9b04b1e57459dfc0bef26d6e4980eb04d1 + #restart: always + #expose: + #- "8080" + #volumes: + #- $${UPLOAD_LOCATION:-./data/ghost}:/opt/activitypub/content + #environment: + #NODE_ENV: production + #PORT: 8080 + #MYSQL_HOST: db + #MYSQL_USER: $${DATABASE_USER:-ghost} + #MYSQL_PASSWORD: $${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required} + #MYSQL_DATABASE: activitypub + #ALLOW_PRIVATE_ADDRESS: true + #USE_MQ: false + #LOCAL_STORAGE_PATH: /opt/activitypub/content/images/activitypub + #LOCAL_STORAGE_HOSTING_URL: https://$${DOMAIN}/content/images/activitypub + #depends_on: + #db: + #condition: service_healthy + #activitypub-migrate: + #condition: service_completed_successfully + #profiles: [activitypub] + #networks: + #- ghost_network + + # Supporting Services + + #tinybird-login: + #build: + #context: ./tinybird + #dockerfile: Dockerfile + #working_dir: /home/tinybird + #command: /usr/local/bin/tinybird-login + #volumes: + #- tinybird_home:/home/tinybird + #- tinybird_files:/data/tinybird + #profiles: [analytics] + #networks: + #- ghost_network + #tty: false + #restart: no + + #tinybird-sync: + # Do not alter this without updating the Ghost container as well + #image: ghost:$${GHOST_VERSION:-6-alpine} + #command: > + #sh -c " + #if [ -d /var/lib/ghost/current/core/server/data/tinybird ]; then + #rm -rf /data/tinybird/*; + #cp -rf /var/lib/ghost/current/core/server/data/tinybird/* /data/tinybird/; + #echo 'Tinybird files synced into shared volume.'; + #else + #echo 'Tinybird source directory not found.'; + #fi + #" + #volumes: + #- tinybird_files:/data/tinybird + #depends_on: + #tinybird-login: + #condition: service_completed_successfully + #networks: + #- ghost_network + #profiles: [analytics] + #restart: no + + #tinybird-deploy: + #build: + #context: ./tinybird + #dockerfile: Dockerfile + #working_dir: /data/tinybird + #command: > + #sh -c " + #tb-wrapper --cloud deploy + #" + #volumes: + #- tinybird_home:/home/tinybird + #- tinybird_files:/data/tinybird + #depends_on: + #tinybird-sync: + #condition: service_completed_successfully + #profiles: [analytics] + #networks: + #- ghost_network + #tty: true + + #activitypub-migrate: + #image: ghcr.io/tryghost/activitypub-migrations:1.1.0@sha256:b3ab20f55d66eb79090130ff91b57fe93f8a4254b446c2c7fa4507535f503662 + #environment: + #MYSQL_DB: mysql://$${DATABASE_USER:-ghost}:$${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}@tcp(db:3306)/activitypub + #networks: + #- ghost_network + #depends_on: + #db: + #condition: service_healthy + #profiles: [activitypub] + #restart: no + +volumes: + caddy_data: + caddy_config: + #tinybird_files: + #tinybird_home: + #traffic_analytics_data: + +networks: + ghost_network: diff --git a/opentofu/modules/vultr/instance/userdata/ghost-compose/env.config.tftpl b/opentofu/modules/vultr/instance/userdata/ghost-compose/env.config.tftpl new file mode 100644 index 0000000..4a2e49e --- /dev/null +++ b/opentofu/modules/vultr/instance/userdata/ghost-compose/env.config.tftpl @@ -0,0 +1,48 @@ +# Ghost Docker Compose - Non-Secret Configuration +# This file is deployed via Ignition at boot time. +# Secrets are stored separately in /var/mnt/storage/ghost-compose/.env.secrets +# +# To enable ActivityPub or Analytics, uncomment COMPOSE_PROFILES below +# COMPOSE_PROFILES=analytics,activitypub + +# Ghost domain +# Custom public domain Ghost will run on +DOMAIN=${ghost_domain} + +# Ghost Admin domain +# If you have Ghost Admin setup on a separate domain +ADMIN_DOMAIN=${ghost_admin_domain} + +# Admin IP address for Caddy access control +# Requests from this IP bypass health check token requirement +ADMIN_IP=${admin_ip} + +# Ghost version (optional - defaults to 6-alpine) +GHOST_VERSION=${ghost_version} + +# ActivityPub +# If you'd prefer to self-host ActivityPub yourself uncomment the line below +# ACTIVITYPUB_TARGET=activitypub:8080 + +# Tinybird configuration +# If you want to run Analytics, paste the output from `docker compose run --rm tinybird-login get-tokens` below +# TINYBIRD_API_URL=https://api.tinybird.co +# TINYBIRD_TRACKER_TOKEN=p.eyJxxxxx +# TINYBIRD_ADMIN_TOKEN=p.eyJxxxxx +# TINYBIRD_WORKSPACE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# SMTP Email (https://ghost.org/docs/config/#mail) +# Transactional email is required for logins, account creation (staff invites), password resets +# This is not related to bulk mail / newsletter sending +mail__transport=SMTP +mail__options__host=${mail_smtp_host} +mail__options__port=465 +mail__options__secure=true +mail__options__auth__user=${mail_smtp_user} + +# Data locations (block storage paths) +# Location to store uploaded data +UPLOAD_LOCATION=/var/mnt/storage/ghost/upload-data + +# Location for database data +MYSQL_DATA_LOCATION=/var/mnt/storage/mysql/data diff --git a/opentofu/modules/vultr/instance/userdata/ghost-compose/mysql-init/create-multiple-databases.sh b/opentofu/modules/vultr/instance/userdata/ghost-compose/mysql-init/create-multiple-databases.sh new file mode 100644 index 0000000..94f1be0 --- /dev/null +++ b/opentofu/modules/vultr/instance/userdata/ghost-compose/mysql-init/create-multiple-databases.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Create additional databases for Ghost services (e.g., ActivityPub) +# Source: https://github.com/TryGhost/ghost-docker/blob/main/mysql-init/create-multiple-databases.sh + +set -e +set -u + +if [ -n "$MYSQL_MULTIPLE_DATABASES" ]; then + echo "Creating multiple databases: $MYSQL_MULTIPLE_DATABASES" + + for db in $(echo "$MYSQL_MULTIPLE_DATABASES" | tr ',' ' '); do + echo "Creating database: $db" + mysql -u root -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL + CREATE DATABASE IF NOT EXISTS \`$db\`; + GRANT ALL ON \`$db\`.* TO '$MYSQL_USER'@'%'; +EOSQL + done + + echo "Multiple databases created" +fi diff --git a/opentofu/modules/vultr/instance/userdata/ghost.bu b/opentofu/modules/vultr/instance/userdata/ghost.bu index 295a634..7d0628e 100644 --- a/opentofu/modules/vultr/instance/userdata/ghost.bu +++ b/opentofu/modules/vultr/instance/userdata/ghost.bu @@ -36,6 +36,58 @@ storage: contents: source: "data:text/plain;charset=utf-8;base64,${ghost_service}" + # ========================================================================== + # Ghost Compose Configuration (Ephemeral - No Secrets) + # ========================================================================== + # These files are deployed via Ignition and contain no secrets. + # Secrets are stored in /var/mnt/storage/ghost-compose/.env.secrets + # which is created manually on the block storage. + # + # Configuration split: + # - /etc/ghost-compose/ (ephemeral): compose.yml, .env.config, caddy/, mysql-init/ + # - /var/mnt/storage/ghost-compose/ (persistent): .env.secrets only + # ========================================================================== + + - path: /etc/ghost-compose/compose.yml + mode: 0644 + contents: + source: "data:text/plain;charset=utf-8;base64,${ghost_compose_yml}" + + - path: /etc/ghost-compose/.env.config + mode: 0644 + contents: + source: "data:text/plain;charset=utf-8;base64,${ghost_env_config}" + + - path: /etc/ghost-compose/caddy/Caddyfile + mode: 0644 + contents: + source: "data:text/plain;charset=utf-8;base64,${ghost_caddyfile}" + + - path: /etc/ghost-compose/caddy/snippets/ActivityPub + mode: 0644 + contents: + source: "data:text/plain;charset=utf-8;base64,${ghost_caddy_activitypub}" + + - path: /etc/ghost-compose/caddy/snippets/Logging + mode: 0644 + contents: + source: "data:text/plain;charset=utf-8;base64,${ghost_caddy_logging}" + + - path: /etc/ghost-compose/caddy/snippets/SecurityHeaders + mode: 0644 + contents: + source: "data:text/plain;charset=utf-8;base64,${ghost_caddy_security_headers}" + + - path: /etc/ghost-compose/caddy/snippets/TrafficAnalytics + mode: 0644 + contents: + source: "data:text/plain;charset=utf-8;base64,${ghost_caddy_traffic_analytics}" + + - path: /etc/ghost-compose/mysql-init/create-multiple-databases.sh + mode: 0755 + contents: + source: "data:text/plain;charset=utf-8;base64,${ghost_mysql_init}" + - path: /etc/locksmith/locksmith.conf mode: 0644 contents: @@ -120,11 +172,13 @@ systemd: Type=oneshot ExecStart=/usr/bin/bash -c '\ mkdir -p /var/mnt/storage/ghost/content && chmod 0755 /var/mnt/storage/ghost/content && \ - mkdir -p /var/mnt/storage/ghost/upload/data && chmod 0755 /var/mnt/storage/ghost/upload/data && \ + mkdir -p /var/mnt/storage/ghost/upload-data && chmod 0755 /var/mnt/storage/ghost/upload-data && \ mkdir -p /var/mnt/storage/caddy/data && chmod 0755 /var/mnt/storage/caddy/data && \ mkdir -p /var/mnt/storage/caddy/config && chmod 0755 /var/mnt/storage/caddy/config && \ + mkdir -p /var/mnt/storage/caddy/certs && chmod 0755 /var/mnt/storage/caddy/certs && \ mkdir -p /var/mnt/storage/alloy && chmod 0750 /var/mnt/storage/alloy && \ - mkdir -p /var/mnt/storage/mysql/data && chmod 0755 /var/mnt/storage/mysql/data\' + mkdir -p /var/mnt/storage/mysql/data && chmod 0755 /var/mnt/storage/mysql/data && \ + mkdir -p /var/mnt/storage/ghost-compose && chmod 0700 /var/mnt/storage/ghost-compose\' RemainAfterExit=true [Install] diff --git a/opentofu/modules/vultr/instance/variables.tofu b/opentofu/modules/vultr/instance/variables.tofu index 9732ad3..29545fd 100644 --- a/opentofu/modules/vultr/instance/variables.tofu +++ b/opentofu/modules/vultr/instance/variables.tofu @@ -50,4 +50,43 @@ variable "locksmith_mask" { variable "tailscale_authkey" { type = string sensitive = true +} + +# ========================================================================== +# Ghost Compose Configuration (Non-Secret) +# ========================================================================== +# These variables are used to generate the ephemeral .env.config file +# that is deployed via Ignition. Secrets are managed separately in +# /var/mnt/storage/ghost-compose/.env.secrets on block storage. + +variable "ghost_domain" { + description = "Ghost site domain (e.g., separationofconcerns.dev)" + type = string +} + +variable "ghost_admin_domain" { + description = "Ghost admin panel domain (e.g., admin.separationofconcerns.dev)" + type = string +} + +variable "ghost_version" { + description = "Ghost Docker image tag" + type = string + default = "6-alpine" +} + +variable "admin_ip" { + description = "Admin workstation IP for Caddy access control" + type = string +} + +variable "mail_smtp_host" { + description = "SMTP host for transactional email" + type = string + default = "smtp.mailgun.org" +} + +variable "mail_smtp_user" { + description = "SMTP username for transactional email" + type = string } \ No newline at end of file