← Back to Projects Custom Terraform Provider for TerraTowns demo
01

Problem

Terraform's power comes from its providers — the plugins that translate HCL resource definitions into real API calls. But when you need to manage infrastructure on a platform that doesn't have an existing provider, you hit a wall. The ExamPro Terraform Beginner Bootcamp 2023 used TerraTowns, a custom cloud hosting platform, as its deployment target. No public Terraform provider existed for it. Deploying homes to TerraTowns manually — outside of Terraform — would defeat the entire purpose of IaC: reproducibility, version control, and automated teardown. The challenge was to bring TerraTowns fully under Terraform's control, so that terraform apply could create, update, and destroy hosted content the same way it manages any AWS or GCP resource.

At the same time, each home needed real hosting infrastructure behind it — a place to serve static HTML and assets to the public. Standing that up manually for two separate homes would mean duplicated effort, inconsistent configuration, and no easy path to tearing it all down cleanly.

02

Solution

I built a custom Terraform provider in Goterraform-provider-terratowns — that implements the full CRUD lifecycle for TerraTowns homes via its REST API. The provider is compiled into a local binary and resolved by Terraform through the local.providers/local/terratowns source path, making it a first-class citizen alongside any community provider.

For the hosting layer, I wrote a reusable Terraform module — terrahome_aws — that provisions an S3 bucket, CloudFront distribution, and Origin Access Control configuration as a single deployable unit. The module is instantiated twice: once for an Arcanum game guide, once for a Vietnamese Pho cooking page. The CloudFront domain name output from each module instance flows directly into the corresponding terratowns_home resource, wiring both layers together through Terraform's dependency graph. Remote state is managed in Terraform Cloud, and the entire dev environment is automated in Gitpod — so the full toolchain (Go, Terraform CLI, AWS CLI) spins up reproducibly from any fresh workspace.

03

Skills Acquired

Each skill reinforced a broader pattern: in infrastructure work, the integration between layers — provider to API, module to resource, CDN to bucket — is where complexity lives and where real understanding is built.

04

Deep Dive

Overview

This project came out of the ExamPro Terraform Beginner Bootcamp 2023 — a hands-on program that goes well beyond running terraform apply against existing providers. The core challenge: build a custom Terraform provider from scratch in Go that can create, read, update, and delete "homes" on TerraTowns, a real cloud hosting platform used as the bootcamp's deployment target.

Alongside the custom provider, I built a reusable Terraform module (terrahome_aws) that provisions the underlying AWS infrastructure — an S3 bucket for static asset storage and a CloudFront distribution for content delivery — and wired both together in a root main.tf that deploys two live homes: a guide on playing Arcanum in 2023 and a page about making Vietnamese Pho.

Writing a custom provider means implementing the full CRUD lifecycle yourself — in Go — each operation backed by real HTTP calls to the TerraTowns REST API. This is how every provider in the Terraform Registry works under the hood.

Architecture

The stack has two independent layers wired together through Terraform's dependency graph. The AWS layer (terrahome_aws module) provisions the hosting infrastructure. The TerraTowns layer (the custom provider) registers each home on the platform, using the CloudFront domain name output from the AWS module as its domain_name argument.

main.tf (Terraform Cloud · org: testtf-organization · workspace: terra-house-1) ├── module "terrahome_arcanum" & module "terrahome_pho" │ └── ./modules/terrahome_aws │ ├── aws_s3_bucket (static assets) │ ├── aws_s3_bucket_website_configuration │ ├── aws_s3_object index.html + assets/ │ ├── aws_cloudfront_origin_access_control │ ├── aws_cloudfront_distribution (CDN, PriceClass_200) │ └── terraform_data.invalidate_cache (auto-invalidation) └── resource "terratowns_home" "arcanum" & "pho" └── terraform-provider-terratowns (local Go binary) └── HTTP ──► TerraTowns API (town: missingo)

State is managed remotely in Terraform Cloud (organization testtf-organization, workspace terra-house-1), keeping the state file safe and enabling future team collaboration. The dev environment runs in Gitpod with automated install scripts so the full toolchain — Go, Terraform CLI, AWS CLI — spins up consistently from any fresh workspace.


The Custom Provider in Go

The terraform-provider-terratowns binary is compiled with go build into a local plugin path. It registers one resource type — terratowns_home — with five schema fields and implements all four CRUD functions using Go's standard net/http package. Three required provider inputs (endpoint, token, user_uuid) are passed into every HTTP request via a *Config struct.

Create

resourceHouseCreate — POST /u/:uuid/homes

Encodes the five fields into a JSON body and POSTs to the API. On success, the returned home UUID is stored with d.SetId() — Terraform uses it for all subsequent operations.

main.go
func resourceHouseCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
    config := m.(*Config)
    payload := map[string]interface{}{
        "name":            d.Get("name").(string),
        "description":     d.Get("description").(string),
        "domain_name":     d.Get("domain_name").(string),
        "town":            d.Get("town").(string),
        "content_version": d.Get("content_version").(int),
    }
    payloadBytes, _ := json.Marshal(payload)
    req, _ := http.NewRequest("POST",
        config.Endpoint+"/u/"+config.UserUuid+"/homes",
        bytes.NewBuffer(payloadBytes))
    req.Header.Set("Authorization", "Bearer "+config.Token)
    req.Header.Set("Content-Type", "application/json")
    // ... send, decode response, extract homeUUID ...
    d.SetId(homeUUID)
    return diags
}

Read

resourceHouseRead — GET /u/:uuid/homes/:home_uuid

Syncs remote state back into Terraform. If the API returns 404, d.SetId("") tells Terraform the resource is gone — handling out-of-band deletions without crashing the plan.

main.go
if resp.StatusCode == http.StatusNotFound {
    d.SetId("")  // resource deleted outside Terraform
    return diags
}
// ... decode and set fields back into state ...

Update

resourceHouseUpdate — PUT /u/:uuid/homes/:home_uuid

Sends a PUT with updated fields. domain_name is intentionally excluded — the TerraTowns API doesn't allow changing the domain after creation.

Delete

resourceHouseDelete — DELETE /u/:uuid/homes/:home_uuid

Sends DELETE and calls d.SetId("") on success. Terraform removes any resource with an empty ID from state.

Local Testing

Mock Server — Ruby/Sinatra

A Sinatra app in terratowns_mock_server/ mirrors all four API endpoints locally, making it possible to test the full CRUD cycle without touching the live platform.


The terrahome_aws Module

The module at ./modules/terrahome_aws is instantiated twice — once per home — with different public_path and content_version inputs. It provisions seven resources and outputs bucket_name, website_endpoint, and domain_name (the CloudFront URL that gets passed to the custom provider).

ResourceWhat it does
aws_s3_bucketPrivate bucket tagged with UserUuid — no public access
aws_s3_bucket_website_configurationStatic website hosting with index.html / error.html routing
aws_s3_objectUploads index.html + assets/*.{jpg,png,gif} via fileset(); version-triggered via terraform_data
aws_s3_bucket_policyLocks S3 to the specific CloudFront distribution via OAC + AWS:SourceArn condition
aws_cloudfront_origin_access_controlSigV4 signing, signing_behavior = "always"
aws_cloudfront_distributionCDN — PriceClass_200, TTLs 0/3600/86400, IPv6, default CloudFront certificate
terraform_data.invalidate_cachelocal-exec provisioner: runs aws cloudfront create-invalidation --paths '/*' on content version bump
resource-storage.tf
resource "aws_s3_object" "upload_assets" {
  for_each = fileset("${var.public_path}/assets", "*.{jpg,png,gif}")
  bucket   = aws_s3_bucket.website_bucket.bucket
  key      = "assets/${each.key}"
  source   = "${var.public_path}/assets/${each.key}"
  etag     = filemd5("${var.public_path}/assets/${each.key}")
  lifecycle {
    replace_triggered_by = [terraform_data.content_version.output]
    ignore_changes       = [etag]
  }
}

resource "aws_cloudfront_origin_access_control" "default" {
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "terraform_data" "invalidate_cache" {
  triggers_replace = terraform_data.content_version.output
  provisioner "local-exec" {
    command = "aws cloudfront create-invalidation --distribution-id ${aws_cloudfront_distribution.s3_distribution.id} --paths '/*'"
  }
}

Root Configuration — Two Homes Deployed

The root main.tf instantiates the module and the custom provider resource twice. The CloudFront domain_name output from each module flows directly into its terratowns_home resource — Terraform resolves the dependency automatically.

main.tf
module "terrahome_arcanum" {
  source          = "./modules/terrahome_aws"
  user_uuid       = var.user_uuid
  public_path     = var.arcanum.public_path
  content_version = var.arcanum.content_version
}

resource "terratowns_home" "arcanum" {
  name            = "How to play Arcanum in 2023!"
  description     = "Arcanum shipped with a lot of bugs but with mods it's really fun to play!"
  domain_name     = module.terrahome_arcanum.domain_name
  town            = "missingo"
  content_version = var.arcanum.content_version
}

module "terrahome_pho" {
  source          = "./modules/terrahome_aws"
  user_uuid       = var.user_uuid
  public_path     = var.pho.public_path
  content_version = var.pho.content_version
}

resource "terratowns_home" "pho" {
  name            = "It's Pho me!"
  description     = "How to make your own Pho at home!"
  domain_name     = module.terrahome_pho.domain_name
  town            = "missingo"
  content_version = var.pho.content_version
}

Reproducible Dev Environment — Gitpod

The full toolchain is automated through .gitpod.yml and bash scripts in bin/. Opening the repo in Gitpod triggers install scripts for the Terraform CLI, the AWS CLI, and Go. Gitpod's gp env command persists sensitive values — AWS credentials, the TerraTowns API token, user UUID — across workspace restarts without ever committing them to the repo.

Environment-as-code: the setup is version-controlled, repeatable, and portable across any Gitpod workspace — the same principle behind production dev containers and CI pipelines.

Key Takeaways

Key Takeaway

Almost all Terraform work involves consuming existing providers. Building one from scratch exposed the full contract Terraform expects: a compiled plugin binary, schema-validated inputs, CRUD functions that accurately reflect remote state, and clean teardown on delete.

Want to See the Full Code?

The complete provider, AWS module, journal entries, and Gitpod config are all on GitHub.