aws proton
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.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


