Skip to content

Infrastructure as Code with Terraform

This guide walks through the complete workflow for managing b'nerd resources with Terraform: installing the provider, authenticating, writing resource configurations, running plan/apply, and keeping state under control.

Prerequisites: Terraform >= 1.5 installed, a b'nerd account with an API token, and an organization + project already created (see First Project & API Token).


1. Install the Provider

Declare the provider in a versions.tf (or any .tf file) in your working directory:

terraform {
  required_version = ">= 1.5"

  required_providers {
    bnerd = {
      source  = "bnerd-cloud/bnerd"
      version = "~> 0.1"
    }
  }
}

Then initialize:

terraform init

Terraform downloads the provider binary from the registry and locks the version in .terraform.lock.hcl. Commit the lock file; don't commit .terraform/.


2. Configure Authentication

The provider needs four values: API URL, bearer token, organization ID, and project ID. Supply them via environment variables to keep secrets out of source control:

export BNERD_API_URL="https://api.bnerd.cloud"
export BNERD_TOKEN="<your-api-token>"
export BNERD_ORG_ID="<org-uuid>"
export BNERD_PROJECT_ID="<project-uuid>"

Then declare the provider block with no hardcoded values:

provider "bnerd" {}

Alternatively, pass values in HCL — useful when different workspaces target different projects:

provider "bnerd" {
  api_url    = var.bnerd_api_url
  token      = var.bnerd_token
  org_id     = var.org_id
  project_id = var.project_id
}

Keep tokens out of state

API tokens passed via HCL variables end up in terraform.tfstate in plaintext. Use environment variables or a secrets manager (Vault, SOPS, GitLab CI variables) and reference them via TF_VAR_* instead.


3. Define Resources

Create a main.tf with the resources you want to manage. A typical starting point manages a project, object storage credentials, and a DNS zone:

# Project (if not already created out-of-band)
resource "bnerd_project" "main" {
  name            = "my-app"
  organization_id = var.org_id
}

# RGW object storage user for the application
resource "bnerd_rgw_user" "app" {
  name       = "my-app-storage"
  project_id = bnerd_project.main.id
}

# DNS zone
resource "bnerd_dns_zone" "primary" {
  name        = "example.com."   # trailing dot required
  kind        = "Native"
  nameservers = ["ns1.bnerd.net.", "ns2.bnerd.net."]
}

# A record for the web endpoint
resource "bnerd_dns_record" "www" {
  zone_id = bnerd_dns_zone.primary.id
  name    = "www.example.com."
  type    = "A"
  ttl     = 300
  records = ["203.0.113.10"]
}

Expose the S3 credentials as outputs so other systems can consume them:

output "s3_endpoint"   { value = var.bnerd_api_url }
output "s3_access_key" { value = bnerd_rgw_user.app.access_key_id }
output "s3_secret_key" {
  value     = bnerd_rgw_user.app.secret_key
  sensitive = true
}

RGW quota and locked fields

quota and locked on bnerd_rgw_user are computed/read-only — they are managed by platform admins and cannot be set via Terraform. If you need quota adjustments, contact your platform team.


4. Read Existing Infrastructure with Data Sources

If resources already exist (created via the dashboard or CLI), use data sources to reference them without importing or recreating them:

# Look up an existing network to attach a server to
data "bnerd_network" "mgmt" {
  project_id = var.project_id
  # filter by name in the consuming resource if the data source returns a list
}

# Look up an existing DNS zone
data "bnerd_dns_zone" "primary" {
  name = "example.com."
}

# Add a record to the existing zone
resource "bnerd_dns_record" "api" {
  zone_id = data.bnerd_dns_zone.primary.id
  name    = "api.example.com."
  type    = "CNAME"
  ttl     = 300
  records = ["www.example.com."]
}

The full data source catalogue is in the Terraform Provider reference.


5. Plan and Apply

Preview what Terraform will create, change, or destroy:

terraform plan

Review the output carefully — resources marked + will be created, ~ will be updated in-place, -/+ will be destroyed and recreated. Apply when ready:

terraform apply

Terraform prompts for confirmation. Type yes to proceed. Pass -auto-approve only in CI pipelines where the plan has already been reviewed.


6. Manage State

Terraform stores the mapping between your configuration and real resources in terraform.tfstate. Keep this file safe:

  • Never commit state to version control. Add *.tfstate and *.tfstate.backup to .gitignore.
  • Use a remote backend for team workflows. GitLab's built-in HTTP backend is the simplest option for b'nerd projects:
terraform {
  backend "http" {
    # GitLab provides the URL, username, and password via CI variables:
    # TF_HTTP_ADDRESS, TF_HTTP_USERNAME, TF_HTTP_PASSWORD
  }
}

Run terraform init -reconfigure after adding a backend to migrate local state to the remote store.

  • State locking is handled automatically by the GitLab backend — concurrent applies are blocked until the lock is released.

7. Import Existing Resources

If a resource was created outside Terraform (via the dashboard or CLI) and you want to bring it under management, use terraform import:

# Import an existing RGW user by its ID
terraform import bnerd_rgw_user.app <user-id>

# Import an existing DNS zone (supports org_id/zone_name or just zone_name)
terraform import bnerd_dns_zone.primary example.com.

After importing, run terraform plan to verify the state matches your configuration. Adjust any attributes that differ before committing.


8. Destroy

To remove all resources managed by a configuration:

terraform destroy

Irreversible

Destroying an RGW user permanently deletes its access keys and all associated buckets and objects. Destroying a DNS zone removes all records. Always review the destroy plan before confirming.


Using Terraform Modules

For recurring infrastructure patterns — such as IPsec VPN gateways — use the b'nerd reusable modules:

module "ipsec_gateway" {
  source = "git::https://git.bnerd.net/cloud/terraform/common.git//modules/ipsec-gateway?ref=v1.0.0"

  prefix         = "prod-gw"
  router_id      = data.openstack_networking_router_v2.router.id
  network_id     = "<management-network-uuid>"
  subnet_id      = "<management-subnet-uuid>"
  floating_ip    = "203.0.113.10"
  image_id       = data.openstack_images_image_v2.ubuntu.id
  ssh_public_key = file("~/.ssh/id_ed25519.pub")

  tunnels = [
    {
      name         = "tunnel1"
      local_cidr   = "10.0.0.0/24"
      remote_cidrs = ["192.168.10.0/24"]
    },
  ]
}

See the Terraform Modules reference for the full ipsec-gateway input/output documentation.



Dashboard and CLI alongside Terraform

Terraform and the dashboard/CLI work on the same underlying resources. You can:

  • Inspect any Terraform-managed resource in the dashboard or via bnerd <resource> get <id> without affecting Terraform state.
  • Import resources created via the dashboard or CLI into Terraform management with terraform import (Step 7 above).
  • Mix approaches — for example, manage network infrastructure with Terraform but use the dashboard for day-to-day server management. Note that resources managed outside Terraform will show as drift on the next terraform plan.

Troubleshooting

Symptom Likely cause Fix
terraform init fails with provider not found Wrong source address Verify source = "bnerd-cloud/bnerd" in required_providers
403 on terraform apply Token lacks required permissions Check errors[0].code in the error output; verify your token has openstack, rgw, or dns permissions as needed
terraform plan shows unexpected replacement RequiresReplace attribute changed (e.g. project_id on a server) Review the plan's # forces replacement lines before applying; these destroy+recreate the resource
State drift after manual changes Resource was modified via dashboard or CLI outside Terraform Run terraform plan to see the drift; use terraform apply to reconcile or terraform import to bring the current state in
locked file error on terraform init Concurrent init or stale lock Delete .terraform.lock.hcl and re-run terraform init
RGW quota/locked show as empty in plan These are admin-only computed fields This is expected — they are set by platform admins, not the Terraform configuration

API Reference

The Terraform provider calls the same REST API documented in the API Reference. If a Terraform operation fails with an unexpected error, the raw API response is preserved in errors[0].raw (debug only — do not log this field in production). The human-readable message is in errors[0].message.


Next Steps