platform engineering
The button in Port and the service it makes

The mission for the year was easy to state and slow to finish: any team should be able to spawn a new service without us in the room. We had spent the previous stretch pulling shared pieces out of a monolith and moving the estate off EC2, and by the time that was working the bottleneck had moved. Writing the Terraform for a new service was not hard. Knowing which repository it went in, which account it deployed to, which module version was current, who to ask for the ECR repository, and which Slack channel to announce it in — that was hard, and it all lived in people.
So we put a catalog in front of it. Port held the entities and the forms, GitHub Actions held the work, and AWS Proton held the infrastructure templates. This post is about the first two thirds: the button, and what pressing it actually starts.
One form, one dispatch
A Port self-service action is a JSON schema and a backend. The schema draws the form. The backend is what happens when someone submits it. For us the backend was always the same shape — dispatch a workflow in one repository — because the moment there are three ways to launch platform work there are three ways for it to be wrong.
{
"identifier": "create_service",
"title": "Create a service",
"trigger": {
"type": "self-service",
"operation": "CREATE",
"blueprintIdentifier": "service",
"userInputs": {
"properties": {
"name": {
"type": "string",
"title": "Service name",
"pattern": "^[a-z][a-z0-9-]{2,29}$"
},
"template": {
"type": "string",
"title": "Template",
"enum": ["nestjs-http", "worker-sqs", "library"]
},
"needs_infrastructure": { "type": "boolean", "default": true },
"team": { "type": "string", "blueprint": "team", "format": "entity" }
},
"required": ["name", "template", "team"]
}
},
"invocationMethod": {
"type": "GITHUB",
"org": "our-org",
"repo": "platform-actions",
"workflow": "create-service.yml",
"reportWorkflowStatus": true,
"workflowInputs": {
"name": "{{ .inputs.name }}",
"template": "{{ .inputs.template }}",
"needs_infrastructure": "{{ .inputs.needs_infrastructure }}",
"team": "{{ .inputs.team.identifier }}",
"run_id": "{{ .run.id }}",
"requested_by": "{{ .trigger.by.user.email }}"
}
}
}
Two things in there are worth more than they look.
pattern on the name is the only validation that happens before anything
exists. That string becomes a repository name, an ECS service name, a Terraform
state key and a DNS label, and every one of those has an opinion about what
characters are legal. Rejecting it in the form costs a person four seconds.
Rejecting it in terraform apply costs a person twenty minutes and leaves a
repository behind.
run_id is how the workflow talks back. Port opens a run when the form is
submitted, and that run is the only thing the requester can see. A workflow
that does not write to it is a workflow that gets triggered twice, because from
the other side an empty run and a broken run look identical.
The two things behind it
Every action we shipped fell into one of two buckets, and keeping the buckets apart mattered more than anything else in the design.
Orchestration. Create the repository from a
Cookiecutter template, push the initial
commit, set branch protection and CODEOWNERS, add the repository to the right
teams, register the entity in the catalog, post to Slack. All of it is API
calls. All of it finishes in under a minute. All of it is cheap to undo.
Infrastructure. An ECS service, a queue, a bucket, a database user, the DNS record, the alarms. This is the part that has a representation in an AWS account, takes minutes rather than seconds, and cannot be undone by deleting a row.
The workflow is one file with that split in the middle of it:
name: create-service
on:
workflow_dispatch:
inputs:
name: { required: true, type: string }
template: { required: true, type: string }
needs_infrastructure: { required: true, type: string }
team: { required: true, type: string }
run_id: { required: true, type: string }
requested_by: { required: true, type: string }
jobs:
scaffold:
runs-on: ubuntu-latest
steps:
- uses: port-labs/port-github-action@v1
with:
clientId: ${{ secrets.PORT_CLIENT_ID }}
clientSecret: ${{ secrets.PORT_CLIENT_SECRET }}
operation: PATCH_RUN
runId: ${{ inputs.run_id }}
logMessage: 'Creating repository ${{ inputs.name }}'
- name: Render the template
run: |
pipx run cookiecutter --no-input \
gh:our-org/service-templates --directory "${{ inputs.template }}" \
service_name="${{ inputs.name }}" owning_team="${{ inputs.team }}"
- name: Create and seed the repository
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: ./scripts/create-repo.sh "${{ inputs.name }}"
provision:
needs: scaffold
if: inputs.needs_infrastructure == 'true'
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.PROTON_CALLER_ROLE }}
aws-region: eu-west-1
- name: Ask Proton for a service
run: |
aws proton create-service \
--name "${{ inputs.name }}" \
--template-name "${{ inputs.template }}" \
--template-major-version 1 \
--spec file://spec.yaml \
--repository-connection-arn "$CONNECTION_ARN" \
--repository-id "our-org/${{ inputs.name }}" \
--branch-name main
The if on the second job is the whole argument. A shared library needs a
repository, a CI workflow and a place in the catalog, and nothing else. A
frontend for an internal tool needs a repository and a Vercel project. Roughly
a third of what people asked the catalog for did not need an AWS account
touched at all, and making those requests wait behind a provisioning path they
did not use would have been the fastest way to teach everyone that the button
is slow.
When it is set, the second job does not build anything. It hands the request to Proton and stops. What happens next — a rendered Terraform pull request, a validation run, an automatic merge, an apply — is the subject of the next two posts. From the workflow's point of view it is fire and forget, and that is a design decision with a cost I will come back to.
Writing back, or the button gets pressed twice
Port updates the run status when the workflow finishes, which is enough for a workflow that takes forty seconds and not nearly enough for one that takes twelve minutes.
We wrote progress into the run at every step boundary — repository created, here is the link; template rendered; Proton service requested, here is the service instance. It reads like logging and it is not. It is the only feedback loop the requester has, and the failure mode it prevents is not confusion, it is duplication. Someone who cannot tell whether the thing is working will press the button again, and the second press arrives while the first is half-finished.
Which brings up the other half of that problem.
Pressing it twice anyway
Someone will. A double-click, a timeout on Port's side, a person who was not sure the first one took. Every action had to survive being run twice with the same inputs, and the honest way to get there was to make each step check before it acted rather than to build one clever lock.
Creating the repository is the easy one: GitHub returns 422 if the name is
taken, so the script treats "already exists, and it was created by this
template" as success and anything else as failure. Registering the catalog
entity is an upsert by identifier. Asking Proton for a service that already
exists is a ConflictException, which we caught and turned into a link to the
existing one.
None of that is elegant. It is a pile of special cases, one per API. It is also the only version of this I have seen work, because the alternative — a lock in the platform's own store, released when the workflow finishes — has its own failure mode, and that failure mode is a stuck lock at 6pm on a Friday.
The name is the one input you cannot take back
Everything else in the form is editable later. The team can change, the instance size can change, the alarm thresholds can change. The name cannot, because by the time anyone wants to change it the name is in a repository URL, an ECR path, a Terraform state key, a log group, three dashboards and a runbook.
We put the regex in the form, and we should also have put a reserved-word list
next to it. api, service, test, new — the names that are fine in
isolation and useless in a list of sixty. That is a five-line change I did not
make, and I noticed it every time I read the catalog.
The form and the schema are the same schema, twice
Here is the part I would do differently.
The Port action carries a JSON schema describing the inputs. The Proton service
template carries a schema.yaml describing the inputs. They describe the same
inputs. They are two files, in two repositories, in two formats, maintained by
hand.
They drift. Someone adds a parameter to the Proton template, bumps the minor version, and the catalog form does not know it exists — so the parameter takes its default forever and the person who added it wonders why nothing changed. Or the form offers a value the template does not accept, and the request dies several minutes later inside a rendered Terraform plan, which is the worst possible place to discover a typo.
The right shape is one source. Proton's schema.yaml is already the
authoritative description of what the template takes, and Port's action can be
created and updated over its API. A small job that reads the registered
template versions and writes the corresponding actions would have removed the
whole class of problem. We talked about it, we sketched it, and we never
shipped it, because the drift was slow enough to keep patching by hand and fast
enough to keep annoying us. That is exactly the sort of thing that never gets
prioritised.
What actually made it work
Not the catalog. Port is a good portal and the forms are pleasant, but the portal is the surface. If I had to name the decision that made the difference, it was that there were only two backends behind every button, and one of them was "call Proton and stop".
Before this, spawning a service meant knowing things. After it, spawning a service meant filling in a form whose fields were the only things worth knowing, and everything behind the form was either an API call or somebody else's provisioning engine. The number of ways to get it wrong went from "however many people are on the platform team" to two.
The provisioning side of that sentence turned out to be considerably more interesting than I expected, and it is where the next post starts: Proton does not apply your Terraform. It opens a pull request and waits for you.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


