Automate AWS account creation with GitLab
Send this guide to your coding agent.
Prerequisite: Install Port MCP
Open plan mode. Implement this Port guide in my org via MCP: https://docs.port.io/guides/all/create-new-aws-account-gitlab/ Goal: get the guide's core flow working end-to-end in my org; adapting it to fit my existing setup takes priority over matching the guide 1:1. Plan: 1. Confirm MCP is connected, in the right org, with sufficient permissions. 2. Diff the guide's data model (blueprints, properties, relations, actions, agents, automations, integrations, secrets) against mine. 3. Propose adaptations for gaps, reusing existing blueprints/relations over guide-named duplicates. 4. Flag what needs a UI click, credential, or secret from me, testing MCP capability empirically before ruling anything out. 5. Stop on any blocker and give me options. Approving this plan authorizes the writes it lists; pause only for writes beyond what's listed. Build: - Extend blueprint schema additively when upserting; don't remove or overwrite existing properties, and treat type conflicts as a blocker, not an auto-fix. - List any mock data in the plan, minimal and labeled mock; once approved, seed it without re-asking, and tell me what you seeded. - For anything the guide writes downstream (e.g. a webhook target), use a real entity, not a mock. - For pages/widgets, use the real page identifier from the app URL, not a guessed slug. - When you hit a UI step confirmed (not assumed) unsupported via MCP, pause, give exact clicks, then resume via MCP. - Validate and give links after each meaningful step (only a tool-returned URL, no guessed paths); don't proceed if the last run wasn't a success. Done: - Confirm the guide's expected output exists and runs in Port. - Summarize adaptations, seeded data, what was mocked or skipped, remaining UI steps, and how to verify.
This guide demonstrates how to build a Port workflow that provisions new AWS accounts using GitLab CI/CD and Terraform.
The workflow collects the account request from a developer, uses an AI node to validate it against the accounts already in your catalog, and then routes the request. Approved requests trigger a GitLab pipeline that runs Terraform against AWS Organizations. Rejected requests are recorded in the catalog with the reason, so denied requests leave an audit trail instead of disappearing.
Port triggers the pipeline through your installed GitLab integration, so there is no trigger token to mint and no webhook URL to copy.
Port workflows are currently in open beta and available to all users. Workflows may undergo changes without prior notice.
Common use cases
- Multi-account strategy: Enable teams to quickly spin up dedicated AWS accounts for different projects or environments.
- Development environments: Provide developers with isolated AWS accounts for testing and development.
- Client onboarding: Streamline the process of creating dedicated AWS accounts for new clients or business units.
- Compliance and security: Maintain standardized account configurations with proper IAM roles and policies.
- Cost management: Enable better cost tracking and resource isolation through dedicated accounts.
Prerequisites
This guide assumes you have:
- Completed the onboarding process.
- A GitLab integration installed in your Port organization, with a token that can trigger pipelines. A legacy PAT needs the
apiscope. For a fine-grained PAT, see the GitLab integration action prerequisites. - A GitLab project that will hold the Terraform configuration and pipeline.
- AWS Organizations access with permissions to create new accounts.
- AWS IAM credentials for Terraform operations.
If you run the GitLab integration self-hosted through Helm or Docker, actions processing is disabled by default and must be enabled explicitly before Port can trigger pipelines. See enable actions processing.
Set up data model
We'll create a blueprint to represent AWS accounts in your Port catalog.
-
Go to the Builder page in Port.
-
Click on
+ Blueprint. -
Click on the
{...}button in the top right corner, and chooseEdit JSON. -
Add this JSON schema:
AWS account blueprint (Click to expand)
{"identifier": "awsAccount","description": "This blueprint represents an AWS account in our context lake.","title": "AWS account","icon": "AWS","schema": {"properties": {"account_name": {"type": "string","title": "Account name","description": "The name of the AWS account."},"email": {"type": "string","format": "email","title": "Root email","description": "The root email address for the account. Must be globally unique across AWS."},"role_name": {"type": "string","title": "IAM role name","description": "The name of the IAM role created for cross-account management."},"account_id": {"type": "string","title": "AWS account ID","description": "The account ID assigned by AWS once the account is created."},"purpose": {"type": "string","title": "Purpose","description": "Why this account was requested."},"requested_by": {"type": "string","format": "user","title": "Requested by"},"rejection_reason": {"type": "string","title": "Rejection reason","description": "Populated when a request is rejected during validation."},"status": {"type": "string","title": "Status","enum": ["active","rejected"],"enumColors": {"active": "green","rejected": "red"}}},"required": ["account_name","email"]},"relations": {}} -
Click
Saveto create the blueprint.
If you also run Port's AWS integration, it ingests AWS accounts into its own blueprint. The awsAccount blueprint above tracks the request and its outcome, including rejected requests that never became real accounts. Keep both, or relate them, depending on how you want to model provisioning history.
Set up the GitLab project
The workflow calls a GitLab pipeline to run Terraform, because creating an AWS account requires signed AWS API calls that a workflow cannot make directly.
Add GitLab CI/CD variables
In your GitLab project, go to Settings > CI/CD > Variables and add the following variables:
TF_USER_AWS_KEY- AWS Access Key ID with Organizations permissions.TF_USER_AWS_SECRET- AWS Secret Access Key.TF_USER_AWS_REGION- AWS region for resource creation, for exampleus-east-1.
The workflow writes to your catalog with a native UPSERT_ENTITY node, so the pipeline never calls Port's API. You do not need PORT_CLIENT_ID or PORT_CLIENT_SECRET here unless you add the optional account ID capture.
Set up Terraform configuration
Create the following Terraform files in your GitLab project.
Main Terraform configuration
Create main.tf:
Terraform main configuration (Click to expand)
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.0"
}
}
}
provider "aws" {
region = var.region
}
resource "aws_organizations_account" "account" {
name = var.name
email = var.email
role_name = var.role_name
close_on_deletion = true
lifecycle {
ignore_changes = [role_name]
}
}
With close_on_deletion = true, running terraform destroy closes the AWS account rather than just removing it from state. Review your state management before using this in production.
Create Terraform variables
Create variables.tf. The variable names here must match the names the pipeline passes with -var:
Terraform variables configuration (Click to expand)
variable "region" {
description = "AWS region where resources will be created"
type = string
default = "us-east-1"
}
variable "name" {
description = "Name of the AWS account to be created"
type = string
}
variable "email" {
description = "Root email to attach to the AWS account"
type = string
}
variable "role_name" {
description = "Name of the IAM role to attach"
type = string
default = "OrganizationAccountAccessRole"
}
Create Terraform outputs
Create outputs.tf:
Terraform outputs configuration (Click to expand)
output "account_id" {
value = aws_organizations_account.account.id
}
output "account_name" {
value = aws_organizations_account.account.name
}
output "email" {
value = aws_organizations_account.account.email
}
output "role_name" {
value = aws_organizations_account.account.role_name
}
Add the GitLab pipeline
Create .gitlab-ci.yml in your project. Port passes the account details as CI/CD variables, so the pipeline reads them directly with $ACCOUNT_NAME, $EMAIL, and $ROLE_NAME:
GitLab CI/CD pipeline (Click to expand)
stages:
- terraform
image:
name: hashicorp/terraform:light
entrypoint:
- '/usr/bin/env'
- 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
variables:
AWS_ACCESS_KEY_ID: ${TF_USER_AWS_KEY}
AWS_SECRET_ACCESS_KEY: ${TF_USER_AWS_SECRET}
AWS_DEFAULT_REGION: ${TF_USER_AWS_REGION}
create-aws-account:
stage: terraform
rules:
- if: $CI_PIPELINE_SOURCE == "api"
script:
- echo "Creating AWS account ${ACCOUNT_NAME}..."
- terraform init
- terraform apply -auto-approve
-var "name=${ACCOUNT_NAME}"
-var "email=${EMAIL}"
-var "role_name=${ROLE_NAME}"
Port reports the pipeline's outcome back to the workflow automatically, so the pipeline does not need to fetch a Port token, post logs, or patch a run status.
Build the workflow
The diagram below shows the workflow you are about to create. Hover a node to inspect its configuration.
-
Go to the Workflows page in Port.
-
Click on the
+ Workflowbutton in the top-right corner. -
Click on the
{...}button in the top right corner. -
Copy and paste the workflow JSON below into the editor to replace the example workflow:
Create AWS account workflow (Click to expand)
{"identifier": "create_aws_account","title": "Create an AWS account","icon": "AWS","description": "Validate an AWS account request and provision it with GitLab CI/CD and Terraform","allowAnyoneToViewRuns": true,"nodes": [{"identifier": "trigger","title": "Request an AWS account","icon": "AWS","description": "Collect the details for the new AWS account","config": {"type": "SELF_SERVE_TRIGGER","userInputs": {"properties": {"account_name": {"icon": "AWS","title": "Account name","description": "The desired name for the new AWS account","type": "string"},"email": {"title": "Root email","description": "The root email for the account. Must not be used by any existing AWS account","type": "string","format": "email"},"role_name": {"title": "IAM role name","description": "The cross-account management role to create","type": "string","default": "OrganizationAccountAccessRole"},"purpose": {"title": "Purpose","description": "What this account will be used for","type": "string"}},"required": ["account_name","email","purpose"],"order": ["account_name","email","purpose","role_name"]},"contexts": [{"on": "CREATE_ENTITY","blueprintIdentifier": "awsAccount"}],"published": true},"variables": {}},{"identifier": "validate_request","title": "Validate the request","icon": "DefaultProperty","description": "Check the request against existing accounts in the catalog","config": {"type": "AI","userPrompt": "A user requested a new AWS account.\n\nAccount name: {{ .outputs.trigger.account_name }}\nRoot email: {{ .outputs.trigger.email }}\nPurpose: {{ .outputs.trigger.purpose }}\n\nList the existing entities of the awsAccount blueprint, then decide whether to approve this request.\n\nReject the request if any of the following is true:\n- An existing awsAccount entity already uses this root email. AWS requires a globally unique root email per account.\n- An existing awsAccount entity already uses this account name.\n- The stated purpose is empty or does not describe a legitimate workload.\n\nSet normalized_account_name to the account name in lowercase, with every character that is not a letter or a digit replaced by a single hyphen.\n\nAlways explain your decision in reason.","systemPrompt": "You are an AWS account provisioning reviewer. Call list_entities at most twice, then return the structured verdict immediately. Do not call any other tools and do not loop. Approve only when you are confident the request is valid. When in doubt, reject and explain why.","tools": ["list_blueprints","list_entities"],"outputSchema": {"type": "object","properties": {"approved": {"type": "boolean"},"reason": {"type": "string"},"normalized_account_name": {"type": "string"}},"required": ["approved","reason","normalized_account_name"]}},"variables": {}},{"identifier": "check_approval","title": "Check validation result","icon": "DefaultProperty","description": "Route the request based on the validation verdict","config": {"type": "CONDITION","outlets": [{"identifier": "approved","title": "Approved","expression": "(.outputs.validate_request.response | fromjson | .approved) == true"},{"identifier": "rejected","title": "Rejected","expression": "(.outputs.validate_request.response | fromjson | .approved) != true"}]},"variables": {}},{"identifier": "provision_account","title": "Provision the AWS account","icon": "GitLab","description": "Trigger the GitLab pipeline that runs Terraform","config": {"type": "INTEGRATION_ACTION","integrationProvider": "gitlab-v2","deferIntegrationInstallation": true,"integrationInvocationType": "trigger_pipeline","integrationActionExecutionProperties": {"project": "<YOUR_GITLAB_PROJECT_PATH>","ref": "main","pipelineVariables": {"ACCOUNT_NAME": "{{ .outputs.validate_request.response | fromjson | .normalized_account_name }}","EMAIL": "{{ .outputs.trigger.email }}","ROLE_NAME": "{{ .outputs.trigger.role_name }}"},"reportPipelineStatus": true}},"variables": {}},{"identifier": "record_account","title": "Record the account","icon": "AWS","description": "Create the AWS account entity in the catalog","config": {"type": "UPSERT_ENTITY","blueprintIdentifier": "awsAccount","mapping": {"identifier": "{{ .outputs.validate_request.response | fromjson | .normalized_account_name }}","title": "{{ .outputs.trigger.account_name }}","properties": {"account_name": "{{ .outputs.trigger.account_name }}","email": "{{ .outputs.trigger.email }}","role_name": "{{ .outputs.trigger.role_name }}","purpose": "{{ .outputs.trigger.purpose }}","requested_by": "{{ .workflowRun.trigger.by.email }}","status": "active"}}},"variables": {}},{"identifier": "record_rejection","title": "Record the rejection","icon": "AWS","description": "Track the denied request and why it was denied","config": {"type": "UPSERT_ENTITY","blueprintIdentifier": "awsAccount","mapping": {"identifier": "{{ .outputs.trigger.account_name | ascii_downcase | gsub(\"[^a-z0-9]+\"; \"-\") }}","title": "{{ .outputs.trigger.account_name }}","properties": {"account_name": "{{ .outputs.trigger.account_name }}","email": "{{ .outputs.trigger.email }}","purpose": "{{ .outputs.trigger.purpose }}","requested_by": "{{ .workflowRun.trigger.by.email }}","rejection_reason": "{{ .outputs.validate_request.response | fromjson | .reason }}","status": "rejected"}}},"variables": {}}],"connections": [{"sourceIdentifier": "trigger","targetIdentifier": "validate_request"},{"sourceIdentifier": "validate_request","targetIdentifier": "check_approval"},{"sourceIdentifier": "check_approval","targetIdentifier": "provision_account","sourceOutletIdentifier": "approved"},{"sourceIdentifier": "check_approval","targetIdentifier": "record_rejection","sourceOutletIdentifier": "rejected"},{"sourceIdentifier": "provision_account","targetIdentifier": "record_account"}]} -
Replace
<YOUR_GITLAB_PROJECT_PATH>with your GitLab project path, for examplemy-group/aws-accounts. You can also use the numeric project ID. -
Click Save to save the workflow.
Connect the GitLab integration
The provision_account node uses deferIntegrationInstallation, so the workflow saves without an installation ID and Port resolves it for you.
After publishing, open the setup checklist in the top-right of the workflow graph and click Connect on the GitLab item. Once the integration is connected, the node is satisfied with no further edits.
Integration action nodes cannot reference organization secrets or encrypted user inputs. Your AWS credentials belong in GitLab's CI/CD variables, as configured above, not in the workflow.
Let's test it!
Test 1: a valid request
-
Head to the self-service page in Port.
-
Click on the
Create an AWS accountworkflow. -
Fill in the account details:
- Account name: A descriptive name for the new AWS account.
- Root email: A unique email address that no existing AWS account uses.
- Purpose: What the account is for.
- IAM role name: The management role to create. Leave the default if you are unsure.
-
Click on
Execute. -
Verify that:
- The
validate_requestnode approves the request. - The GitLab pipeline runs and Terraform creates the account.
- The
provision_accountnode turns green once the pipeline completes. - An
awsAccountentity appears in your catalog with statusactive. - The account exists in your AWS Organizations console.
- The
Test 2: a duplicate request
-
Trigger the workflow again with the same root email you used in test 1.
-
Verify that:
- The
validate_requestnode rejects the request. - The workflow follows the
rejectedbranch and never triggers the pipeline. - The entity's
rejection_reasonexplains that the email is already in use.
- The
Capture the AWS account ID (optional)
The default workflow records the request details but not the account ID that AWS assigns, because that value only exists after Terraform runs. To bring it back into the catalog, let the pipeline report the node result itself.
-
In the
provision_accountnode, setreportPipelineStatustofalseand add the node run identifier topipelineVariables:"pipelineVariables": {"ACCOUNT_NAME": "{{ .outputs.validate_request.response | fromjson | .normalized_account_name }}","EMAIL": "{{ .outputs.trigger.email }}","ROLE_NAME": "{{ .outputs.trigger.role_name }}","PORT_NODE_RUN_ID": "{{ .workflowNodeRun.identifier }}"},"reportPipelineStatus": false -
Add
PORT_CLIENT_IDandPORT_CLIENT_SECRETto your GitLab CI/CD variables. See Port API credentials. -
Report the result from the same job that runs Terraform. The Terraform state and the
terraformbinary are only available inside that job, so a separate reporting job would not be able to read the outputs:Report the account ID back to Port (Click to expand)
create-aws-account:stage: terraformrules:- if: $CI_PIPELINE_SOURCE == "api"before_script:- apk add --no-cache curl jqscript:- terraform init- terraform apply -auto-approve-var "name=${ACCOUNT_NAME}"-var "email=${EMAIL}"-var "role_name=${ROLE_NAME}"- |ACCOUNT_ID=$(terraform output -raw account_id)ACCESS_TOKEN=$(curl -s -X POST \-H 'Content-Type: application/json' \-d "{\"clientId\": \"${PORT_CLIENT_ID}\", \"clientSecret\": \"${PORT_CLIENT_SECRET}\"}" \'https://api.port.io/v1/auth/access_token' | jq -r '.accessToken')curl -X PATCH \-H 'Content-Type: application/json' \-H "Authorization: Bearer ${ACCESS_TOKEN}" \-d "{\"status\": \"COMPLETED\", \"result\": \"SUCCESS\", \"output\": {\"account_id\": \"${ACCOUNT_ID}\"}, \"links\": [\"${CI_PIPELINE_URL}\"]}" \"https://api.port.io/v1/workflows/nodes/runs/${PORT_NODE_RUN_ID}"after_script:- |if [ "$CI_JOB_STATUS" != "success" ]; thenACCESS_TOKEN=$(curl -s -X POST \-H 'Content-Type: application/json' \-d "{\"clientId\": \"${PORT_CLIENT_ID}\", \"clientSecret\": \"${PORT_CLIENT_SECRET}\"}" \'https://api.port.io/v1/auth/access_token' | jq -r '.accessToken')curl -X PATCH \-H 'Content-Type: application/json' \-H "Authorization: Bearer ${ACCESS_TOKEN}" \-d "{\"status\": \"COMPLETED\", \"result\": \"FAILED\", \"links\": [\"${CI_PIPELINE_URL}\"]}" \"https://api.port.io/v1/workflows/nodes/runs/${PORT_NODE_RUN_ID}"fi -
Add the account ID to the
record_accountnode's mapping:"account_id": "{{ .outputs.provision_account.account_id }}"
With reportPipelineStatus set to false, Port stops tracking the pipeline and the node stays in progress until your pipeline patches it. The after_script above covers the failure case, so a failed Terraform run does not leave the node hanging. If you restructure this job, keep a failure path that reports FAILED.
Conclusion
You now have a single workflow that validates AWS account requests against your catalog, provisions approved ones through GitLab CI/CD and Terraform, and records both approvals and rejections in Port.
To extend it further:
- Add an input node so a platform engineer signs off before provisioning.
- Add a
WEBHOOKnode to notify the requesting team in Slack. - Relate the
awsAccountblueprint to your teams or cost center blueprints for ownership and chargeback. - Explore more workflow examples for inspiration.