// infra
Infrastructure
arcane@prod:~$ cat infra/**
How this page reaches you — the actual infrastructure, read straight from the repo at build time (no hand-copied snippets, so it can never drift).
A single-node k3s box behind a Cloudflare Tunnel, GitOps-deployed: push to main, CI builds the image, ArgoCD syncs the cluster.
living diagrams
The same system as two interactive maps — every node and edge sourced from the manifests and code below, nothing decorative. Hover to preview, click to pin.
the request path
How a request reaches this page and what talks to what — read from the manifests in this repo (Sprints H-I).
select a component — hover previews, click pins, Escape clears
full text: nodes, edges and facts — the request path
- visitor (you, now)
- This page render is itself a walk of this diagram.
- Cloudflare edge (DNS · TLS · WAF)
- gipc.dev + www resolve at Cloudflare; TLS terminates at the edge.
- Origin is reachable ONLY via the tunnel — no inbound ports exposed on the origin host (the tunnel dials out).
- cloudflared (host systemd — the tunnel)
- A host systemd process, not a cluster workload (infra/cloudflared/config.yml).
- Outbound-only tunnel to Cloudflare; tunnel id redacted from this page by policy.
- k8s API (k3s server)
- Single-node k3s; the API server runs on the same host.
- Caddy (ingress · CSP · headers)
- Route table (caddy.yaml): /api/ai/* → ai:8000 · /api/* → core:8080 · everything else → web:80.
- Sets the CSP + security headers site-wide; -Server header stripped.
- web (Next.js 15)
- Static-first App Router; SSR pages seed from core in-cluster.
- core (Go · stdlib)
- One external dep (lib/pq, Sprint H); everything else stdlib.
- Boot-independent: serves /api/healthz with every dependency down.
- ai (FastAPI · RAG)
- The oracle's tools also GET the site's own public APIs via https://gipc.dev (config.py core_base) — a real loopback through this whole diagram.
- ollama (qwen2.5:0.5b-instruct)
- Self-hosted model server, in-cluster only (ollama:11434).
- prometheus
- Scrapes the cluster; core's /system panels run fixed PromQL against it.
- loki
- Log store; promtail labels streams namespace/pod/container.
- postgres (pgvector · gipc_ai)
- pgvector/pgvector:pg16 (postgres.yaml); the RAG corpus lives here.
- NetworkPolicy: only ns gipc may connect (port 5432).
- demo-db (disposable postgres)
- postgres:16.9-alpine on emptyDir — pod delete = full reseed (synthetic data).
- The Lab DB explorer's target; SELECT-only role, 6-query allowlist.
- chaos-target (×3 echo pods)
- nginx-unprivileged ×3 — the chaos button's kill target and the load test's backstop.
- demo ns is netpol-isolated: ingress only from ns gipc, egress DNS + intra-demo.
- visitor → Cloudflare edge: HTTPS · gipc.dev
- Cloudflare edge → cloudflared: tunnel — outbound-only, no inbound ports exposed
- cloudflared → Caddy: http://localhost:30082 (Caddy NodePort)
- Caddy → web: fallback route → web:80
- Caddy → core: /api/* → core:8080
- Caddy → ai: /api/ai/* → ai:8000 (SSE: flush_interval -1)
- web → core: SSR seeds fetch http://core:8080 (system/status pages)
- core → web: uptime probe → http://web:80 (WEB_URL)
- core → prometheus: fixed PromQL · prometheus.observability:9090
- core → loki: fixed LogQL · loki.observability:3100
- core → k8s API: pod reads for /api/topology — Roles topology-pod-reader (gipc/observability/data) + chaos (demo: list/delete — the Lab kill's real path)
- core → demo-db: Lab DB explorer · demo-db.demo:5432 · demo_ro SELECT-only
- core → chaos-target: load test HTTP (LOAD_TARGET_URL) · netpol ingress-from-gipc; the chaos kill lands here via the k8s API (Role chaos)
- ai → postgres: RAG retrieval · postgres.data:5432 · netpol: gipc-only ingress
- ai → ollama: /api/ai/infer demo only · ollama:11434 · qwen2.5:0.5b-instruct
the oracle's RAG pipeline
How the oracle answers: the real modules in services/ai, in the order a question flows through them (Sprints H-I).
select a component — hover previews, click pins, Escape clears
full text: nodes, edges and facts — the oracle's RAG pipeline
- corpus sources (résumé · projects · site · code)
- corpus.py loads resume.json, projects.json, site.md; code_corpus.py bakes annotated code excerpts (code-manifest.json) at build.
- No hidden sources — what the oracle knows is exactly this list.
- ingest Job (python -m app.ingest)
- A k8s Job (ingest-job.yaml) — chunks the corpus (62 chunks, 36 of them code, at the last ingest) and upserts embeddings in one transaction.
- embedder.py (bge-small-en-v1.5)
- BAAI/bge-small-en-v1.5 via fastembed (ONNX, CPU) — 384-dim vectors, baked into the image at build.
- pgvector (chunks · ns data)
- The chunks table in the data-ns postgres (gipc_ai) — cosine distance via the <=> operator.
- retrieval.py (top-k 6 · code cap 2)
- TOP_K=6; the oracle's auto-context admits at most CODE_CAP=2 code chunks (dilution guard); fixed SQL shape, embedding passed as a literal.
- oracle.py (assembly + budget)
- Trims history (6 turns / 4k chars), builds the user turn, enforces budget.py's fail-closed daily cost breaker + per-IP limits.
- Anthropic API (claude-haiku-4-5)
- Generation is the Anthropic Messages API (llm.py) — model claude-haiku-4-5, streamed.
- The self-hosted Ollama serves only the separate /api/ai/infer demo — not this pipeline.
- tool loop (tools.py · ≤4 rounds)
- The model may call fixed tools (search_corpus, the public site APIs, show_station — the Construct hook) for at most tool_rounds_max=4 rounds — then it must answer.
- SSE → Oracle UI (sse.py)
- Tokens stream to the browser as server-sent events through Caddy (flush_interval -1).
- evals.py (published, real)
- retrieval hit@6 0.812 · MRR 0.565 (n=16)
- faithfulness 0.925 over 146 graded claims
- Full dashboard with misses + methodology: /oracle (evals tab).
- corpus sources → ingest Job: chunking — headers + notes carry retrieval semantics
- ingest Job → embedder.py: embed each chunk (384-dim)
- embedder.py → pgvector: upsert vectors — single transaction, stale rows removed
- pgvector → retrieval.py: cosine top-k (<=> operator)
- retrieval.py → oracle.py: auto-context: 6 chunks, ≤2 code
- oracle.py → Anthropic API: prompt + tools, streamed
- Anthropic API → tool loop: tool calls — answered locally, fed back
- Anthropic API → SSE → Oracle UI: token stream
- evals.py → retrieval.py: exercises the REAL pipeline — results committed to evals.json, shown on /oracle
CI/CD — build & deploy the web app
.github/workflows/web.yml
GitHub Actions builds the Next.js image, pushes it to GHCR, then pins the tag into kustomize with [skip ci]; ArgoCD on the box watches main and syncs. Push to main = deploy.
name: web image
on:
push:
branches: [main]
paths:
- "apps/web/**"
- "packages/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/web.yml"
workflow_dispatch:
concurrency:
group: web-image
cancel-in-progress: false
env:
IMAGE: ghcr.io/gabrielipcarvalho/gipc-web
jobs:
build:
runs-on: ubuntu-24.04-arm
permissions:
contents: write # to commit the GitOps tag bump
packages: write # to push to GHCR
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push image
id: build
uses: docker/build-push-action@v6
with:
context: .
file: apps/web/Dockerfile
platforms: linux/arm64
push: true
tags: |
${{ env.IMAGE }}:${{ github.sha }}
${{ env.IMAGE }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: GitOps — pin manifest to this image (deploy feed)
id: pin
run: |
# rebase-retry: two workflows can race to pin on the same push; the loser fetches the
# winner's commit, re-applies its own tag, and retries. FETCH_HEAD is fresh on a shallow clone.
# BRANCH-AWARE (ai/core pattern): workflow_dispatch can run from a test branch — pin the
# triggering ref, never hardcode main.
git config user.name "gipc-ci"
git config user.email "ci@gipc.dev"
for attempt in 1 2 3 4 5; do
git fetch origin "$GITHUB_REF_NAME"
git reset --hard FETCH_HEAD
sed -i "s|newTag: .*|newTag: \"${GITHUB_SHA}\"|" infra/k8s/web/kustomization.yaml
if git diff --quiet; then echo "already pinned"; exit 0; fi
git commit -am "deploy(web): ${GITHUB_SHA:0:7} → gipc.dev [skip ci]"
if git push origin HEAD:"$GITHUB_REF_NAME"; then echo "pinned (attempt $attempt)"; exit 0; fi
echo "push rejected — another workflow pushed; retrying"
[ "$attempt" -lt 5 ] && sleep $((attempt * 3))
done
echo "::error::failed to pin web after 5 attempts"; exit 1
- name: Notify deploy feed
# post whenever the image built (even if the pin retried/failed), but never on a cancelled run.
if: ${{ !cancelled() && github.ref == 'refs/heads/main' && steps.build.outcome == 'success' }}
env:
DEPLOY_HOOK_KEY: ${{ secrets.DEPLOY_HOOK_KEY }}
# released reflects the PIN outcome — a failed pin = manifest not bumped = site NOT on the new image
RELEASED_STATUS: ${{ steps.pin.outcome == 'success' && 'success' || 'failure' }}
run: |
[ -z "$DEPLOY_HOOK_KEY" ] && { echo "no key"; exit 0; }
SUBJECT=$(git log -1 --format=%s "$GITHUB_SHA"); TS=$(date -u +%FT%TZ)
post() { # $1 stage, $2 status — jq builds subject-safe JSON; sign the exact bytes sent
body=$(jq -nc --arg sha "$GITHUB_SHA" --arg subject "$SUBJECT" --arg stage "$1" --arg status "$2" --arg ts "$TS" \
'{sha:$sha,subject:$subject,stage:$stage,status:$status,ts:$ts}')
sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$DEPLOY_HOOK_KEY" | awk '{print $2}')
curl -fsS -X POST -H "X-Signature: sha256=$sig" -H 'Content-Type: application/json' --data "$body" https://gipc.dev/api/hooks/deploy || true
}
post build success
post released "$RELEASED_STATUS"
Go core — Deployment, Service & config
infra/k8s/core/core.yaml
The stdlib Go backend: single replica, distroless non-root, read-only rootfs, 12-factor config via ConfigMap. The HMAC secret is referenced by name, never inlined.
apiVersion: v1
kind: ConfigMap
metadata:
name: core-config
namespace: gipc
data:
PORT: "8080"
CORS_ORIGIN: "https://gipc.dev"
RATE_LIMIT_RPS: "10"
RATE_LIMIT_BURST: "20"
PROMETHEUS_URL: "http://prometheus.observability:9090" # used from P3
LOKI_URL: "http://loki.observability:3100" # P6 log surface (cross-ns ClusterIP)
WEB_URL: "http://web:80" # P7 uptime probe target
UPTIME_INTERVAL: "30s" # P7 probe cadence
SHUTDOWN_TIMEOUT: "25s"
LAB_ENABLED: "true" # M5 — chaos/lab handlers active (P2)
LAB_NAMESPACE: "demo" # the ONLY ns core's k8s client touches
CHAOS_RPS: "0.1" # chaos-kill per-IP cooldown ≈ 1 / 10s
CHAOS_BURST: "1" # single-flight per IP
LOAD_TARGET_URL: "http://chaos-target.demo" # FIXED load target (isolated demo echo) — no SSRF
LOAD_MAX_CONCURRENCY: "50" # code-clamped ceiling
LOAD_MAX_SECONDS: "10" # code-clamped ceiling
LOAD_MAX_RUNS: "4" # global concurrent-run ceiling
LOAD_RPS: "0.2" # per-IP cooldown ≈ 1 run / 5s
LOAD_BURST: "1"
LAB_EVENT_HEARTBEAT: "10s" # /api/lab/events heartbeat cadence
DB_RPS: "0.5" # db-explorer per-IP cooldown ≈ 1 run / 2s
DB_BURST: "2"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: core
namespace: gipc
labels: { app: core }
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
selector:
matchLabels: { app: core }
template:
metadata:
labels: { app: core }
spec:
serviceAccountName: core-lab # M5 — SA whose namespaced Role (ns demo) grants pods list/delete
terminationGracePeriodSeconds: 30 # ≥ SHUTDOWN_TIMEOUT so /api/* drains on a roll
securityContext:
runAsNonRoot: true
runAsUser: 65532 # distroless:nonroot
fsGroup: 65532
seccompProfile: { type: RuntimeDefault }
containers:
- name: core
image: ghcr.io/gabrielipcarvalho/gipc-core:latest # tag pinned by CI via kustomization
ports:
- { containerPort: 8080, name: http }
envFrom:
- configMapRef: { name: core-config }
env:
- name: DEPLOY_HOOK_KEY # HMAC key for /api/hooks/deploy — imperative Secret, never committed
valueFrom:
secretKeyRef: { name: deploy-hook, key: hmac }
- name: DEMO_DB_URL # Lab DB explorer DSN (demo-ns toy postgres) — imperative Secret;
valueFrom: # optional: a missing Secret must never block boot (explorer 503s)
secretKeyRef: { name: demo-db-url, key: url, optional: true }
readinessProbe:
httpGet: { path: /api/readyz, port: 8080 }
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet: { path: /api/healthz, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 20
resources:
requests: { cpu: 25m, memory: 32Mi }
limits: { cpu: 200m, memory: 128Mi }
securityContext: # static binary writes nothing → full lockdown
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
---
apiVersion: v1
kind: Service
metadata:
name: core
namespace: gipc
labels: { app: core }
spec:
type: ClusterIP
selector: { app: core }
ports:
- { port: 8080, targetPort: 8080, name: http }
Kustomize — the image pin CI writes
infra/k8s/core/kustomization.yaml
CI rewrites the built image tag here; ArgoCD reconciles the cluster to match. This file is the deploy handshake.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# One app manages core + caddy. Each manifest sets its own namespace: gipc (no namespace directive here).
# Caddy's image is pinned inline in ../caddy (no images: entry), so CI's tag-pin sed matches only the
# core image line below. Keep comments free of the literal tag-key token so the sed can't match them.
resources:
- core.yaml
- core-lab-sa.yaml
- core-topology-rbac.yaml
- ../caddy
images:
- name: ghcr.io/gabrielipcarvalho/gipc-core
newTag: "e027f2e93fdfecc01985f4f2a0557eb6f84e8227"
Caddy — reverse proxy & metrics
infra/k8s/caddy/caddy.yaml
Fronts the cluster inside k3s: routes /api/* to core (SSE-safe, flush-immediate), everything else to web, and exposes its own Prometheus metrics.
apiVersion: v1
kind: ConfigMap
metadata:
name: caddy-config
namespace: gipc
data:
Caddyfile: |
{
auto_https off
admin off
servers {
trusted_proxies static private_ranges # trust cloudflared → preserve X-Forwarded-Proto/For
metrics # enable per-server Prometheus metrics
}
}
:80 {
handle /caddy-health {
respond 200
}
# arc4ne.io — the company-site tenant (ns arcane). Host-scoped FIRST so gipc's /api/*
# routes never leak onto the arc4ne hostnames.
@arc4ne host arc4ne.io www.arc4ne.io
handle @arc4ne {
reverse_proxy arcane-web.arcane:80
}
handle /api/ai/* {
reverse_proxy ai:8000 {
flush_interval -1
}
}
handle /api/* {
reverse_proxy core:8080 {
flush_interval -1
}
}
handle {
reverse_proxy web:80
}
header {
-Server
# CSP: 'unsafe-inline' is required for Next.js static (inline hydration bootstrap + inline styles;
# no nonce middleware on prerendered pages). challenges.cloudflare.com = Turnstile (the only external
# host). Everything else is same-origin. No eval/worker/blob in the codebase → those stay omitted.
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "DENY"
# HSTS apex-only (no includeSubDomains — mail/other subdomains aren't all HTTPS-forced; no preload).
Strict-Transport-Security "max-age=31536000"
Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()"
}
# The résumé PDF is embedded by /connect in a same-origin <iframe>. The global block above
# stamps frame-ancestors 'none' + XFO DENY on EVERY response — including the PDF itself,
# which made the browser refuse OUR OWN embed ("gipc.dev refused to connect"). Re-set the
# two fields for that one asset: same-origin framing only, and a bare default-src 'none'
# CSP (a PDF document loads no subresources). Everything else keeps the deny-all.
# `defer` is load-bearing: the Caddyfile adapter sorts same-name directives with path
# matchers FIRST, so without it the global block runs after this one and wins back.
@resumepdf path /Gabriel_Carvalho_Resume.pdf
header @resumepdf {
Content-Security-Policy "default-src 'none'; frame-ancestors 'self'"
X-Frame-Options "SAMEORIGIN"
defer
}
}
:2019 {
metrics /metrics # internal only — NOT in the Service/tunnel
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: caddy
namespace: gipc
labels: { app: caddy }
spec:
replicas: 1
selector:
matchLabels: { app: caddy }
template:
metadata:
labels: { app: caddy }
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "2019"
prometheus.io/path: "/metrics"
spec:
securityContext:
seccompProfile: { type: RuntimeDefault }
containers:
- name: caddy
image: caddy:2.8-alpine # pinned INLINE (not in kustomization images:) so CI's sed can't touch it
ports:
- { containerPort: 80, name: http }
- { containerPort: 2019, name: metrics }
readinessProbe:
httpGet: { path: /caddy-health, port: 80 }
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet: { path: /caddy-health, port: 80 }
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests: { cpu: 25m, memory: 32Mi }
limits: { cpu: 150m, memory: 128Mi }
securityContext:
allowPrivilegeEscalation: false # runs root for :80 bind + /data,/config writes — lighter than core
volumeMounts:
- { name: config, mountPath: /etc/caddy }
volumes:
- name: config
configMap: { name: caddy-config }
---
apiVersion: v1
kind: Service
metadata:
name: caddy
namespace: gipc
labels: { app: caddy }
spec:
type: NodePort
selector: { app: caddy }
ports:
- { port: 80, targetPort: 80, nodePort: 30082, name: http }
Cloudflare Tunnel — the front door
infra/cloudflared/config.yml
cloudflared dials out to Cloudflare's edge with zero inbound ports open on the box; gipc.dev ingress lands on the in-cluster Caddy NodePort. The tunnel id is redacted here.
tunnel: ‹tunnel-id›
credentials-file: /etc/cloudflared/gipc.json
originRequest:
connectTimeout: 30s
ingress:
- hostname: gipc.dev
service: http://localhost:30082
- hostname: www.gipc.dev
service: http://localhost:30082
- hostname: arc4ne.io
service: http://localhost:30082
- hostname: www.arc4ne.io
service: http://localhost:30082
- service: http_status:404
Terraform — the tunnel + DNS, under management
infra/terraform/cloudflare/cloudflare.tf
The whole Cloudflare zone as code: the tunnel, its proxied CNAMEs, and the full Migadu mail set (MX, DKIM, SPF, DMARC, SRV). Imported from the live account and applied — `terraform plan` is clean, so the code now describes reality and future changes go through it. The reconciling import (short names, config_src, one in-place apply that touched zero DNS bytes) is documented in the repo README.
# The codified mirror of gipc.dev's Cloudflare provisioning: the tunnel + the full DNS zone
# (site CNAMEs + the Migadu mail set). Imported and applied in Sprint J — `plan` is clean;
# see README.md for the import notes (short names, config_src, the one reconciling apply).
# gipc.dev and www route through the tunnel: proxied CNAMEs onto cfargotunnel.com.
resource "cloudflare_record" "apex" {
zone_id = var.zone_id
name = "gipc.dev"
type = "CNAME"
content = "${var.tunnel_id}.cfargotunnel.com"
proxied = true
ttl = 1
}
resource "cloudflare_record" "www" {
zone_id = var.zone_id
name = "www"
type = "CNAME"
content = "${var.tunnel_id}.cfargotunnel.com"
proxied = true
ttl = 1
}
# --- Migadu mail: the records that make gipc.dev email work. All DNS-only (unproxied),
# ttl 300. Authored verbatim from the live-API snapshot — never edit content to "tidy" it;
# a wrong byte here silently breaks mail. Names are the provider's SHORT form (verified at
# import: v4 stores the record's short name in state, not the FQDN). ---
resource "cloudflare_record" "autoconfig" {
zone_id = var.zone_id
name = "autoconfig"
type = "CNAME"
content = "autoconfig.migadu.com"
proxied = false
ttl = 300
}
resource "cloudflare_record" "autodiscover" {
zone_id = var.zone_id
name = "autodiscover"
type = "CNAME"
content = "autodiscover.migadu.com"
proxied = false
ttl = 300
}
resource "cloudflare_record" "dkim1" {
zone_id = var.zone_id
name = "key1._domainkey"
type = "CNAME"
content = "key1.gipc.dev._domainkey.migadu.com"
proxied = false
ttl = 300
}
resource "cloudflare_record" "dkim2" {
zone_id = var.zone_id
name = "key2._domainkey"
type = "CNAME"
content = "key2.gipc.dev._domainkey.migadu.com"
proxied = false
ttl = 300
}
resource "cloudflare_record" "dkim3" {
zone_id = var.zone_id
name = "key3._domainkey"
type = "CNAME"
content = "key3.gipc.dev._domainkey.migadu.com"
proxied = false
ttl = 300
}
resource "cloudflare_record" "mx_primary" {
zone_id = var.zone_id
name = "gipc.dev"
type = "MX"
content = "aspmx1.migadu.com"
priority = 10
proxied = false
ttl = 300
}
resource "cloudflare_record" "mx_secondary" {
zone_id = var.zone_id
name = "gipc.dev"
type = "MX"
content = "aspmx2.migadu.com"
priority = 20
proxied = false
ttl = 300
}
resource "cloudflare_record" "srv_autodiscover" {
zone_id = var.zone_id
name = "_autodiscover._tcp"
type = "SRV"
ttl = 300
data {
priority = 0
weight = 1
port = 443
target = "autodiscover.migadu.com"
}
}
resource "cloudflare_record" "srv_imaps" {
zone_id = var.zone_id
name = "_imaps._tcp"
type = "SRV"
ttl = 300
data {
priority = 0
weight = 1
port = 993
target = "imap.migadu.com"
}
}
resource "cloudflare_record" "srv_pop3s" {
zone_id = var.zone_id
name = "_pop3s._tcp"
type = "SRV"
ttl = 300
data {
priority = 0
weight = 1
port = 995
target = "pop.migadu.com"
}
}
resource "cloudflare_record" "srv_submissions" {
zone_id = var.zone_id
name = "_submissions._tcp"
type = "SRV"
ttl = 300
data {
priority = 0
weight = 1
port = 465
target = "smtp.migadu.com"
}
}
resource "cloudflare_record" "txt_dmarc" {
zone_id = var.zone_id
name = "_dmarc"
type = "TXT"
content = "v=DMARC1; p=quarantine;"
proxied = false
ttl = 300
}
resource "cloudflare_record" "txt_spf" {
zone_id = var.zone_id
name = "gipc.dev"
type = "TXT"
content = "v=spf1 include:spf.migadu.com -all"
proxied = false
ttl = 300
}
resource "cloudflare_record" "txt_mail_verify" {
zone_id = var.zone_id
name = "gipc.dev"
type = "TXT"
content = "hosted-email-verify=mj2ixoiz"
proxied = false
ttl = 300
}
# The tunnel itself. config_src = "local" is the truth: ingress rules live in
# /etc/cloudflared/config.yml on the box (see infra/cloudflared + infra/ansible) — a remote
# tunnel_config resource would misstate how this tunnel is actually managed.
resource "cloudflare_zero_trust_tunnel_cloudflared" "gipc" {
account_id = var.account_id
name = "gipc"
secret = var.tunnel_secret
config_src = "local"
# Both secret and config_src are ForceNew, and neither survives import into state (the v4
# provider leaves config_src null on read and never returns the real secret) — so a first
# plan would see null→"local" and null→<dummy> and want to REPLACE the live tunnel. Ignoring
# both suppresses that spurious replacement; the real credential + the local config both live
# on the box, never in Terraform. (Verified at import: state carries the computed tunnel_token
# but not `secret`; config_src imported as null → the replace was driven by config_src, not
# the secret.)
lifecycle {
ignore_changes = [secret, config_src]
}
}