Skip to content

Phase 1 — Build

Phase 1 deploys an authoritative primary DNS zone on F5 Distributed Cloud with Terraform, backed by remote state in Azure Blob Storage. This page shows every file in the plan, explains the required variables and secrets, and covers both a local run and the GitHub Actions pipeline.

  • An xcsh_dns_zone for your delegated domain, in the system namespace, with a demo-records group of A records (www, app, api).
  • Terraform state stored in an Azure Blob Storage container, so the same state is shared between your machine and CI.

The plan lives under terraform/: a thin root module that wires inputs into a dns-zone module.

Pins Terraform and the provider. The provider constraint is >= 3.62.0 — the release where system-only DNS resources default their namespace automatically.

terraform {
required_version = ">= 1.5"
required_providers {
xcsh = {
source = "f5-sales-demo/xcsh"
# >= 3.62.0: the namespace attribute for system-only DNS resources defaults
# to "system" (spec-driven), so it can be omitted. Locally the provider is
# consumed via dev_overrides, which ignores this constraint.
version = ">= 3.62.0"
}
}
}

The provider takes no arguments in code — it authenticates from the environment, so no secrets are committed.

# The xcsh provider authenticates from the environment — no secrets in code.
# Export one of the following credential sets before running Terraform:
#
# Token auth: XCSH_API_URL + XCSH_API_TOKEN
# P12 auth: XCSH_API_URL + XCSH_P12_FILE + XCSH_P12_PASSWORD
# PEM auth: XCSH_API_URL + XCSH_CERT + XCSH_KEY
#
# See terraform/README.md for local dev setup (dev_overrides + env).
provider "xcsh" {}

A partial azurerm backend: no environment-specific values are hardcoded. The coordinates are supplied at init time (from a local file, or from GitHub Actions variables in CI).

terraform {
# Azure Blob Storage remote state, configured as a PARTIAL backend:
# no environment-specific values are hardcoded here. Supply them at init time.
#
# CI: terraform init -backend-config="resource_group_name=$RG" ...
# (values from GitHub Actions repository variables)
# Local: terraform init -backend-config=backend.hcl (copy backend.hcl.example; gitignored)
#
# Auth is the storage account access key via the ARM_ACCESS_KEY environment
# variable (never committed). Keyless auth (use_oidc / use_azuread_auth) is not
# used: our Contributor-only RBAC cannot assign the "Storage Blob Data
# Contributor" role those methods require.
backend "azurerm" {}
}

domain is required (supplied at runtime, never hardcoded); a_records maps each record name to its IPv4 addresses. There is no namespace variable — the provider fixes DNS objects to the system namespace from the API spec’s constraint, so it is never configured here.

variable "domain" {
description = "DNS zone FQDN (required; supplied via TF_VAR_domain / a GitHub variable / tfvars)."
type = string
}
variable "labels" {
description = "Labels applied to managed DNS objects."
type = map(string)
default = {
managed_by = "terraform"
use_case = "dns"
}
}
variable "a_records" {
description = "A records: record name (\"\" = apex) to list of IPv4 addresses."
type = map(list(string))
default = {
www = ["203.0.113.10"]
app = ["203.0.113.20"]
api = ["203.0.113.30"]
}
}

The root module wires inputs into ./modules/dns-zone:

module "dns_zone" {
source = "./modules/dns-zone"
domain = var.domain
labels = var.labels
a_records = var.a_records
}

The module creates the zone (namespace is omitted — the provider defaults it to system). A dynamic "rr_set" block turns the a_records map into one record set per entry:

resource "xcsh_dns_zone" "this" {
name = var.domain
labels = var.labels
primary {
default_soa_parameters {}
rr_set_group {
metadata {
name = "demo-records"
}
dynamic "rr_set" {
for_each = var.a_records
content {
ttl = var.record_ttl
a_record {
name = rr_set.key
values = rr_set.value
}
}
}
}
}
}

Root outputs.tf re-exports the zone name and F5 XC identifier from the module.

Nothing environment-specific is baked into the .tf files. Everything is supplied at runtime — from GitHub Actions variables and secrets in CI, or from local files and environment variables.

ValuePurposeCI sourceLocal source
resource_group_name, storage_account_name, container_name, keyazurerm backend coordinatesRepository variables TFSTATE_RESOURCE_GROUP, TFSTATE_STORAGE_ACCOUNT, TFSTATE_CONTAINER, TFSTATE_KEY (passed via -backend-config)backend.hcl (copy backend.hcl.example; gitignored)
domainTerraform inputRepository variable DNS_DOMAIN (as TF_VAR_domain)terraform.tfvars (copy the example) or TF_VAR_domain
ARM_ACCESS_KEYazurerm backend auth (storage account key)Repository secretexport ARM_ACCESS_KEY=...
XCSH_API_URL, XCSH_API_TOKENxcsh provider authRepository secretsexport XCSH_API_URL=... XCSH_API_TOKEN=...

The two example files that ship in the repo:

resource_group_name = "f5-sales-demo-tfstate"
storage_account_name = "f5salesdemotfstate"
container_name = "tfstate"
key = "dns.tfstate"

The backend storage cannot store its own bootstrap, so create it once, out of band, with an authenticated Azure CLI session. The repo ships scripts/bootstrap-azure-state.sh:

Terminal window
az group create --name f5-sales-demo-tfstate --location eastus2 \
--tags managed_by=terraform use_case=dns purpose=tfstate
az storage account create --name f5salesdemotfstate --resource-group f5-sales-demo-tfstate \
--location eastus2 --sku Standard_LRS --kind StorageV2 \
--min-tls-version TLS1_2 --allow-blob-public-access false
# State safety: keep prior versions and allow recovery of deleted state blobs.
az storage account blob-service-properties update \
--account-name f5salesdemotfstate --resource-group f5-sales-demo-tfstate \
--enable-versioning true \
--enable-delete-retention true --delete-retention-days 7 \
--enable-container-delete-retention true --container-delete-retention-days 7
KEY="$(az storage account keys list \
--account-name f5salesdemotfstate --resource-group f5-sales-demo-tfstate \
--query '[0].value' -o tsv)"
az storage container create --name tfstate \
--account-name f5salesdemotfstate --auth-mode key --account-key "$KEY"

Then export the key for local runs, and set it (plus the provider credentials) as GitHub secrets:

Terminal window
export ARM_ACCESS_KEY="$KEY"
gh secret set ARM_ACCESS_KEY -R f5-sales-demo/dns
gh secret set XCSH_API_URL -R f5-sales-demo/dns
gh secret set XCSH_API_TOKEN -R f5-sales-demo/dns
  1. Configure inputs. Copy the examples and fill in your values:

    Terminal window
    cp terraform/backend.hcl.example terraform/backend.hcl
    cp terraform/terraform.tfvars.example terraform/terraform.tfvars
  2. Export credentials:

    Terminal window
    export XCSH_API_URL="https://<tenant>.console.ves.volterra.io"
    export XCSH_API_TOKEN="<api-token>"
    export ARM_ACCESS_KEY="<storage-account-key>"
  3. Initialize the backend with the partial config:

    Terminal window
    cd terraform
    terraform init -backend-config=backend.hcl
  4. Check formatting and validate:

    Terminal window
    terraform fmt -check -recursive
    terraform validate
  5. Plan and apply:

    Terminal window
    terraform plan
    terraform apply

A successful apply creates the zone and writes state to the Azure container. Continue to Phase 2 — Validate to confirm it resolves.

The .github/workflows/terraform.yml workflow runs a plan on every pull request and an apply on merge to main. It reads the backend coordinates and inputs from repository variables and the credentials from secrets, injecting each only into the step that needs it, and serializes runs on a concurrency group so two applies never race on the shared state blob.

name: Terraform
on:
pull_request:
branches: [main]
paths: ['terraform/**', '.github/workflows/terraform.yml']
push:
branches: [main]
paths: ['terraform/**']
permissions:
contents: read
concurrency:
group: terraform-state
cancel-in-progress: false
env:
TF_IN_AUTOMATION: 'true'
jobs:
terraform:
name: ${{ github.event_name == 'push' && 'apply' || 'plan' }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Terraform
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
- name: Init
env:
ARM_ACCESS_KEY: ${{ secrets.ARM_ACCESS_KEY }}
RESOURCE_GROUP: ${{ vars.TFSTATE_RESOURCE_GROUP }}
STORAGE_ACCOUNT: ${{ vars.TFSTATE_STORAGE_ACCOUNT }}
CONTAINER: ${{ vars.TFSTATE_CONTAINER }}
STATE_KEY: ${{ vars.TFSTATE_KEY }}
run: |
terraform init -input=false \
-backend-config="resource_group_name=${RESOURCE_GROUP}" \
-backend-config="storage_account_name=${STORAGE_ACCOUNT}" \
-backend-config="container_name=${CONTAINER}" \
-backend-config="key=${STATE_KEY}"
- name: Format check
run: terraform fmt -check -recursive
- name: Validate
run: terraform validate -no-color
- name: Plan
if: github.event_name == 'pull_request'
env:
ARM_ACCESS_KEY: ${{ secrets.ARM_ACCESS_KEY }}
XCSH_API_URL: ${{ secrets.XCSH_API_URL }}
XCSH_API_TOKEN: ${{ secrets.XCSH_API_TOKEN }}
TF_VAR_domain: ${{ vars.DNS_DOMAIN }}
run: terraform plan -input=false -no-color
- name: Apply
if: github.event_name == 'push'
env:
ARM_ACCESS_KEY: ${{ secrets.ARM_ACCESS_KEY }}
XCSH_API_URL: ${{ secrets.XCSH_API_URL }}
XCSH_API_TOKEN: ${{ secrets.XCSH_API_TOKEN }}
TF_VAR_domain: ${{ vars.DNS_DOMAIN }}
run: terraform apply -input=false -auto-approve -no-color

The actions are pinned to commit SHAs (with version comments), and secrets are scoped to the init, plan, and apply steps rather than the whole job.