Cloudflare Zero Trust¶
The cloudflare role manages Cloudflare Tunnels (connectors) and Cloudflare Access applications. It uses the Cloudflare API directly – no cloudflared login required on the control node.
How it works¶
- Connector (tunnel): A named
cloudflaredtunnel is created in your Cloudflare account and installed as a systemd service on the target host. - Published hostnames: Each route in
published_hostnamesis added to the tunnel's ingress config and gets a CNAME DNS record pointing to the tunnel. - Access applications: Each route also gets a corresponding Cloudflare Access application that enforces authentication policies before allowing traffic through.
Required secrets¶
These must be in an encrypted vault file:
vault_cloudflare_credentials_api_token: "..." # Cloudflare API token
vault_cloudflare_credentials_account_id: "..." # Cloudflare account ID
Minimal host configuration¶
The simplest way to configure a web host is via cloudflare.zero_trust in the host's main.yml:
# inventories/prod/host_vars/bkdk-web-01/main.yml
cloudflare:
zero_trust:
slug_web: bkdk-e01 # becomes the tunnel name and SSH hostname prefix
application:
public_hostnames:
- subdomain: cms
domain: barberklingen.dk
path: /wp/wp-graphql # allow public access to this path
- subdomain: cms
domain: barberklingen.dk
path: wc-api/*
private_hostnames:
- subdomain: cms
domain: barberklingen.dk
path: '*' # require authentication for everything else
The role automatically: - Creates a tunnel named bkdk-e01 - Adds an SSH ingress rule pointing to localhost:22 - Creates CNAME DNS records for all published hostnames - Creates Access applications with appropriate policies
Access policies¶
Three pre-defined policy UUIDs are available in roles/cloudflare/defaults/main.yml:
| Variable | Cloudflare policy name | Who it allows |
|---|---|---|
cloudflare_policy_allow_emails | ALLOW-GROUP-IT-ORG | Subscribed team email addresses |
cloudflare_policy_allow_bitbucket_ci | ALLOW-GROUP-BITBUCKET-CI | Bitbucket CI service token |
cloudflare_policy_allow_all | BYPASS-GROUP-PUBLIC-WEB | Everyone (no authentication) |
Always use the variable, never hardcode the policy name or UUID. Combine policies only when necessary – default to the most restrictive option.
To assign a specific policy to a route:
cloudflare:
zero_trust:
connector:
application_routes:
- subdomain: metrics
domain: example.com
service: http
url: localhost:9090
policies:
- "{{ cloudflare_policy_allow_emails }}"
Advanced connector and application configuration¶
For full control, use the lower-level variables directly.
Connectors define what the server exposes to Cloudflare (the tunnel ingress). Applications define who may access those exposed services (the Access policy layer).
cloudflare_connectors:
- name: mysite-e01
published_hostnames:
- subdomain: mysite-e01
domain: subscribed.build
service: ssh
url: localhost:22
- subdomain: cms
domain: mysite.dk
service: http
url: localhost:80
cloudflare_applications:
- name: mysite-e01 - SSH
attr:
type: self_hosted
hostnames:
- subdomain: mysite-e01
domain: subscribed.build
policies:
- "{{ cloudflare_policy_allow_emails }}"
- "{{ cloudflare_policy_allow_bitbucket_ci }}"
Adding connectors or applications without replacing the base¶
Use the _extra variants – they are merged additively with the base lists:
cloudflare_connectors_extra:
- name: mysite-e01-extra
published_hostnames:
- subdomain: metrics
domain: subscribed.build
service: http
url: localhost:9090
cloudflare_applications_extra:
- name: mysite-e01 - Metrics
attr:
policy:
- "{{ cloudflare_policy_allow_emails }}"
hostnames:
- subdomain: metrics
domain: subscribed.build
path: ""
Extending existing public/private applications¶
To add hostnames to the built-in private or public application without creating a new application:
# Protected by Access policy (admin routes, internal paths)
cloudflare_private_hostnames:
- subdomain: cms
domain: mysite.dk
path: '*'
# Intentionally open to the public (no authentication)
cloudflare_public_hostnames:
- subdomain: cms
domain: mysite.dk
path: /wp/wp-graphql
Keep the existing default entries in these lists – they are part of the base setup.
Zero Trust lockdown mode¶
When zt_lockdown_enabled: true, the server becomes fully tunnel-only. Three things happen:
- SSH is bound to
127.0.0.1only (sshdno longer listens on the network) - UFW removes all allow rules for port 22 and adds an explicit deny
- Access shifts to Cloudflare: connectors publish
localhost:22as a TCP service; Access applications control who may open a session
The model is service-level exposure, not server-level: the host itself is not broadly reachable – only specific routes defined in cloudflare_connectors are exposed, and only to principals allowed by cloudflare_applications.
Enabling lockdown¶
See the New server guide for the full workflow. The lockdown.yml playbook verifies that cloudflared is healthy before closing the firewall.
SSH access after lockdown¶
# Direct SSH via cloudflared
ssh -o ProxyCommand="cloudflared access ssh --hostname %h" pt@bkdk-e01.subscribed.build
# Or configure ~/.ssh/config
Host *.subscribed.build
User pt
IdentityFile ~/.ssh/id_ed25519
ProxyCommand cloudflared access ssh --hostname %h
Ansible access after lockdown¶
Set these host variables:
# inventories/prod/host_vars/bkdk-web-01/main.yml
ansible_host: bkdk-e01.subscribed.build
ansible_ssh_common_args: '-o ProxyCommand="cloudflared access ssh --hostname %h"'
Install cloudflared without creating a tunnel¶
Set cloudflared_install_only: true to install the cloudflared binary and service without configuring a tunnel. Useful during the bootstrap phase.
Key defaults¶
| Variable | Default | Description |
|---|---|---|
cloudflared_remote_config | true | Use Cloudflare-managed config (not local YAML) |
cloudflared_create_tunnel | true | Create tunnel if it doesn't exist |
cloudflared_dns_proxied | true | DNS records are proxied through Cloudflare |
cloudflare_application_session_duration | 24h | Access session duration |
cloudflared_no_log | false | Suppress sensitive output in task logs |