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:

  1. Plan — run tofu plan (or terraform plan) for changed stacks, post the plan output as a PR comment, set a commit status (neptune plan)
  2. Apply — when @neptbot apply is commented on a PR, run tofu apply for the same stacks, post the apply output as a PR comment, set a commit status (neptune apply)
  3. 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:

  1. neptbot sends repository_dispatch with command: plan
  2. Neptune runs tofu plan for all changed stacks
  3. Neptune posts a comment with the plan output, diff summary, and stack lock status
  4. neptune plan commit status turns green

When a reviewer comments @neptbot apply:

  1. neptbot sends repository_dispatch with command: apply
  2. Neptune checks that the PR has the required approvals
  3. Neptune runs tofu apply for the changed stacks
  4. Neptune posts the apply output as a PR comment
  5. neptune apply commit status turns green
  6. 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.