Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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 \
Expand Down Expand Up @@ -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: |
Expand All @@ -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 \
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/pr-tofu-plan-develop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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 \
Expand Down
121 changes: 119 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
4 changes: 4 additions & 0 deletions docker/scripts/infra-shell.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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')"
Expand All @@ -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)
Expand Down
Loading