---
applyTo: "dns/**"
---

# DNS Management Instructions

## Overview

CULTSCALE DNS zones are managed as code using OctoDNS. Zone files are YAML and automatically sync to Cloudflare via GitHub Actions.

## Directory Structure

```
dns/
├── config.yaml           # OctoDNS configuration (12 zones)
├── zones/                # DNS zone files (YAML)
│   ├── cultscale.com.yaml
│   └── ...
├── Makefile              # Management commands
└── README.md             # Full documentation
```

## Makefile Commands

```bash
make validate    # Validate changes (dry-run)
make sync        # Apply changes to Cloudflare
make dump        # Export Cloudflare state to YAML
make report      # Show zone status
make status      # List zones and files
```

**Normal workflow:** Edit zone files → Commit → Push to main. GitHub Actions automatically syncs changes to Cloudflare. No need to run `make validate` or `make sync` locally unless testing.

## Email Configuration Policy

**cultscale.com (INBOUND/TEAM EMAIL DOMAIN):**
- ImprovMX mail forwarding (mx1/mx2.improvmx.com)
- SPF includes improvmx.com

**cultscale.net (NEWSLETTERS + TRANSACTIONAL OUTBOUND DOMAIN):**
- SMTP2Go sending with DKIM

**All other zones (NO EMAIL):**
- Null MX record: `MX 0 .` (RFC 7505)
- SPF reject: `v=spf1 -all`
- DMARC reject: `v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s;`

**When adding new zones:** Always add null MX configuration unless email is explicitly needed.

## Zone File Format

```yaml
---
# Root domain (@ in DNS)
'':
  type: A
  value: 192.0.2.1
  ttl: 300

# Subdomain
www:
  type: CNAME
  value: example.com.
  ttl: 300

# Multiple values
'':
  type: MX
  values:
    - exchange: mx1.example.com.
      preference: 10
    - exchange: mx2.example.com.
      preference: 20
  ttl: 3600
```

**Key points:**
- Root domain: `''` (empty string)
- Domains in values need trailing `.`: `example.com.`
- Subdomain keys don't need trailing dots: `www:` not `www.:`
- Use `value:` for single, `values:` for multiple
- TXT records with semicolons (like DMARC) are supported unescaped because `escaped_semicolons: false` is enabled in `dns/config.yaml`.

## Root Domain CNAMEs (ALIAS Records)

For apex/root domain CNAMEs (e.g., pointing cultscale.com to Cloudflare Pages), use `type: ALIAS`:

```yaml
'':
  octodns:
    cloudflare:
      auto-ttl: true
      proxied: true
  type: ALIAS
  value: cultscale-web.pages.dev.
```

**Why ALIAS not CNAME:**
- DNS RFCs prohibit CNAME records at the root domain
- OctoDNS validates against RFCs and rejects `type: CNAME` at root
- `type: ALIAS` is OctoDNS's representation of apex CNAMEs
- Cloudflare provider automatically converts ALIAS to CNAME flattening
- Result: Root domain resolves via Cloudflare's proxy IPs

**For subdomains, use regular CNAME:**
```yaml
www:
  type: CNAME
  value: cultscale-web.pages.dev.
```

## DMARC Record Format

DMARC records contain semicolons. With `escaped_semicolons: false` in the YamlProvider config, they can be used unquoted:

```yaml
_dmarc:
  ttl: 300
  type: TXT
  values:
  - v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s;
```

**Important:**
- Must use `values:` list format (even for single value)
- Semicolons are unquoted (due to `escaped_semicolons: false`)
- No need for `lenient: true` flag
- This is the standard pattern for all non-email zones

## Modifying DNS Records

1. Edit zone file in `dns/zones/`
2. Validate: `cd dns && make validate`
3. If changes look correct, sync: `make sync`

**Common record types:**

**A record:**
```yaml
subdomain:
  type: A
  value: 192.0.2.1
  ttl: 300
```

**CNAME:**
```yaml
www:
  type: CNAME
  value: example.com.
  ttl: 300
```

**TXT:**
```yaml
'':
  type: TXT
  value: "verification-code"
  ttl: 300
```

## Adding New Zones

1. Add to `dns/config.yaml`:
   ```yaml
   zones:
     newdomain.com.:
       sources: [config]
       targets: [cloudflare]
   ```

2. Create `dns/zones/newdomain.com.yaml` with null MX (unless email needed):
   ```yaml
   ---
   ? ''
   : - ttl: 300
       type: MX
       value:
         exchange: .
         preference: 0
     - ttl: 300
       type: TXT
       value: v=spf1 -all
   _dmarc:
     ttl: 300
     type: TXT
     value: v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s;
   ```

3. Validate and sync

## Cloudflare Bulk Redirects for Domain Consolidation

All CULTSCALE brand domains (except cultscale.com and cultshot.com), plus select hostnames like `about.cultscale.com`, redirect to cultscale.com using **Cloudflare Bulk Redirects**, managed via GitOps.

**Why Bulk Redirects?**
- `_redirects` file only supports path-based redirects within a single domain
- `_headers` file can set headers but cannot perform actual HTTP redirects
- Domain-to-domain redirects require Cloudflare Bulk Redirects at the account level

### Current Configuration (Updated 2025-12-25)

**List:** `cultscale_domain_redirects`  
**Ruleset:** `CULTSCALE Domain Redirects`  
**Account ID:** `0b48baba59566ce8df7575e5393cec9c`

**Active redirects (10 total):**
- `www.cultscale.com/` → `https://cultscale.com/` (301 permanent)
- `cultborn.com/` → `https://cultscale.com/` (302 temporary)
- `cultreel.com/` → `https://cultscale.com/` (302 temporary)
- `cultscale.net/` → `https://cultscale.com/` (302 temporary)
- `cultsonic.com/` → `https://cultscale.com/` (302 temporary)
- `cultsync.com/` → `https://cultscale.com/` (302 temporary)
- `about.cultscale.com/` → `https://cultscale.com/about` (301 permanent)
- `yusi.app/` → `https://yusiapp.com/` (302 temporary)
- `www.yusi.app/` → `https://yusiapp.com/` (302 temporary)
- `www.yusiapp.com/` → `https://yusiapp.com/` (302 temporary)

**Not redirected:** `cultshot.com` is a first-party site (Cloudflare Pages: `cultshot-web.pages.dev`) and was removed from the bulk redirect list on 2025-12-19.

### GitOps management

- Source of truth: `dns/bulk-redirects.json`
- Sync workflow: `.github/workflows/sync-bulk-redirects.yml` (runs on `main` changes or manual dispatch)
- Secret required: `CLOUDFLARE_TOKEN` (preferred) or `CLOUDFLARE_DNS_TOKEN` or `CLOUDFLARE_API_TOKEN` with scopes `account_filter_lists:edit` and `account:read` (workflow skips if missing)
- Process: edit JSON → merge to `main` → workflow PUTs full list to Cloudflare (replacement)

### DNS Configuration for Redirect Domains

According to [Cloudflare documentation](https://developers.cloudflare.com/dns/troubleshooting/faq/#what-ip-should-i-use-for-parked-domain--redirect-only--originless-setup), redirect-only domains use placeholder IPs:

```yaml
'':
  type: A
  value: 192.0.2.0  # Reserved TEST-NET-1 address (RFC 5737)
  octodns:
    cloudflare:
      proxied: true  # Required for Bulk Redirects
```

**Why this works:**
- Proxied (orange-clouded) required for Bulk Redirects to intercept traffic
- DNS must resolve to something for proxy to activate
- `192.0.2.0` is conventional/documented approach for originless setups
- Placeholder IP never reached - redirect happens at Cloudflare edge

All redirect hostnames are configured in `dns/zones/` with proxied A records.

### Modifying Redirects

**Via Dashboard:**
1. Navigate to **Account Home** → **Manage Account** → **Configurations** → **Lists**
2. Edit `cultscale_domain_redirects` list
3. Add/remove/modify redirect items
4. Changes are immediate

**Via API:**
Requires API token with:
- Account > Account Filter Lists > Edit

```bash
export CLOUDFLARE_TOKEN='your-token'
ACCOUNT_ID="0b48baba59566ce8df7575e5393cec9c"

# Get list ID
curl -s "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/rules/lists" \
  -H "Authorization: Bearer ${CLOUDFLARE_TOKEN}" | jq '.result[] | select(.name=="cultscale_domain_redirects") | .id'

# Add redirect
curl -X POST \
  "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/rules/lists/${LIST_ID}/items" \
  -H "Authorization: Bearer ${CLOUDFLARE_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '[{"redirect": {"source_url": "newdomain.com/", "target_url": "https://cultscale.com/", "status_code": 302}}]'
```

**Testing:**
```bash
curl -I https://cultborn.com  # Should return 302
curl -I https://www.cultscale.com  # Should return 301
```

## Exporting from Cloudflare

**All zones:**
```bash
cd dns && make dump
```

**Single zone:**
```bash
octodns-dump --config-file=config.yaml --output-dir=zones --output-provider=config cultscale.com. cloudflare
```

## Conflict Resolution

**OctoDNS is source-of-truth based:**
- YAML files are authoritative
- Cloudflare is the target (made to match YAML)
- One-way sync, not merge-based
- YAML always wins on sync

**If DNS changed in Cloudflare UI:**
1. Export first: `make dump`
2. Review changes: `git diff zones/`
3. Decide: commit the Cloudflare changes or discard them
4. Next sync will make Cloudflare match YAML

## Zone List (11 zones)

- cultborn.com (no email)
- cultreel.com (no email)
- **cultscale.com** (primary email domain)
- cultscale.net (no email)
- cultscale.org (no email)
- cultshot.com (no email)
- cultsonic.com (no email)
- cultsync.com (no email)
- michapp.org (no email)
- yusi.app (no email)
- yusiapp.com (no email)

## Makefile Validation

The Makefile `validate` target runs `octodns-sync` in dry-run mode. **It will fail on syntax errors** - this is expected and correct behavior. Previously, validation failures were hidden by grep fallbacks.

**Common validation errors:**
- YAML indentation errors
- Missing trailing dots on domain values
- Incorrect record format

Always run `make validate` before committing DNS changes.

## Best Practices

1. Always validate before syncing: `make validate`
2. Never remove email config from cultscale.com
3. Always use null MX for non-email domains
4. Keep zone files minimal and clean
5. Document unusual configurations in YAML comments
6. Use `make dump` to sync from Cloudflare UI changes

## Troubleshooting

**YAML syntax errors:**
- Check indentation (2 spaces)
- Check trailing dots on domains in values
- Validate: `make validate`

**No changes detected:**
- YAML matches Cloudflare state (expected)

**Permission denied:**
- CLOUDFLARE_TOKEN may have expired or lacks permissions

**Makefile requires yq:**
- Installed via: `pkg install yq` (Termux) or `brew install yq` (macOS)
