Manage a Zero Trust Access application together with the policies that gate it — targeting
cloudflare/cloudflare ~> 5.0.
This composite manages an Access application and its gate:
- 🔐 The application (
cloudflare_zero_trust_access_application.this) — the protected domain/app. - 📜 Its policies (
cloudflare_zero_trust_access_policy.this) — one per key viafor_each, wired into the app by precedence. - 🔒 Deny by default — a policy with no explicit
decisiondefaults todeny; you typeallowto grant.
💡 Why it matters: an Access application and the policies protecting it are one access-control unit. Bundling them — and defaulting policy decisions to
deny— makes the gate explicit, ordered, and fail-closed.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
graph LR
acct["Cloudflare account (account_id)"]:::ext
grp["terraform-cloudflare-zero-trust-access-group"]:::sib
idp["Identity providers (by id)"]:::ext
za["terraform-cloudflare-zero-trust-access-application (this module)"]:::this
res["cloudflare_zero_trust_access_application + _policy"]:::keystone
acct -->|"account_id"| za
grp -->|"group id in policy rules"| za
idp -->|"allowed_idps"| za
za -->|"manages"| res
classDef this fill:#F38020,color:#fff,stroke:#F38020;
classDef keystone fill:#FBAD41,color:#000,stroke:#FBAD41;
classDef sib fill:#f5f5f5,color:#333,stroke:#cccccc;
classDef ext fill:#eeeeff,color:#333,stroke:#9999ff;
graph TD
aid["account_id"]:::in
ap["application = name, domain, type, session_duration"]:::in
po["policies = decision, precedence, include/exclude/require"]:::in
child["cloudflare_zero_trust_access_policy.this (for_each over policies)"]:::child
this["cloudflare_zero_trust_access_application.this (keystone)"]:::this
oid["output: id"]:::out
oaud["output: aud"]:::out
opi["output: policy_ids"]:::out
aid --> this
ap --> this
po --> child
child -->|"policy id + precedence"| this
this --> oid
this --> oaud
child --> opi
classDef this fill:#F38020,color:#fff,stroke:#F38020;
classDef child fill:#FBAD41,color:#000,stroke:#FBAD41;
classDef in fill:#f5f5f5,color:#333,stroke:#cccccc;
classDef out fill:#eeeeff,color:#333,stroke:#9999ff;
Resource inventory
| Resource | Name | Cardinality | Role |
|---|---|---|---|
cloudflare_zero_trust_access_application |
this |
1 (keystone) | The protected Access application. |
cloudflare_zero_trust_access_policy |
this |
0..N (for_each over policies) |
The gating policies, ordered by precedence. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| Provider | cloudflare/cloudflare ~> 5.0 |
| Provider block | None — the caller configures the provider and supplies CLOUDFLARE_API_TOKEN out of band. |
| Scope | account_id (per-resource input, not provider config). |
Schema notes that bite (verified against the live provider schema):
- 🔒
decisiondefaults todenyhere (fail-closed) — setallowexplicitly to grant, ornon_identity/bypassfor their specific gates. - 🔒
application.typeis effectively immutable — changing it forces replacement. ⚠️ Policy order is precedence. The app'spolicieslist carries each policy id with itsprecedence; keep precedences unique.- ℹ️ Rule matchers mirror the Access group module and are typed
anyhere (the union is large and per-rule); reference reusable groups viagroup = { id = ... }. - 🔒 Secrets referenced by id. Service tokens and IdPs are referenced by id — never as plaintext variables.
- ℹ️
tagshere is..._access_application.tags, a per-app label set — not a library tag tail.
Access: Apps and Policies· ReadAccess: Apps and Policies· WriteAccess: Apps and Policies· Revoke
- A Cloudflare Zero Trust organization configured for the account (a team domain).
- At least one identity provider configured (out of band) for identity-based policies.
- Account entitlement for Zero Trust Access.
terraform-cloudflare-zero-trust-access-application/
├── providers.tf # terraform{} + required_providers (cloudflare ~> 5.0); no provider block
├── variables.tf # account_id, application{}, policies{} (for_each, deny-by-default)
├── main.tf # cloudflare_zero_trust_access_policy.this (for_each) + _access_application.this
├── outputs.tf # id first, then account_id, aud, domain, policy_ids
├── README.md # this document
├── SCOPE.md # cross-module contract
├── LICENSE # MIT
└── .gitignore # canonical library ignore set
provider "cloudflare" {}
# export CLOUDFLARE_API_TOKEN=... (scoped to Access: Apps and Policies)
module "internal_app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "Internal Tools", domain = "tools.example.com" }
policies = {
staff = { name = "Allow staff", precedence = 1, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] }
}
}Consumes
| Input | Type | Typical source |
|---|---|---|
account_id |
string |
caller |
application |
object({...}) |
caller |
policies |
map(object({...})) |
caller; group ids from terraform-cloudflare-zero-trust-access-group |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Access application identifier | audit / reporting |
account_id |
Account scope (echoed) | composition |
aud |
Application audience (AUD) tag | JWT / service-token validation |
domain |
Protected domain | operational checks |
policy_ids |
map: policy key → id | audit |
1 · Minimal — app + one allow policy
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "Wiki", domain = "wiki.example.com" }
policies = { staff = { name = "staff", precedence = 1, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] } }
}2 · Application with no policy (denies everyone)
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "Locked", domain = "locked.example.com" }
}🔒 With no policies, the application admits no one — a safe starting point.
3 · Allow specific emails
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "Admin", domain = "admin.example.com" }
policies = {
admins = { name = "admins", precedence = 1, decision = "allow", include = [{ email = { email = "sec@example.com" } }] }
}
}4 · Gate by a reusable Access group
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "Engineering", domain = "eng.example.com" }
policies = {
eng = { name = "engineering", precedence = 1, decision = "allow", include = [{ group = { id = var.eng_group_id } }] }
}
}5 · Explicit deny (block) policy
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "Restricted", domain = "restricted.example.com" }
policies = {
block_contractors = { name = "block contractors", precedence = 1, decision = "deny", include = [{ email_domain = { domain = "contractor.example.net" } }] }
allow_staff = { name = "allow staff", precedence = 2, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] }
}
}6 · Non-identity (service token) access
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "API", domain = "api.example.com", type = "self_hosted" }
policies = {
svc = { name = "service token", precedence = 1, decision = "non_identity", include = [{ service_token = { token_id = var.token_id } }] }
}
}7 · Bypass for an office IP range
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "Intranet", domain = "intranet.example.com" }
policies = {
office = { name = "office bypass", precedence = 1, decision = "bypass", include = [{ ip = { ip = "203.0.113.0/24" } }] }
}
}
⚠️ bypassskips authentication for matching requests — scope it tightly (e.g. a trusted IP range).
8 · Multiple ordered policies
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "Layered", domain = "app.example.com" }
policies = {
deny_embargo = { name = "deny embargo", precedence = 1, decision = "deny", include = [{ geo = { country_code = "KP" } }] }
allow_staff = { name = "allow staff", precedence = 2, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] }
}
}9 · Hardened session (binding cookie, short duration)
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = {
name = "Sensitive"
domain = "sensitive.example.com"
session_duration = "1h"
enable_binding_cookie = true
http_only_cookie_attribute = true
same_site_cookie_attribute = "strict"
}
policies = { staff = { name = "staff", precedence = 1, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] } }
}🔒 A short session, a binding cookie, HttpOnly, and SameSite=strict tighten the session's blast radius.
10 · Require MFA and device posture
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "Prod Console", domain = "console.example.com" }
policies = {
staff = {
name = "staff mfa"
precedence = 1
decision = "allow"
include = [{ email_domain = { domain = "example.com" } }]
require = [{ auth_method = { auth_method = "mfa" } }, { device_posture = { integration_uid = var.posture_id } }]
}
}
}11 · SaaS application type
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "Salesforce", type = "saas", allowed_idps = [var.okta_idp_id], auto_redirect_to_identity = true }
policies = { all_staff = { name = "staff", precedence = 1, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] } }
}12 · Allowed IdPs + auto-redirect
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = {
name = "SSO App"
domain = "sso.example.com"
allowed_idps = [var.okta_idp_id]
auto_redirect_to_identity = true
}
policies = { staff = { name = "staff", precedence = 1, decision = "allow", include = [{ login_method = { id = var.okta_idp_id } }] } }
}13 · 🏗️ End-to-end composition — group + application + policy
provider "cloudflare" {}
variable "cloudflare_account_id" { type = string }
module "eng_group" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-group.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
name = "engineering"
include = [{ email_domain = { domain = "example.com" } }]
require = [{ device_posture = { integration_uid = var.posture_id } }]
}
module "app" {
source = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
account_id = var.cloudflare_account_id
application = { name = "Engineering Tools", domain = "eng.example.com", session_duration = "8h", enable_binding_cookie = true }
policies = {
allow_eng = { name = "allow engineering", precedence = 2, decision = "allow", include = [{ group = { id = module.eng_group.id } }] }
deny_embargo = { name = "deny embargo", precedence = 1, decision = "deny", include = [{ geo = { country_code = "KP" } }] }
}
}
output "app_aud" { value = module.app.aud }🏗️ The group defines "engineering" once (with device posture); the application gates
eng.example.comwith an ordered pair of policies — deny embargoed geos first, then allow the group.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
account_id |
string |
✅ | — | Account the application belongs to. |
application |
object({...}) |
✅ | — | App name, domain, type, session + cookie hardening. |
policies |
map(object({...})) |
— | {} |
Gating policies (deny-by-default), ordered by precedence. |
Full input schemas (from variables.tf)
variable "application" {
type = object({
name = optional(string), domain = optional(string), type = optional(string, "self_hosted"), session_duration = optional(string),
allowed_idps = optional(set(string)), auto_redirect_to_identity = optional(bool),
enable_binding_cookie = optional(bool), http_only_cookie_attribute = optional(bool), same_site_cookie_attribute = optional(string),
tags = optional(set(string)), self_hosted_domains = optional(set(string)),
saas_app = optional(any), cors_headers = optional(any), scim_config = optional(any), mfa_config = optional(any), ... # complex nested passed through
})
# validation: type ∈ supported set
}
variable "policies" {
type = map(object({
name = string, precedence = number, decision = optional(string, "deny"), # allow|deny|non_identity|bypass
include = optional(any, []), exclude = optional(any, []), require = optional(any, []),
session_duration = optional(string), approval_required = optional(bool), isolation_required = optional(bool), ...
}))
default = {}
# validation: decision enum; name non-empty
}| Output | Description | Notes |
|---|---|---|
id |
Access application identifier | Primary reference. |
account_id |
Account scope (echoed) | For composition. |
aud |
Audience (AUD) tag | JWT/service-token validation. |
domain |
Protected domain | — |
policy_ids |
map: policy key → id | Conditional (empty with no policies). |
- Composite, wired by precedence. Policies are
for_eachchildren; their ids are collected into the application'spolicieslist with each policy'sprecedence, so the gate's order lives with the gate. - Deny by default.
decisiondefaults todeny— a policy you forget to markallowfails closed rather than silently granting. - Rules mirror Access groups.
include/exclude/requireuse the same matcher union; they're typedanyhere (the union is large and per-policy) withdecision/name/precedencevalidated, and the provider validates rule specifics at plan. - Immutable type.
application.typeis set at creation; a change replaces the app.
| Concern | Secure default | How to opt out (deliberately) |
|---|---|---|
Policy decision |
deny (fail-closed) |
Set allow/non_identity/bypass explicitly. |
policies |
{} (admits no one) |
Add policies explicitly. |
| Secrets | Referenced by id only | n/a — never plaintext. |
| Session hardening | Opt-in cookie/session controls documented | Enable enable_binding_cookie, short session_duration, etc. |
terraform init -backend=false
terraform validate
terraform fmt -check- Pin by immutable tag
?ref=v1.0.0, never a branch.
- ✅
terraform validate— parsesapplication/policies; enforces the app-type and policy-decision enums and policy names. - ✅
terraform fmt -check— canonical formatting. - ⛔ Not offline: IdP/group/token id validity, Zero Trust org setup, and rule specifics are checked at a real
plan/apply.
$ terraform output
account_id = "023e105f4ecef8ad9ca31a8372d0c353"
aud = "d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"
domain = "tools.example.com"
id = "0f9c8b7a-6d5e-4c3b-2a1f-0e9d8c7b6a5f"
policy_ids = { "staff" = "699d9864-2c56-4d2e-855e-9661899b7252" }
| Symptom | Cause | Fix |
|---|---|---|
| Nobody can access the app | No allow policy (deny-by-default) |
Add a policy with decision = "allow". |
each policy decision must be one of ... |
Bad decision | Use allow/deny/non_identity/bypass. |
| Policies apply in the wrong order | Precedence collision | Give each policy a unique precedence. |
| Group not matched | Wrong group id in a rule | Use the access-group module's id via group = { id = ... }. |
application.type change recreates the app |
Type is immutable | Treat a type change as a new application. |
| Provider auth error | No/insufficient token | Configure the provider with an Access: Apps and Policies token. |
- Cloudflare provider —
cloudflare_zero_trust_access_application,cloudflare_zero_trust_access_policy - Sibling module:
terraform-cloudflare-zero-trust-access-group(reusable membership referenced in policy rules) - This module's
SCOPE.md— the cross-module contract.
🧡 "Infrastructure as Code should be standardized, consistent, and secure."

