case studies

Cloud Provisioning With FlowChassis Tears Down What a Failed Deploy Built

Putting a site on a new server takes a handful of calls to different providers. Create the VM. Point a DNS record at it. Get a TLS certificate. Then wait until the thing answers its health check, which can take a while because VMs take time to boot.

When the certificate step fails, you’re left with a VM you pay for by the hour and a DNS record pointing at it. Run that a few hundred times across a team and you get the cloud bill everyone recognises: servers nobody remembers creating.

This case study is a complete program in the repository. Three sites go through the same flow, and each ends a different way: completed, compensated, and failed with a clear note of what’s left.

Three deploys through VM, DNS record, certificate and health check. shop.example.com completes after three health checks. blog.example.net’s certificate is refused, so the record is removed and the VM destroyed. legacy.example.org’s certificate is refused and the record can’t be removed, so the run stops with the VM and record left for a person.

The Flow

Four steps. Each one that creates something saves its ID in the run’s state, and its undo uses that ID to remove it:

{
	Name: "add_dns",
	Do: func(c *flowchassis.Ctx) error {
		rec, err := cloud.DNS.Create(c, "A record "+site(c).Domain+" pointing at "+get(c, "vm"))
		if err != nil {
			return err
		}
		c.Set("dns", []byte(rec))
		return nil
	},
	Undo: func(c *flowchassis.Ctx) error {
		return cloud.DNS.Do(c, "remove "+get(c, "dns"))
	},
	// Also the policy for the undo: three tries to remove the
	// record, then the run stops and waits for a person.
	Retry: flowchassis.RetryPolicy{MaxAttempts: 3, Backoff: 50 * time.Millisecond},
},

The health check is where retries earn their keep. A fresh VM refuses connections until it’s up, so the step keeps trying, waiting longer each time:

{
	Name: "health_check",
	Do: func(c *flowchassis.Ctx) error {
		return cloud.Compute.Do(c, "GET https://"+site(c).Domain+"/health")
	},
	// A VM takes a while to boot: keep checking, waiting longer
	// each time, for up to about half a minute.
	Retry: flowchassis.RetryPolicy{MaxAttempts: 12, Backoff: 100 * time.Millisecond, Multiplier: 2, MaxBackoff: 5 * time.Second},
},

The wait between checks is in the journal. If the program restarts during it, the wait goes on from the time recorded there and doesn’t start over.

What Happens

The program’s output, with only the closing lines about the journal’s path left out:

shop.example.com:
  compute    create small VM for shop.example.com -> compute-1
  dns        A record shop.example.com pointing at compute-1 -> dns-1
  ca         certificate for shop.example.com -> ca-1
  compute    GET https://shop.example.com/health -> error: connection refused, still booting
  compute    GET https://shop.example.com/health -> error: connection refused, still booting
  compute    GET https://shop.example.com/health -> done
  => completed
blog.example.net:
  compute    create small VM for blog.example.net -> compute-2
  dns        A record blog.example.net pointing at compute-2 -> dns-2
  ca         certificate for blog.example.net -> error: CAA record forbids this CA for the domain
  dns        remove dns-2 -> done
  compute    destroy compute-2 -> done
  => compensated (step issue_cert: CAA record forbids this CA for the domain)
legacy.example.org:
  compute    create large VM for legacy.example.org -> compute-3
  dns        A record legacy.example.org pointing at compute-3 -> dns-3
  ca         certificate for legacy.example.org -> error: CAA record forbids this CA for the domain
  dns        remove dns-3 -> error: DNS provider API: 503 service unavailable
  dns        remove dns-3 -> error: DNS provider API: 503 service unavailable
  dns        remove dns-3 -> error: DNS provider API: 503 service unavailable
  => failed (undo add_dns: DNS provider API: 503 service unavailable)
     left for a person: vm=compute-3 dns=dns-3

The shop comes up after two refused health checks. Nobody wrote a polling loop.

The blog’s certificate is refused because the domain’s CAA record doesn’t allow that CA. Retrying won’t change a DNS policy, so the certificate step checks for that answer and wraps it in flowchassis.Permanent, and the run compensates at once:

cert, err := cloud.CA.Create(c, "certificate for "+site(c).Domain)
if errors.Is(err, errCAARefused) {
	// A DNS policy won't change on a retry.
	return flowchassis.Permanent(err)
}
``` Order matters here. The record is removed first, then the VM it points at is destroyed: newest first, the reverse of how they were built.

## When the Cleanup Fails Too

The third site is the case most tools leave out. The certificate is refused, compensation starts, and the DNS provider's API is down. The undo is tried three times, as its policy says, and fails each time.

At that point FlowChassis stops. It doesn't skip ahead and destroy the VM anyway. Destroying a server while a DNS record still points at its address is how you end up with a name pointing at an IP that someone else gets next. The safe thing is to leave both and say so.

The run ends `failed`. In FlowChassis that status means compensation couldn't finish and a person needs to look. The run's state still holds the IDs of what was built, so the person knows exactly what to clean up. The `flowchassis` command shows the whole story, with times:

```text
$ flowchassis show provision.journal legacy.example.org
run      legacy.example.org
flow     provision
status   failed
error    undo add_dns: DNS provider API: 503 service unavailable
started  2026-10-18T12:43:57+03:00
ended    2026-10-18T12:43:57+03:00
done     create_vm, add_dns
input    {"domain":"legacy.example.org","size":"large"}
state
  dns = dns-3
  vm = compute-3
history
  12:43:57.104  started provision
  12:43:57.105  create_vm: attempt 1 began
  12:43:57.105  create_vm: done, next add_dns
  12:43:57.105  add_dns: attempt 1 began
  12:43:57.106  add_dns: done, next issue_cert
  12:43:57.106  issue_cert: attempt 1 began
  12:43:57.106  issue_cert: attempt 1 failed for good, compensating: CAA record forbids this CA for the domain
  12:43:57.106  undo add_dns: attempt 1 began
  12:43:57.106  undo add_dns: attempt 1 failed, retry at 12:43:57.156: DNS provider API: 503 service unavailable
  12:43:57.157  undo add_dns: attempt 2 began
  12:43:57.158  undo add_dns: attempt 2 failed, retry at 12:43:57.258: DNS provider API: 503 service unavailable
  12:43:57.258  undo add_dns: attempt 3 began
  12:43:57.259  undo add_dns: attempt 3 failed for good, run failed: DNS provider API: 503 service unavailable

What To Take From It

A deploy script that only handles success leaves a mess on failure. One that tries to handle every failure turns into the bigger part of the codebase. Here the flow says what each step builds and how to remove it. Whatever goes wrong, the run ends in a state you can read, and when it can’t clean up by itself it says exactly what’s left.

go run ./examples/provision

The account signup case study shows the same pattern on a smaller scale, and the video transcoding case study is about the other kind of failure: the process itself dying in the middle.