GitOps: Terraform + Atlantis + GitHub Actions for DomainIndia Infrastructure
terraform apply is code-reviewed before it runs. This guide shows the full loop: PR opens, Atlantis plans, team reviews, merge triggers apply. Reproducible, auditable, team-friendly IaC.What GitOps buys you
Without GitOps:
- One engineer runs
terraform applyon their laptop - State file lives somewhere unclear
- No audit trail of who changed what
- PRs show code diff, not the effective infrastructure change
- Drift from what's in git vs what's running is invisible
With GitOps:
- Every infra change is a PR
- Atlantis runs
terraform planon the PR, posts diff as comment - Team reviews: "does this diff match what we want?"
- Merge → automated
terraform apply - Git is the single source of truth
The stack
| Tool | Role |
|---|---|
| Terraform / OpenTofu | Infrastructure definition (HCL files) |
| GitHub (or GitLab) | Storage + PR review |
| Atlantis | The GitOps bot — runs plan/apply on comments |
| AWS S3 or GCS | Remote state storage + DynamoDB lock |
| GitHub Actions | CI/CD for build steps + linting |
Prerequisites
- Working Terraform project (see our Terraform & Ansible article)
- DomainIndia VPS for Atlantis (2 GB RAM sufficient)
- GitHub repo with infra code
- S3 bucket for remote state
Step 1 — Remote state in S3
backend.tf:
terraform {
backend "s3" {
bucket = "yourcompany-terraform-state"
key = "production/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "yourcompany-terraform-lock"
encrypt = true
}
}Create the bucket + DynamoDB lock table (one-time, via AWS console or a separate Terraform project):
resource "aws_s3_bucket" "tfstate" {
bucket = "yourcompany-terraform-state"
}
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration { status = "Enabled" }
}
resource "aws_dynamodb_table" "tflock" {
name = "yourcompany-terraform-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute { name = "LockID"; type = "S" }
}Initialise: terraform init -reconfigure
Step 2 — Install Atlantis on DomainIndia VPS
# Download binary
wget https://github.com/runatlantis/atlantis/releases/download/v0.27.0/atlantis_linux_amd64.zip
unzip atlantis_linux_amd64.zip
sudo mv atlantis /usr/local/bin/
# Atlantis needs terraform available
wget https://releases.hashicorp.com/terraform/1.8.0/terraform_1.8.0_linux_amd64.zip
unzip terraform_1.8.0_linux_amd64.zip
sudo mv terraform /usr/local/bin/
# Create atlantis user
sudo useradd -r -m -d /opt/atlantis atlantis
sudo mkdir -p /opt/atlantis/data
sudo chown -R atlantis:atlantis /opt/atlantis/etc/systemd/system/atlantis.service:
[Unit]
Description=Atlantis GitOps Server
After=network.target
[Service]
Type=simple
User=atlantis
Group=atlantis
WorkingDirectory=/opt/atlantis
ExecStart=/usr/local/bin/atlantis server
--atlantis-url=https://atlantis.yourcompany.com
--gh-user=atlantis-bot
--gh-token=$GH_TOKEN
--gh-webhook-secret=$WEBHOOK_SECRET
--repo-allowlist=github.com/yourcompany/infra
--data-dir=/opt/atlantis/data
--port=4141
EnvironmentFile=/opt/atlantis/.env
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target/opt/atlantis/.env:
GH_TOKEN=ghp_xxx # Personal Access Token for atlantis-bot
WEBHOOK_SECRET=random-long-string
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=ap-south-1Add nginx reverse proxy + SSL (see our Running Go Applications — same pattern).
Start:
sudo systemctl daemon-reload
sudo systemctl enable --now atlantisStep 3 — GitHub webhook
In your infra repo on GitHub: Settings → Webhooks → Add webhook.
- Payload URL:
https://atlantis.yourcompany.com/events - Content type:
application/json - Secret: same as
WEBHOOK_SECRET - Events: Pull requests, Pull request review comments, Issue comments, Pushes
Step 4 — atlantis.yaml in your repo
At the root of your infra repo:
version: 3
projects:
- name: production
dir: environments/production
workflow: production
autoplan:
when_modified: ["*.tf", "../../modules/**/*.tf"]
enabled: true
- name: staging
dir: environments/staging
workflow: default
autoplan:
when_modified: ["*.tf"]
enabled: true
workflows:
default:
plan:
steps:
- init
- plan
apply:
steps:
- apply
production:
plan:
steps:
- init
- plan
apply:
steps:
- run: echo "Production apply — require approval"
- applyStep 5 — The workflow in action
- You open a PR modifying
environments/staging/main.tf - Atlantis posts a comment with
terraform planoutput - Team reviews the diff — "yes, adding this DNS record looks right"
- Commenter types
atlantis applyon the PR - Atlantis runs
terraform apply, posts results - Merge the PR
For production, require approval:
projects:
- name: production
apply_requirements: [approved, mergeable]Now production apply requires an approving reviewer first.
Step 6 — GitHub Actions for extras
Atlantis handles Terraform. Use GitHub Actions for linting, security scanning, docs:
.github/workflows/ci.yml:
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check -recursive
- name: tflint
uses: terraform-linters/setup-tflint@v4
- run: tflint --init && tflint
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkov scan
uses: bridgecrewio/checkov-action@master
with:
directory: .
framework: terraform
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: terraform-docs/gh-actions@v1
with:
working-dir: .
output-file: README.md
output-method: inject
git-push: "true"This runs on every PR:
terraform fmtchecktflintstatic analysischeckovsecurity scan- Auto-update
README.mdwith module docs
Secrets handling in GitOps
Never commit secrets. Two patterns:
1. External secret store (recommended):
Use AWS Secrets Manager or HashiCorp Vault. Your Terraform reads:
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/database/password"
}
resource "aws_rds_instance" "main" {
password = data.aws_secretsmanager_secret_version.db.secret_string
}2. SOPS (encrypted files in git):
sops --encrypt --pgp <YOUR_KEY_FINGERPRINT> secrets.yaml > secrets.enc.yaml
# Commit secrets.enc.yaml, not secrets.yamlTerraform reads via provider carlpett/sops.
Common pitfalls
FAQ
Atlantis: self-hosted, free, you run it. Terraform Cloud: managed, free tier for <5 users, paid above. Spacelift: powerful, paid. Atlantis wins if you have DIY ops capacity.
Not necessarily — can share with other internal tools. But Atlantis needs to be reachable by GitHub webhooks, which means public HTTPS.
Drift is when manual changes (someone SSH'd and tweaked a config) diverge from Terraform state. Detect via scheduled terraform plan — any non-empty diff = drift. GitHub Actions weekly cron works well.
Yes — Terraform creates the VPS, Atlantis applies it, then trigger Ansible from GitHub Actions on merge. See our Terraform + Ansible article.
Revert the PR in git + re-apply. Terraform is declarative — reverting the code reverts the infra. Keep an eye on destructive changes (RDS deletion) — review diffs carefully before merging.
Self-host Atlantis on a DomainIndia VPS — full control, zero vendor lock-in. Get a VPS