# GitOps: Terraform + Atlantis + GitHub Actions for DomainIndia Infrastructure
TL;DR
GitOps turns infrastructure changes into pull requests — every 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 apply` on 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 plan` on 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](https://domainindia.com/support/kb/terraform-ansible-infrastructure-as-code-vps))
- 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`:
```hcl
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):
```hcl
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
```bash
# 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`:
```ini
[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-1
```
Add nginx reverse proxy + SSL (see our [Running Go Applications](https://domainindia.com/support/kb/running-go-applications-vps-systemd-reverse-proxy) — same pattern).
Start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now atlantis
```
## Step 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:
```yaml
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"
- apply
```
## Step 5 — The workflow in action
1. You open a PR modifying `environments/staging/main.tf`
2. Atlantis posts a comment with `terraform plan` output
3. Team reviews the diff — "yes, adding this DNS record looks right"
4. Commenter types `atlantis apply` on the PR
5. Atlantis runs `terraform apply`, posts results
6. Merge the PR
For production, require approval:
```yaml
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`:
```yaml
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 fmt` check
- `tflint` static analysis
- `checkov` security scan
- Auto-update `README.md` with 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:
```hcl
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):**
```bash
sops --encrypt --pgp secrets.yaml > secrets.enc.yaml
# Commit secrets.enc.yaml, not secrets.yaml
```
Terraform reads via provider `carlpett/sops`.
## Common pitfalls
## FAQ
Q
Atlantis vs Terraform Cloud / Spacelift?
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.
Q
Do I need a separate VPS for Atlantis?
Not necessarily — can share with other internal tools. But Atlantis needs to be reachable by GitHub webhooks, which means public HTTPS.
Q
What's "drift" and how do I detect it?
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.
Q
Can I use Ansible inside this workflow?
Yes — Terraform creates the VPS, Atlantis applies it, then trigger Ansible from GitHub Actions on merge. See our Terraform + Ansible article.
Q
How do I rollback a bad apply?
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