aws proton

Proton opens the pull request. You own everything after it.

Proton opens the pull request. You own everything after it.

The catalog ends with a GitHub Actions job calling aws proton create-service and exiting. This post is what happens on the other side of that call, and the first thing to understand is that Proton did not build anything for us. It wrote some Terraform, put it on a branch, opened a pull request, and waited.

That is not a limitation we hit. It is the only way Proton does Terraform.

Why a provisioning service at all

We already had modules. Every team could terraform init and every team had. The problem was that "we have modules" and "there is one current way to stand up a service" are different sentences, and we were living in the first one: seven repositories with a terraform/ directory, each pinned to whatever the module registry offered on the day it was written, each drifting from the others in ways nobody could see without opening all seven.

What we wanted from Proton was three specific things. Templates versioned in one place, so "current" is a number rather than an opinion. Environments as first-class objects, so a service instance belongs to an environment and inherits its account, VPC and cluster instead of being told about them. And a list — an actual queryable list of every service instance in every environment and which template major and minor version it is running, which is the thing that makes a migration plannable.

Two ways to provision, and Terraform picks one

Proton provisions in one of a few ways, and the method is decided by the template bundle you register, not by a setting.

With AWS-managed provisioning, the bundle contains CloudFormation, Proton renders it, calls CloudFormation itself, watches the stack, and captures the outputs. Nothing of yours runs.

With self-managed provisioning — the only option for Terraform — Proton renders your .tf files, submits a pull request to a repository you registered, and then stops. Your automation merges it, runs the provisioning engine, and tells Proton how it went. The documentation is blunt about the division: provisioned by your code, status tracked by your code.

That difference propagates further than it first appears. It means the pull request is the API. It means a closed PR is a cancelled deployment. It means Proton's view of your infrastructure is exactly as accurate as your callback, and no more.

What a template bundle actually is

Three parts, and the third is a directory.

service-template/
  manifest.yaml
  schema/
    schema.yaml
  infrastructure/
    main.tf
    outputs.tf

schema/schema.yaml is the input contract. It is what draws the form in the Proton console, what validates the spec, and — as the previous post complained at some length — the thing the catalog form should have been generated from.

schema:
  format:
    openapi: '3.0.0'
  service_input_type: HttpServiceInput
  types:
    HttpServiceInput:
      type: object
      description: 'An HTTP service behind the shared ALB'
      properties:
        port:
          type: number
          default: 3000
          minimum: 1
          maximum: 65535
        desired_count:
          type: number
          default: 2
          minimum: 1
          maximum: 20
        cpu:
          type: string
          default: '512'
          enum: ['256', '512', '1024', '2048']
        health_check_path:
          type: string
          default: /healthz
      required:
        - port

infrastructure/ is ordinary Terraform with one unusual rule: the inputs arrive under namespaces. Not var.port, but var.service_instance.inputs.port. Not var.vpc_id, but var.environment.outputs.vpc_id, which is how an instance reaches values the environment produced when it was provisioned.

resource "aws_ecs_service" "this" {
  name            = var.service_instance.name
  cluster         = var.environment.outputs.cluster_arn
  desired_count   = var.service_instance.inputs.desired_count
  task_definition = aws_ecs_task_definition.this.arn

  network_configuration {
    subnets         = var.environment.outputs.private_subnet_ids
    security_groups = [aws_security_group.this.id]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.this.arn
    container_name   = var.service_instance.name
    container_port   = var.service_instance.inputs.port
  }
}

Writing against those namespaces is the part that takes a day to stop feeling strange. The upside is that the template is genuinely portable between environments, because it never names one.

What lands in the branch

When Proton renders, it does not just copy your .tf files. It compiles them with the inputs from the create call and writes two extra files next to them: a proton.{resource-type}.variables.tf declaring the namespaced variables, and a proton.auto.tfvars.json holding the values. Terraform picks up *.auto.tfvars.json without being told, which is the whole trick.

The layout follows the resource hierarchy. Proton looks for an environments folder in the registered repository and, if it finds one, nests everything under it:

/environments
  /env-prod
      main.tf
      proton.environment.variables.tf
      proton.auto.tfvars.json

      /billing-api-prod
          main.tf
          proton.service_instance.variables.tf
          proton.auto.tfvars.json

      /search-indexer-prod
          main.tf
          proton.service_instance.variables.tf
          proton.auto.tfvars.json

A service instance is a directory inside its environment's directory. That is a better default than it sounds, because it makes the blast radius of a plan obvious from the path: everything a single terraform apply can touch is in one folder, and the folder is named after exactly one service instance in exactly one environment.

The tfvars file also carries proton_tags — the account, template and environment ARNs — which you can wire into the provider's default_tags and get provenance on every resource for free. We did, and it paid for itself the first time somebody asked where an unlabelled security group had come from.

The state key is yours to get right

Proton does not manage Terraform state. The backend block is in your template like anything else, which means the same rendered template in twenty directories will happily share one state key unless you make it not.

terraform {
  backend "s3" {}
}

An empty backend block plus -backend-config at init time, with the key derived from the path Proton just rendered into, was the arrangement that survived. Hardcoding a key in the template is the mistake that looks fine until the second instance, and by then the first one is in production.

Closing the PR is an API call

This is the sentence from the documentation I would put on a poster: if the customer closes the PR, Proton recognises it as closed and marks the deployment as cancelled.

The pull request is not a notification about a deployment. It is the deployment. Merge it and provisioning starts; close it and the deployment is cancelled; leave it and Proton waits, which is its own problem — a PR that times out leaves the resource in a state that is neither created nor failed.

Deletion works the same way and is stranger. Proton cannot simply delete the files, because a Terraform directory contains configuration that is not resources — providers, backends — and removing it does not destroy anything, it orphans it. So Proton marks the files for deletion and sets a flag in the PR metadata, and your automation is expected to read that flag and run terraform destroy rather than treating the PR as an ordinary change.

We got that wrong first time round. The delete PR merged, the files went away, the resources did not, and the only evidence was an AWS bill that was flat in a month when things had definitely been deleted.

The callback, and what forgetting it looks like

When your apply finishes, you call NotifyResourceDeploymentStatusChange with the resource ARN, a status, and the outputs.

aws proton notify-resource-deployment-status-change \
  --resource-arn "$PROTON_RESOURCE_ARN" \
  --status SUCCEEDED \
  --deployment-id "$PROTON_DEPLOYMENT_ID" \
  --outputs "$(terraform output -json | jq -c '
      to_entries | map({key: .key, valueString: (.value.value | tostring)})')"

The outputs are not decoration. They are how the next thing reads this thing: an environment's vpc_id and cluster_arn land in the environment's outputs, and every service instance rendered into that environment reads them through var.environment.outputs. Skip them on the environment and every service template that depends on them renders with holes.

Forgetting the call entirely is worse than it sounds, and the failure is silent. Proton keeps showing In progress. Nothing errors. The resources exist, the PR is merged, the ECS service is serving traffic, and the console says the deployment is still running — which means the next update to that instance is refused, and the person who requested it is told nothing useful. We spent an afternoon on that once, on a path where the apply succeeded and the notify step had been skipped by an if: success() on a job whose earlier step had a continue-on-error.

The status is also the only place a failure can be reported. If the apply fails, --status FAILED with a --status-message is what puts the reason in front of the person who pressed the button. Without it they see a spinner and ask you.

What it bought, and what it did not

It bought exactly the three things we asked for. Template versions are numbers. Environments own their accounts and hand their outputs down. And the list exists: every instance, every environment, every template version, queryable, which turned "who is still on v1 of the HTTP template" from an afternoon of grep into a command.

It did not buy speed. A managed-provisioning stack goes from API call to CloudFormation in seconds. Ours went from API call to a pull request, and then sat there until a validation pipeline and a merge decided otherwise. For a developer that is minutes of waiting, and the documentation says so plainly — with self-managed provisioning, developers might experience slower response, because someone has to merge the PR before provisioning can start.

Our answer to that was to make sure the someone was not a person, which turned out to require a good deal more care than "turn on auto-merge". That is the next post.


Written in 2024. AWS has since announced that Proton reaches end of support on 7 October 2026, and it has been closed to new customers since 7 October 2025. The mechanics above are still an accurate description of how self-managed provisioning works, and most of them — a rendered directory per instance, a pull request as the deployment, a status callback — are worth stealing whether or not the thing rendering them is Proton.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev