Automating Infrastructure PRs with Neptune
Neptune adds plan-on-open and apply-on-comment to your GitHub Actions Terraform/OpenTofu workflow. Here's how it works, what it enforces, and how to set it up from scratch in under an hour.
Infrastructure as code is only as good as the process around it. You can write clean Terraform all day, but if apply runs from an engineer’s laptop after a quick Slack message, you’re accumulating invisible risk: unseen plans, no audit trail, no rollback path.
Neptune solves this with a simple model: plan runs on PR open, apply runs on @neptbot apply comment, merge happens after apply. Every infrastructure change goes through GitHub review — plan output is in the PR, apply output is in the PR, and the commit history tells you exactly who approved what.
This post walks through how Neptune works and how to set it up.
The problem Neptune solves
The standard GitHub Actions + Terraform workflow has a gap. You can run terraform plan in CI on PR open (most teams do). But apply typically happens:
- After merge, triggered by push to main — which means you merge before you know the apply succeeds
- Manually, by an engineer with credentials — which means no audit trail and no PR record
- Via a scheduled job — which means delay and lost context
Apply-before-merge inverts this. The PR is the apply record. You review the plan, run apply in CI, and merge only after apply succeeds. If apply fails, the branch stays open.
Neptune is an opinionated implementation of this pattern.
How Neptune works
Neptune is a Go CLI that runs in GitHub Actions. It does three things:
- Plan — run
tofu plan(orterraform plan) for changed stacks, post the plan output as a PR comment, set a commit status (neptune plan) - Apply — when
@neptbot applyis commented on a PR, runtofu applyfor the same stacks, post the apply output as a PR comment, set a commit status (neptune apply) - Lock — use object storage (GCS or S3) for stack-level locks to prevent concurrent runs on the same stack
The GitHub App (neptbot) receives PR events and @neptbot comments via webhooks, then triggers Neptune in your repository via repository_dispatch.
Setup walkthrough
Prerequisites
- A GitHub repository with Terraform or OpenTofu stacks
- Object storage bucket (GCS or S3) for Neptune lock files and state (if using remote state)
- Install the neptbot GitHub App on your repository
1. Add the Neptune workflow
Create .github/workflows/neptune.yml:
name: Neptune
on:
repository_dispatch:
types: [neptune]
jobs:
neptune:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
statuses: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.client_payload.ref }}
fetch-depth: 0
- uses: opentofu/setup-opentofu@v1
- name: Neptune
run: neptune run
env:
NEPTUNE_COMMAND: ${{ github.event.client_payload.command }}
NEPTUNE_PR_NUMBER: ${{ github.event.client_payload.pr_number }}
NEPTUNE_REF: ${{ github.event.client_payload.ref }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Object storage (GCS example)
NEPTUNE_GCS_BUCKET: ${{ secrets.NEPTUNE_GCS_BUCKET }}
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }}
2. Configure Neptune
Create .neptune.yaml at the repo root:
# .neptune.yaml
plan:
require_approval: false # plan runs automatically on PR open
apply:
require_approval: true # apply only runs when @neptbot apply is commented
approved_by:
- any_reviewer # at least one PR reviewer must have approved
3. Install Neptune in the workflow
Add a step to install the Neptune binary:
- name: Install Neptune
run: |
curl -sSL https://github.com/devopsfactory-io/neptune/releases/latest/download/neptune_linux_amd64.tar.gz | tar -xz
sudo mv neptune /usr/local/bin/
Or use your package manager of choice.
4. Enable required status checks
In repository Settings → Branches → branch protection:
- Add neptune apply as a required status check
- This prevents merge until apply has run and passed
5. Set @neptbot apply as a protected trigger
The neptbot GitHub App only fires repository_dispatch when the comment author has write access to the repository. This means only engineers who can merge can trigger apply — no need for additional CODEOWNERS configuration.
What the PR experience looks like
When a PR is opened:
- neptbot sends
repository_dispatchwithcommand: plan - Neptune runs
tofu planfor all changed stacks - Neptune posts a comment with the plan output, diff summary, and stack lock status
neptune plancommit status turns green
When a reviewer comments @neptbot apply:
- neptbot sends
repository_dispatchwithcommand: apply - Neptune checks that the PR has the required approvals
- Neptune runs
tofu applyfor the changed stacks - Neptune posts the apply output as a PR comment
neptune applycommit status turns green- The PR is now mergeable
Stack discovery
Neptune discovers stacks in two modes:
- Terramate — uses the Terramate Go SDK to find changed stacks based on git diff. Respects Terramate’s dependency graph for run order.
- Local stacks — directory-based discovery from a configured root, with explicit inclusion/exclusion patterns
Both modes support parallelism with configurable concurrency limits and stack-level locks via object storage.
Object storage for locks
Neptune uses a GCS or S3 bucket to store stack locks. When Neptune starts a plan or apply for a stack, it acquires a lock; when it finishes, it releases it. If another Neptune run tries to acquire the same lock, it waits or fails fast depending on configuration.
This prevents the classic concurrent-apply race: two engineers both comment @neptbot apply on different PRs touching the same stack.
The full documentation
The Neptune docs site is at devopsfactory-io.github.io/neptune. The getting-started guides cover Terramate and local stack configurations with copy-paste workflow examples.
Neptune is open source under MIT. Issues and contributions welcome at github.com/devopsfactory-io/neptune.