Atlantis Pull Request Automation for GIS Terraform
The workflow most spatial platform teams actually run is: someone opens a pull request, someone else runs terraform plan on their laptop, pastes the output into a comment, gets an approval, and applies from that laptop. Every part of that is a problem — the plan nobody can reproduce, the credentials on a workstation, the gap between the reviewed plan and the applied one, and the state lock held by a person rather than a system. Atlantis closes it by making the pull request the place where planning and applying happen, with the plan comment produced by the same process that will apply it. This guide extends Pipeline Orchestration for Spatial Deploys within CI/CD Automation and Governance.
What changes, and what it costs
Atlantis runs as a long-lived service that receives repository webhooks, runs plan on pull-request events, comments the output, and runs apply when an authorised person comments the command. Three consequences matter for a spatial estate specifically.
The plan and the apply are the same artifact. Atlantis applies the saved plan file, so what was approved is what runs. In an estate where an apply can replace a database this is the difference between a reviewed change and a hopeful one.
Locking becomes visible. Atlantis holds a workspace lock per project for the life of the pull request, and it says so in the comment. On a repository holding a shared PostGIS cluster and several tile services, that visibility is the mechanism that stops two pull requests racing on the same state.
A long-lived service now holds deployment credentials. This is the cost, and it is real. The Atlantis instance can apply changes to production, so it is as sensitive as the pipeline identity it replaces — and unlike a short-lived job, it is always running. It belongs in a private subnet, behind authentication, with per-project roles rather than one broad credential.
Prerequisites and environment assumptions
Terraform 1.6 or later. A repository whose Terraform is organised into projects with their own state, which is the arrangement Atlantis expects and the one Refactoring a Monolithic GIS Stack into Composable Modules produces. A private network in which the service can run with a public webhook path exposed through a load balancer, and a webhook secret so the endpoint cannot be driven by anyone who finds it. Per-project IAM roles that Atlantis assumes rather than one credential covering everything.
Decide the approval model before deployment. apply_requirements supporting approved and mergeable are the two that matter: the first requires a human approval on the pull request, the second requires the branch to be up to date and passing checks. For an estate holding authoritative geometry, both should be on, and for production projects an additional restriction on who may issue the apply command.
Step-by-step deployment
-
Define projects explicitly in
atlantis.yaml. Autodiscovery is convenient and imprecise; an explicit project list makes the plan scope, the workspace and the apply requirements visible in the repository. It is also where per-project requirements — stricter for the database, looser for the tile service — are expressed. -
Add custom workflow steps for the spatial gates. The policy check, the plan-contract assertions and the cost estimate all belong in the plan workflow so their output appears in the same comment as the plan. A gate whose result is in a different system is a gate reviewers do not read.
-
Give each project its own role. The Atlantis instance assumes a role per project rather than holding one credential. A pull request touching the tile service then cannot, even in principle, modify the database.
-
Restrict who may apply, per project. Comment-driven applies are convenient and the convenience must not extend to production database changes. Restrict the apply command on sensitive projects to a named team.
-
Run it privately with a webhook secret and authentication. The service holds deployment credentials and is always running. Public exposure limited to the webhook path, the user interface behind authentication, and the whole thing in a private subnet.
# atlantis.yaml — explicit projects. Autodiscovery is convenient and imprecise;
# this makes plan scope, workspace and approval rules reviewable in the repo.
version: 3
automerge: false
parallel_plan: true
parallel_apply: false # applies touch shared spatial state — keep them serial
projects:
- name: postgis-cluster
dir: infra/data
workspace: prod
terraform_version: v1.9.5
autoplan:
when_modified: ["*.tf", "../modules/postgis/**/*.tf"]
# Strictest requirements: this project can replace a database.
apply_requirements: [approved, mergeable, undiverged]
workflow: spatial
- name: tile-service
dir: infra/serving
workspace: prod
terraform_version: v1.9.5
autoplan:
when_modified: ["*.tf", "../modules/tileserv/**/*.tf"]
apply_requirements: [approved, mergeable]
workflow: spatial
workflows:
spatial:
plan:
steps:
- init
- plan
# The spatial gates run here so their output lands in the SAME comment
# as the plan. A gate whose result is in another system is one that
# reviewers do not read.
- run: terraform show -json $PLANFILE > plan.json
- run: conftest test plan.json --policy $HOME/policy --all-namespaces
- run: python3 $HOME/checks/spatial_plan_contract.py plan.json
- run: infracost breakdown --path plan.json --format table
apply:
steps:
# Applies the SAVED plan: what was reviewed is what runs.
- apply
# Per-project roles: a pull request touching the tile service cannot, even in
# principle, modify the database.
resource "aws_iam_role" "atlantis_tile_service" {
name = "atlantis-tile-service"
assume_role_policy = data.aws_iam_policy_document.atlantis_trust.json
permissions_boundary = aws_iam_policy.deploy_boundary.arn
}
resource "aws_iam_role" "atlantis_data_tier" {
name = "atlantis-data-tier"
assume_role_policy = data.aws_iam_policy_document.atlantis_trust.json
permissions_boundary = aws_iam_policy.deploy_boundary.arn
}
# The service is always running and can change production, so it is at least as
# sensitive as the pipeline identity it replaces.
resource "aws_ecs_service" "atlantis" {
name = "atlantis"
cluster = aws_ecs_cluster.tooling.id
task_definition = aws_ecs_task_definition.atlantis.arn
desired_count = 1 # a single instance: it holds locks, and two would race
network_configuration {
subnets = var.private_subnet_ids
assign_public_ip = false
security_groups = [aws_security_group.atlantis.id]
}
}
# Only the webhook path is reachable from outside; the UI sits behind auth.
resource "aws_lb_listener_rule" "atlantis_webhook" {
listener_arn = aws_lb_listener.tooling.arn
priority = 10
condition {
path_pattern { values = ["/events"] }
}
action {
type = "forward"
target_group_arn = aws_lb_target_group.atlantis.arn
}
}
Verification
Open a trivial pull request and confirm the plan comment appears, that it includes the policy and cost output, and that a lock is taken and named. Open a second pull request touching the same project and confirm it is told the project is locked rather than planning against contended state — that behaviour is much of why Atlantis was adopted.
Then verify the approval requirements bite. Attempt an apply comment without an approval and confirm it is refused. Attempt one from an account outside the permitted team on a restricted project and confirm the same. Finally verify the role separation by checking that the plan for the tile service project was produced under the tile service role — the credential in use should appear in the audit trail, and if both projects show the same identity the separation exists only in the configuration file.
Preventing recurrence
- Keep the project list explicit and reviewed. A new component added without a project entry is a component whose changes bypass the gates entirely.
- Alarm on locks older than a working day. A stale lock from an abandoned pull request blocks a project silently, and it is the most common day-to-day annoyance of this setup.
- Treat the Atlantis instance as production infrastructure. It is always running and can change production. Patch it, monitor it, and restrict it as you would the database it can modify.
- Keep the gates in the plan workflow, not beside it. The value comes from the reviewer reading the policy result in the same comment as the plan, and a gate reported elsewhere gets skimmed.
Frequently Asked Questions
Is Atlantis better than running Terraform in the CI system?
Different, not strictly better. A CI-based pipeline avoids a long-lived credential-holding service, which is a genuine security advantage, and is well suited to the OIDC pattern in GitHub Actions OIDC for Terraform Spatial Deploys. Atlantis gives visible locking, the plan-equals-apply guarantee and comment-driven review, which suit an estate with many projects and many contributors.
Can I run more than one Atlantis instance?
Not naively — it holds locks, and two instances will race unless they share a backing store configured for it. A single instance with a restart policy is the usual arrangement, and the resulting brief unavailability is acceptable because it delays deployments rather than affecting the platform.
How do I stop someone applying without review?
apply_requirements with approved and mergeable, plus a restriction on who may issue the command for sensitive projects. Both are needed: the first ensures a human looked, the second ensures they looked at the current state of the branch.
What happens to a lock when a pull request is abandoned?
It persists until someone unlocks it, which is why an alarm on lock age matters. The alternative — automatic expiry — would let a second pull request plan against state a first is still intending to change, which is the situation locking exists to prevent.
Related
- Pipeline Orchestration for Spatial Deploys — the parent topic on delivery flow
- GitHub Actions OIDC for Terraform Spatial Deploys — the credential-free alternative
- Blocking Public Raster Buckets with Conftest in CI — one of the gates that belongs in the plan workflow
- Managing Terraform State Locks for Spatial Data — the backend lock beneath the workspace lock