## Context Mailgun was decommissioned on 2026-04-12 in favour of Brevo as the outbound SMTP relay. The DMARC aggregate (`rua`) and forensic (`ruf`) report targets still pointed at `e21c0ff8@dmarc.mailgun.org`, an inbox that no longer exists — meaning every DMARC report Google/Microsoft/etc. generate has been bouncing or silently dropped for six days. No alerts fire on this (DMARC reports are best-effort, not RFC-mandated), but we've lost visibility into alignment failures and spoofing attempts during the exact window where the SPF/DKIM/DMARC posture was being reshaped for the Brevo cutover. Decision (2026-04-18): route reports to `mailto:dmarc@viktorbarzin.me`. The mailserver's catch-all sieve delivers anything to non-existent local-parts into `spam@`, so `dmarc@` does not need to be provisioned as a real mailbox — the inbox will land in `spam@`'s maildir unchanged. Alternative considered: route to a dedicated `dmarc@` maildir with sieve rules to file into a folder. Rejected for now — the monitoring value of DMARC reports is low-frequency (one aggregate per reporter per day at most), so the catch-all path is good enough until volume justifies a proper parser. Can be revisited once we see actual report traffic. The third-party aggregator target `adb84997@inbox.ondmarc.com` (Red Sift OnDMARC) is preserved in both rua and ruf — it provides parsed dashboards that we actually read. The `postmaster@viktorbarzin.me` ruf-only target also stays as a local mirror. As a side effect, this apply also canonicalises the TXT record: the previous value was stored as a two-string split in Cloudflare state (`...viktorbarzin" ".me;"`) due to the 255-byte TXT string limit (the record length exceeded 255 chars). The new value is shorter (dmarc@viktorbarzin.me is 21 chars vs e21c0ff8@dmarc.mailgun.org's 26 chars, doubled across rua and ruf) and fits in a single string, so the provider serialises it as one string and the prior split-drift noise disappears from future plans. ## This change Single-line content edit on `cloudflare_record.mail_dmarc` in `stacks/cloudflared/modules/cloudflared/cloudflare.tf`: Before → After (rua and ruf, both): ``` mailto:e21c0ff8@dmarc.mailgun.org → mailto:dmarc@viktorbarzin.me ``` All other DMARC tags unchanged: `v=DMARC1`, `p=quarantine`, `pct=100`, `fo=1`, `ri=3600`, `sp=quarantine`, `adkim=r`, `aspf=r`. Delivery flow: ``` DMARC reporter (Gmail/Outlook/...) │ aggregate XML.gz to rua / forensic to ruf ▼ dmarc@viktorbarzin.me │ mailserver catch-all (no local recipient) ▼ spam@viktorbarzin.me (Viki's mailbox) ``` ## What is NOT in this change - **Mailbox sieve rules** to file DMARC reports into a dedicated folder (separate concern; deferred until traffic justifies it). - **DMARC parser / dashboard**. OnDMARC (adb84997@inbox.ondmarc.com) already provides this for aggregate reports. - **Policy tightening** (`p=reject`, `pct` ramp) — out of scope. - **SPF / DKIM records** — not touched. - **Removal of the split-string drift suppression**, if any existed in prior work. The canonicalisation happens naturally on this apply; no separate workaround was needed. ## Test Plan ### Automated Targeted terragrunt plan + apply via `scripts/tg`: ``` $ cd stacks/cloudflared && scripts/tg plan \ -target=module.cloudflared.cloudflare_record.mail_dmarc ... Terraform will perform the following actions: # module.cloudflared.cloudflare_record.mail_dmarc will be updated in-place ~ resource "cloudflare_record" "mail_dmarc" { ~ content = "\"v=DMARC1; ... rua=mailto:e21c0ff8@dmarc.mailgun.org, mailto:adb84997@inbox.ondmarc.com; ... ruf=mailto:e21c0ff8@dmarc.mailgun.org, mailto:adb84997@inbox.ondmarc.com, mailto:postmaster@viktorbarzin\" \".me;\"" -> "\"v=DMARC1; ... rua=mailto:dmarc@viktorbarzin.me, mailto:adb84997@inbox.ondmarc.com; ... ruf=mailto:dmarc@viktorbarzin.me, mailto:adb84997@inbox.ondmarc.com, mailto:postmaster@viktorbarzin.me;\"" } Plan: 0 to add, 1 to change, 0 to destroy. $ scripts/tg apply /tmp/dmarc.tfplan module.cloudflared.cloudflare_record.mail_dmarc: Modifying... module.cloudflared.cloudflare_record.mail_dmarc: Modifications complete after 1s Apply complete! Resources: 0 added, 1 changed, 0 destroyed. ``` Authoritative DNS post-apply: ``` $ dig TXT _dmarc.viktorbarzin.me @evan.ns.cloudflare.com +short "v=DMARC1; p=quarantine; pct=100; fo=1; ri=3600; sp=quarantine; adkim=r; aspf=r; rua=mailto:dmarc@viktorbarzin.me,mailto:adb84997@inbox.ondmarc.com; ruf=mailto:dmarc@viktorbarzin.me,mailto:adb84997@inbox.ondmarc.com,mailto:postmaster@viktorbarzin.me;" ``` Note: `dig @1.1.1.1` still served the old value immediately after apply — Cloudflare's public resolver holds its cache until TTL expires (TTL=1/auto ≈ 5 min). Authoritative NS is the source of truth. ### Manual Verification **Setup**: none (DNS change only). **Commands**: ``` # 1. Confirm authoritative DNS (run now, should pass) dig TXT _dmarc.viktorbarzin.me @evan.ns.cloudflare.com +short # Expected: rua=mailto:dmarc@viktorbarzin.me,... and ruf similarly. # 2. Confirm public resolver catches up (run after ~5min) dig TXT _dmarc.viktorbarzin.me @1.1.1.1 +short # Expected: same as above (no more mailgun.org entries). # 3. Within 24-48h, check Viki's spam@ inbox for an incoming DMARC # aggregate report from Google/Microsoft/etc. Reports are # typically .zip or .gz attachments with XML inside. ``` **Interpretation**: seeing a DMARC report land in spam@ proves the end-to-end delivery path works: reporter DNS lookup → _dmarc.viktorbarzin.me → mailto:dmarc@viktorbarzin.me → catch-all → spam@ maildir. ## Reproduce locally ``` 1. git pull 2. cd stacks/cloudflared 3. dig TXT _dmarc.viktorbarzin.me @evan.ns.cloudflare.com +short 4. Expected: rua=mailto:dmarc@viktorbarzin.me (and ruf the same). ``` Closes: code-569 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .beads | ||
| .claude | ||
| .git-crypt | ||
| .github | ||
| .planning | ||
| .woodpecker | ||
| ci | ||
| cli | ||
| diagram | ||
| docs | ||
| modules | ||
| playbooks | ||
| scripts | ||
| secrets | ||
| stacks | ||
| state/stacks | ||
| .gitattributes | ||
| .gitignore | ||
| .sops.yaml | ||
| AGENTS.md | ||
| config.tfvars | ||
| CONTRIBUTING.md | ||
| LICENSE.txt | ||
| MEMORY.md | ||
| README.md | ||
| setup-monitoring.sh | ||
| terragrunt.hcl | ||
| tiers.tf | ||
This repo contains my infra-as-code sources.
My infrastructure is built using Terraform, Kubernetes and CI/CD is done using Woodpecker CI.
Read more by visiting my website: https://viktorbarzin.me
Documentation
Full architecture documentation is available in docs/ — covering networking, storage, security, monitoring, secrets, CI/CD, databases, and more.
Adding a New User (Admin)
Adding a new namespace-owner to the cluster requires three steps — no code changes needed.
1. Authentik Group Assignment
In the Authentik admin UI, add the user to:
kubernetes-namespace-ownersgroup (grants OIDC group claim for K8s RBAC)Headscale Usersgroup (if they need VPN access)
2. Vault KV Entry
Add a JSON entry to secret/platform → k8s_users key in Vault:
"username": {
"role": "namespace-owner",
"email": "user@example.com",
"namespaces": ["username"],
"domains": ["myapp"],
"quota": {
"cpu_requests": "2",
"memory_requests": "4Gi",
"memory_limits": "8Gi",
"pods": "20"
}
}
usernamekey must match the user's Forgejo username (for Woodpecker admin access)namespaces— K8s namespaces to create and grant admin access todomains— subdomains underviktorbarzin.mefor Cloudflare DNS recordsquota— resource limits per namespace (defaults shown above)
3. Apply Stacks
vault login -method=oidc
cd stacks/vault && terragrunt apply --non-interactive
# Creates: namespace, Vault policy, identity entity, K8s deployer role
cd ../platform && terragrunt apply --non-interactive
# Creates: RBAC bindings, ResourceQuota, TLS secret, DNS records
cd ../woodpecker && terragrunt apply --non-interactive
# Adds user to Woodpecker admin list
What Gets Auto-Generated
| Resource | Stack |
|---|---|
| Kubernetes namespace | vault |
Vault policy (namespace-owner-{user}) |
vault |
| Vault identity entity + OIDC alias | vault |
| K8s deployer Role + Vault K8s role | vault |
| RBAC RoleBinding (namespace admin) | platform |
| RBAC ClusterRoleBinding (cluster read-only) | platform |
| ResourceQuota | platform |
| TLS secret in namespace | platform |
| Cloudflare DNS records | platform |
| Woodpecker admin access | woodpecker |
New User Onboarding
If you've been added as a namespace-owner, follow these steps to get started.
1. Join the VPN
# Install Tailscale: https://tailscale.com/download
tailscale login --login-server https://headscale.viktorbarzin.me
# Send the registration URL to Viktor, wait for approval
ping 10.0.20.100 # verify connectivity
2. Install Tools
Run the setup script to install kubectl, kubelogin, Vault CLI, Terraform, and Terragrunt:
# macOS
bash <(curl -fsSL https://k8s-portal.viktorbarzin.me/setup/script?os=mac)
# Linux
bash <(curl -fsSL https://k8s-portal.viktorbarzin.me/setup/script?os=linux)
3. Authenticate
# Log into Vault (opens browser for SSO)
vault login -method=oidc
# Test kubectl (opens browser for OIDC login)
kubectl get pods -n YOUR_NAMESPACE
4. Deploy Your First App
# Clone the infra repo
git clone https://github.com/ViktorBarzin/infra.git && cd infra
# Copy the stack template
cp -r stacks/_template stacks/myapp
mv stacks/myapp/main.tf.example stacks/myapp/main.tf
# Edit main.tf — replace all <placeholders>
# Store secrets in Vault
vault kv put secret/YOUR_USERNAME/myapp DB_PASSWORD=secret123
# Submit a PR
git checkout -b feat/myapp
git add stacks/myapp/
git commit -m "add myapp stack"
git push -u origin feat/myapp
After review and merge, an admin runs cd stacks/myapp && terragrunt apply.
5. Set Up CI/CD (Optional)
Create .woodpecker.yml in your app's Forgejo repo:
steps:
- name: build
image: woodpeckerci/plugin-docker-buildx
settings:
repo: YOUR_DOCKERHUB_USER/myapp
tag: ["${CI_PIPELINE_NUMBER}", "latest"]
username:
from_secret: dockerhub-username
password:
from_secret: dockerhub-token
platforms: linux/amd64
- name: deploy
image: hashicorp/vault:1.18.1
commands:
- export VAULT_ADDR=http://vault-active.vault.svc.cluster.local:8200
- export VAULT_TOKEN=$(vault write -field=token auth/kubernetes/login
role=ci jwt=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token))
- KUBE_TOKEN=$(vault write -field=service_account_token
kubernetes/creds/YOUR_NAMESPACE-deployer
kubernetes_namespace=YOUR_NAMESPACE)
- kubectl --server=https://kubernetes.default.svc
--token=$KUBE_TOKEN
--certificate-authority=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
-n YOUR_NAMESPACE set image deployment/myapp
myapp=YOUR_DOCKERHUB_USER/myapp:${CI_PIPELINE_NUMBER}
Useful Commands
# Check your pods
kubectl get pods -n YOUR_NAMESPACE
# View quota usage
kubectl describe resourcequota -n YOUR_NAMESPACE
# Store/read secrets
vault kv put secret/YOUR_USERNAME/myapp KEY=value
vault kv get secret/YOUR_USERNAME/myapp
# Get a short-lived K8s deploy token
vault write kubernetes/creds/YOUR_NAMESPACE-deployer \
kubernetes_namespace=YOUR_NAMESPACE
Important Rules
- All changes go through Terraform — never
kubectl apply/edit/patchdirectly - Never put secrets in code — use Vault:
vault kv put secret/YOUR_USERNAME/... - Always use a PR — never push directly to master
- Docker images: build for
linux/amd64, use versioned tags (not:latest)
git-crypt setup
To decrypt the secrets, you need to setup git-crypt.
- Install git-crypt.
- Setup gpg keys on the machine
git-crypt unlock
This will unlock the secrets and will lock them on commit