Custom workflows & steps
Shape plan/apply commands.
A car factory has one assembly line, and every car rides it in the same order. Atlantis works the same way. Every Terraform change goes through the same stations: check out the code, run terraform init, run terraform plan, and once a human says go, run terraform apply. A custom workflow re-tools that line without ripping out the conveyor. You can bolt on an inspection station (scan the plan with Checkov, a tool that reads infrastructure code and flags insecure settings), swap one machine for another (Terragrunt in place of raw Terraform), or reject a part before it reaches packaging (fail the plan when a check fails). The pull request (PR) ritual stays identical. Comment in, comment out, lock held, apply gated. Only the stations change.
Three words to get straight first. A *step* is one command Atlantis runs: a built-in like init, plan, or apply, or an arbitrary run command of your own. A *stage* is an ordered list of steps attached to one Atlantis command. There is a plan stage and an apply stage, plus policy_check, import, and state_rm. A *workflow* is a named bundle of stages that a project points at, the way a continuous integration (CI) job points at a shared pipeline definition.
Anatomy of a workflow, and where it is allowed to live
The default workflow is tiny. The plan stage runs init then plan. The apply stage runs apply. Built-in steps accept extra_args, so small tweaks need no shell at all; adding -lock-timeout=30s is a two-line change. Anything past that means defining a workflow, and *where* you define it is a trust decision, not a matter of taste. Workflows in the server-side repo config (a YAML file you hand to Atlantis with atlantis server --repo-config) belong to the operators who run the server. Workflows in a repo's own atlantis.yaml belong to anybody who can open a pull request against that repo. So Atlantis rejects repo-defined workflows by default, and it says so out loud: the PR gets an error, not a quiet fallback. A repo may *select* a server-side workflow only if allowed_overrides includes workflow. It may pick only from the names in allowed_workflows, if you set that list. And it may *define* its own steps only if you flip allow_custom_workflows: true, a flag worth treating as radioactive.
# Operator-owned; lives OUTSIDE the Terraform repos, behind its own PR reviewrepos:- id: /github\.com\/acme\/.*/ # exact string or /regex/ — id has NO glob supportbranch: /^main$/workflow: scan-then-apply # default for every matching repoallowed_overrides: [workflow] # repos may SELECT a workflow...allowed_workflows: [scan-then-apply, terragrunt] # ...only from this listallow_custom_workflows: false # repos may NEVER define their own stepsapply_requirements: [approved, mergeable, undiverged]workflows:scan-then-apply:plan:steps: [init, plan] # expanded in the next section# Load it at startup — Atlantis validates the file and refuses to boot on errors:# atlantis server --repo-config=/etc/atlantis/repos.yaml ...
Custom steps: run, env, and the plan in JSON
The run step runs a shell command inside the project's checked-out directory, and Atlantis hands that command its context through environment variables. $PLANFILE is the absolute path to the binary plan file. $SHOWFILE is the same plan written as JSON (JavaScript Object Notation, a plain-text data format), and it exists only after a show step has run terraform show. You also get $DIR, $REPO_REL_DIR, $WORKSPACE, $PROJECT_NAME, $PULL_NUM, $PULL_AUTHOR, $HEAD_COMMIT, $USER_NAME, and $COMMENT_ARGS (any extra flags a user tacked onto their comment). Two more step types finish the kit. env sets one variable for every later step in the stage, either a static value or the stdout of a command. multienv runs a script whose output sets several variables at once. Exit codes are the contract. A run step that exits non-zero fails the whole stage, its output lands in the PR comment, the commit status goes red, and atlantis apply refuses to run because no valid plan was ever stored. That single rule is what turns a scanner from advisory noise into a hard gate.
run has a longer form too, with an output mode: show (the default), hide (print only on failure), or strip_refreshing (drop Terraform's *Refreshing state...* chatter). Use them. Everything a step prints gets pushed into the PR comment. One note on scope: Atlantis has a first-class policy_check stage wired to Conftest, and it gets its own lesson. A run-step scanner like the one below is the lightweight roll-your-own version of the same idea.
workflows:scan-then-apply:plan:steps:- env: # visible to every later step in this stagename: TF_VAR_deployed_bycommand: 'echo "pr-${PULL_NUM}-${PULL_AUTHOR}"'- init- plan- show # terraform show -json -> $SHOWFILE- run:command: checkov -f "$SHOWFILE" --framework terraform_plan --compact --quietoutput: hide # stay silent unless it failsapply:steps:- apply- run: curl -sf -X POST -d "{\"text\":\"applied ${PULL_URL} by ${USER_NAME}\"}" "$SLACK_WEBHOOK_URL"# On a clean plan, checkov exits 0 and (because output: hide) prints nothing.# On a violation it exits 1, failing the stage, and the PR shows:# Check: CKV_AWS_130: "Ensure VPC subnets do not assign public IP by default"# FAILED for resource: aws_subnet.public# Passed checks: 30, Failed checks: 1, Skipped checks: 0
What the pull request actually sees
None of this machinery shows up for developers. They comment atlantis plan (or autoplan fires on its own) and get back one comment per project. Behind that, Atlantis sets the atlantis/plan and atlantis/apply commit statuses, so a failed run step blocks the merge through branch protection as well as through Atlantis' own apply gate. Here is a realistic exchange, failure case included, because the failure is the part that makes the gate real.
## You comment:atlantis plan -d prod/network## Atlantis replies (commit status atlantis/plan: pending -> success):Ran Plan for dir: `prod/network` workspace: `default`# aws_flow_log.vpc will be created+ resource "aws_flow_log" "vpc" {+ traffic_type = "ALL"+ vpc_id = "vpc-0a1b2c3d"}Plan: 1 to add, 0 to change, 0 to destroy.* To apply this plan, comment: atlantis apply -d prod/network* To delete this plan and lock, comment: atlantis unlock## Same PR after a policy violation — no plan is stored, apply is impossible:Ran Plan for dir: `prod/network` workspace: `default`**Plan Error**Check: CKV_AWS_130: "Ensure VPC subnets do not assign public IP by default"FAILED for resource: aws_subnet.publicPassed checks: 30, Failed checks: 1, Skipped checks: 0running "checkov -f $SHOWFILE ...": exit status 1
Wrappers, custom images, and Kubernetes
Because run hands you a plain shell, wrapping Terraform in some other tool is a workflow change, never a fork of Atlantis. Terragrunt is the classic case. Replace the built-in steps outright, but *keep the $PLANFILE contract*: write the plan exactly where Atlantis expects to find it, and locking, the plan comment, and apply all keep working untouched.
workflows:terragrunt:plan:steps:- env:name: TF_IN_AUTOMATIONvalue: "true"- run:command: terragrunt plan -input=false -out=$PLANFILEoutput: strip_refreshing # drop "Refreshing state..." noiseapply:steps:- run: terragrunt apply -input=false $PLANFILE
Here is the catch. The official image ships Terraform, OpenTofu, and Conftest (the first-class policy_check stage needs Conftest), but not Checkov and not Terragrunt. Every binary your steps call has to exist inside the container, which in production means building your own image and shipping it with the official Helm chart. That chart runs Atlantis as a *StatefulSet*, a Kubernetes workload whose pod keeps the same name and the same disk across restarts, with a persistent volume attached. Plans and locks live in that data directory, so a pod restart does not throw them away. The chart also takes your whole server-side config inline, as the repoConfig value.
# Extend the official image with the binaries your run steps needFROM ghcr.io/runatlantis/atlantis:latest # pin a version tag + digest in prodUSER rootRUN apk add --no-cache python3 py3-pip && \pip3 install --break-system-packages --no-cache-dir checkovUSER atlantis
# values.yaml (excerpt)image:repository: ghcr.io/acme/atlantis-customtag: "2026.07"orgAllowlist: github.com/acme/* # the allowlist DOES take globs, unlike repos.idvcsSecretName: atlantis-vcs # k8s Secret: github_token + github_secretrepoConfig: | # your entire repos.yaml, inlinerepos:- id: /github\.com\/acme\/.*/workflow: scan-then-applyallow_custom_workflows: falseworkflows:scan-then-apply:# ...as defined above# Install / upgrade:$ helm repo add runatlantis https://runatlantis.github.io/helm-charts$ helm upgrade --install atlantis runatlantis/atlantis \-n atlantis --create-namespace -f values.yamlRelease "atlantis" has been upgraded. Happy Helming!$ kubectl -n atlantis get statefulset,pvcNAME READY AGEstatefulset.apps/atlantis 1/1 2m4sNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGEpersistentvolumeclaim/atlantis-data-atlantis-0 Bound pvc-8c1d2e3f 5Gi RWO gp3 2m4s
Failure modes, scale, and the trust boundary
Troubleshooting custom workflows comes down to three errors most days. *"repo config not allowed to set 'workflow' key"* means a repo tried to select a workflow and allowed_overrides does not permit it. *"workflow 'x' is not defined"* means the name in atlantis.yaml matches nothing on the server side, and server config always wins. exit status 127 in a PR comment means the binary your step called is missing from the image. Then there is scale. Every step runs on every plan in every pull request. A 40-second scanner across a 30-project monorepo turns one autoplan into a 20-minute event. Everything your steps print gets posted to the PR, and GitHub caps a comment at 65k characters (Atlantis splits the overflow into *continued* comments). So keep steps fast, use output: hide, push slow analysis into CI, and version the repo config in an operator-owned repository with mandatory review. That file decides what executes on the box holding your cloud credentials.
run step executes as the Atlantis server's operating system user, with the server's cloud credentials and version control token sitting right there in scope. If untrusted repos are allowed to define workflows (allow_custom_workflows: true behind allowed_overrides: [workflow]), any pull request author gets arbitrary code execution with those credentials. No merge, no approval, one atlantis plan comment on their own PR. Keep workflow *definitions* server-side, and let repos at most *select* from allowed_workflows. Then mind the quieter version of the same problem: even a trusted, operator-written run step executes against checkout contents an attacker controls, so treat the working directory as hostile input. Never source repo files, and never execute scripts shipped inside the PR.Custom workflows decide *what* Atlantis runs for each project. The other half of the ergonomics story is *when* it runs: which changed files, in which directories, make Atlantis plan at all. That is autoplan and change detection, and it is up next.
A workflow that curls arbitrary scripts off the internet during plan is a supply-chain hole wearing cloud admin rights. Vendor your tools into the image you build; do not fetch them at plan time. Read every run: line as if it were a CI job on a privileged runner, because that is exactly what it is.
Terragrunt users usually need their own init, plan, and apply wrappers. Write them once in a named workflow and point projects at it. Copy-pasting the same shell into twelve project entries guarantees drift.
Pin a version for every binary your custom workflow shells out to inside the Atlantis image. Floating latest tags turn Monday morning into a scanner surprise party. Publish the image digest in the chart values, and require a pull request to bump a tool the same way you require one to bump a Terraform provider.
When a run step wants cloud access beyond what Terraform already holds, stop and redesign. Extra credentials inside a workflow are how a plan-time script quietly becomes a second control plane. Read policy inputs from the plan file already sitting on disk, rather than from live APIs reached with a broader role.
Try this
Define a custom workflow in server-side config that runs init → plan plus a Checkov (or conftest) step, then trigger a plan on a pull request you know should fail the scanner.
# repos.yaml fragment# workflows:# checkov:# plan:# steps:# - init# - plan# - run: checkov -f $PLANFILE --quietatlantis plan -d infra/app
Running step: run checkov...Check: CKV_AWS_18 FAILED for aws_s3_bucket.logs# plan comment shows failure — apply stays blocked if you require success
Takeaway
Custom workflows re-tool the conveyor and leave the ritual alone: same comments, same locks, different stations. Every run step you add inherits Atlantis's cloud credentials.
Next: keep dangerous run steps in server-side config, pin tool versions inside the Atlantis image, and fail closed when a scanner itself errors.
run step inside the scan-then-apply workflow finds a violation and exits non-zero. What actually makes atlantis apply impossible afterwards, turning the scanner into a hard gate?atlantis apply is already impossible on its own, since no plan exists.