# Terraform (/infrastructure/terraform)



The `infra/terraform` modules manage the edge and secret surface of `zumik.ai`: the Cloudflare zone
that fronts the OVH origin VPS, and a renderer that turns the managed-provider and billing secrets into
a deliverable `.env`-shaped file. It deliberately does **not** provision the VPS (that stays a
documented runbook on a single box) and does **not** manage the GPU Kubernetes profiles - those are the
[BYOC stack](/infrastructure/byoc-stack) and [portable Kubernetes](/infrastructure/portable-kubernetes)
Helm charts.

## Layout [#layout]

```text
terraform/
├── versions.tf            provider + Terraform version pins, remote (R2/S3) backend stub
├── providers.tf           Cloudflare provider (token from a sensitive var)
├── variables.tf           all root inputs; every secret marked sensitive, no defaults
├── main.tf                zone lookup + module wiring
├── outputs.tf
├── terraform.tfvars.example
└── modules/
    ├── cloudflare/        DNS records, WAF rate-limit ruleset, zone SSL/TLS settings
    └── provider-secrets/  renders the provider + Stripe + ZUMIK_SECRET_KEY env template
```

## What it manages [#what-it-manages]

### Cloudflare (`modules/cloudflare`) [#cloudflare-modulescloudflare]

<CardGroup cols="2">
  <Card title="DNS" icon="globe">
    Proxied A (and optional AAAA) records for the apex, `www`, `api`, `console`, and `auth` pointing at
    the origin VPS; `docs` either delegated or on the origin. `status` is left alone by default - the
    [uptime worker](/infrastructure/uptime-monitoring) owns that hostname.
  </Card>

  <Card title="WAF rate limiting" icon="shield-halved">
    Per-IP edge caps in the `http_ratelimit` phase, stricter on auth and inference than on reads. This
    is the outermost layer only; per-key and per-project limits live deeper at
    [Bifrost and api-core](/infrastructure/bifrost).
  </Card>

  <Card title="Zone TLS settings" icon="lock">
    SSL Full (strict), Always Use HTTPS, HSTS (1y, preload, subdomains), a TLS 1.2 floor with 1.3,
    Brotli, HTTP/3, Early Hints, and Rocket Loader off (it breaks Next.js hydration).
  </Card>

  <Card title="Defense in depth" icon="layer-group">
    A server-to-server API client cannot solve a challenge, so the inference burst cap always blocks
    rather than challenges. Reads carry looser limits.
  </Card>
</CardGroup>

The WAF rules implement the layered rate-limit policy: a failed-login brute-force cap (5/IP/10 min,
counting only 401/403/429), a signup and password-reset cap (3/IP/hour), an always-block inference
burst cap, and a looser read-endpoint cap.

### Secrets (`modules/provider-secrets`) [#secrets-modulesprovider-secrets]

This module renders the five managed-provider keys, the Stripe trio, and `ZUMIK_SECRET_KEY` into a
`.env`-shaped file via `local_sensitive_file`, so values never surface in plan or apply output. All
inputs are `sensitive` variables with no defaults - nothing is hardcoded. The rendered file is
gitignored; deliver it to the host or a secret store out of band.

<Warning>
  This is a convenience renderer, not a secret store. For production, prefer Cloudflare Secrets or a
  KMS and feed `TF_VAR_*` from there. Because state carries the rendered-secret content hash, the
  backend bucket must itself be access-controlled and encrypted, and you must never run with local
  state in CI.
</Warning>

## Usage [#usage]

Secrets come from the environment, never a committed file:

```sh
cd infra/terraform

export TF_VAR_cloudflare_api_token='cf-token-with-zone+dns+waf-edit'
export TF_VAR_zumik_secret_key="$(openssl rand -hex 32)"
export TF_VAR_provider_keys='{openai="sk-...",anthropic="sk-ant-...",gemini="...",xai="...",fireworks="..."}'
export TF_VAR_stripe='{secret_key="sk_live_...",webhook_secret="whsec_...",price_base_monthly="price_..."}'

cp terraform.tfvars.example terraform.tfvars   # fill the NON-secret values (account id, origin ip)
```

<Steps>
  <Step title="Validate locally without a backend">
    ```sh
    terraform init -backend=false
    terraform fmt -check
    terraform validate
    ```
  </Step>

  <Step title="Init against remote state">
    The `s3` backend block in `versions.tf` is intentionally partial - no bucket or endpoint is
    committed. Cloudflare R2 is S3-compatible; point a `backend.hcl` at the R2 bucket holding Zumik IaC
    state.

    ```sh
    terraform init -backend-config=backend.hcl
    ```
  </Step>

  <Step title="Plan and apply">
    ```sh
    terraform plan
    terraform apply
    ```
  </Step>
</Steps>

## Conventions [#conventions]

* Provider versions are pinned exactly in `versions.tf`; commit the generated `.terraform.lock.hcl`.
* Every secret variable is `sensitive = true` and has no default.
* Real Cloudflare resource names only (`cloudflare_record`, `cloudflare_ruleset`,
  `cloudflare_zone_settings_override`, the `cloudflare_zone` data source).
* `terraform.tfvars`, `rendered.env`, and all state are gitignored.

The rendered provider keys feed the [Bifrost gateway](/infrastructure/bifrost) config, and the WAF
ruleset is the edge layer of the rate-limiting story described there.
