Application manifests get reviewed line by line in a PR. A Terraform plan gets read before apply. But an ALTER TABLE in production usually lives in a file called V47__add_column.sql that nobody diffs against the database’s actual state — you just trust it applied the way it was meant to. Atlas closes that gap: it describes a schema declaratively and computes the diff itself, instead of you hand-maintaining a chain of numbered migrations.
Table of contents
Open Table of contents
- Why migrations are version control, not a diff
- What changes in practice
- The Atlas Kubernetes Operator on top of CloudNativePG
- Rollback and blue/green compatibility
- Comparison: versioned migrations vs. declarative diff
- What you need to connect Atlas to an existing CloudNativePG cluster
- How to verify it works
- Bottom line
Why migrations are version control, not a diff
A classic migration tool (Flyway, golang-migrate, Django’s ORM) works like this: you write an up script for the change and a down script to roll it back, number the file, and on deploy the engine applies every not-yet-applied number in order. That’s version control in the literal sense — a history of commands, not a state.
The problem doesn’t show up in a single environment — it shows up between them. Someone applies a migration by hand in staging to unblock testing and forgets to commit the file, and staging quietly drifts from what the same chain of numbers is supposed to produce in production. You can only diagnose that by comparing the actual schemas with pg_dump --schema-only, not by reading the migration history — history tells you what was supposed to happen, not what actually did.
A declarative approach removes this whole class of drift. You describe the desired state of the schema — tables, columns, indexes, foreign keys — in HCL or a plain SQL file. Atlas connects to the real database, builds a graph of its actual schema, and computes the minimal set of DDL operations needed to turn the current state into the desired one. It doesn’t matter what was done to the database by hand in the past — the plan is built from fact, not history.
What changes in practice
- A diff, not a migration.
atlas schema diffprints the concreteALTER TABLEstatements needed — liketerraform plan, but for DDL. atlas schema apply --auto-approve=falseas a CI gate. The command builds a plan, prints it in the pipeline output, and waits for confirmation before applying — a schema PR gets reviewed the same way a manifest PR does.- Destructive-change detection. If the diff requires dropping a column or a table, Atlas refuses by default and requires an explicit
--allow-destructive— accidentally losing data to a carelessDROP COLUMNin the desired schema gets harder, not easier. AtlasSchemaas a Kubernetes resource. Through the Atlas Kubernetes Operator, the schema becomes as declarative a CR as CloudNativePG’sClusteror Flux’sKustomization— a reconcile loop keeps the database matching the described state on its own.
The Atlas Kubernetes Operator on top of CloudNativePG
If you already have a CloudNativePG cluster running (covered in an earlier post), the Atlas Operator connects to it like any other client — through the Secret with the connection string CNPG already generates for the -rw service. The desired schema is then described with an AtlasSchema resource that points at an HCL file (or an inline schema) and at the secret with the cluster’s address:
apiVersion: db.atlasgo.io/v1alpha1
kind: AtlasSchema
metadata:
name: orders-schema
namespace: prod
spec:
urlFrom:
secretKeyRef:
key: uri
name: pg-app # Secret CloudNativePG creates for you
schema:
configMapKeyRef:
key: schema.hcl
name: orders-schema-hcl
policy:
lint:
destructive:
error: true # no drop of a column/table without an explicit allow
The operator reacts to a change in the schema ConfigMap the same way Flux reacts to a manifest change: it computes the diff, applies it to the database behind the -rw service, and updates the resource’s status — kubectl get atlasschema shows the current reconcile state right in the cluster, no trip to a separate migration system needed.
The schema for a single table in HCL is compact — it’s not a SQL migration, it’s a description of the end state:
table "orders" {
schema = schema.public
column "id" {
type = bigint
identity {}
}
column "customer_id" {
type = bigint
}
column "status" {
type = varchar(32)
default = "pending"
}
primary_key {
columns = [column.id]
}
index "orders_customer_id_idx" {
columns = [column.customer_id]
}
}
Add a column to this file and the diff shows ADD COLUMN. Remove one, and Atlas stops on the destructive check and demands explicit confirmation that dropping the column isn’t a typo.
Rollback and blue/green compatibility
With versioned migrations, rollback is a separate down script you write ahead of time and that often turns out to be untested by the moment you actually need it, under incident pressure. Atlas doesn’t have a rollback in that sense — there’s only applying the schema of a previous version through the same diff mechanism: you revert the HCL file to an earlier commit, Atlas computes the reverse diff, and applies it. Rollback isn’t a separate artifact — it’s the same tool, applied to a different desired state.
That same property makes Atlas convenient during a blue/green deploy of the application. The classic compatibility rule still holds: the schema needs to work with both the old and the new version of the code while traffic is switching over — a new column goes in nullable or with a default first, and NOT NULL/DROP waits for the next release, once the old app version is already gone. Atlas doesn’t remove that discipline, but the built-in destructive gate protects against the most common way to violate it — a forgotten DROP that would have slipped in with an unrelated change.
Comparison: versioned migrations vs. declarative diff
| Aspect | Versioned migrations (Flyway, golang-migrate) | Atlas (declarative) |
|---|---|---|
| Source of truth | a chain of up/down files | desired schema state in HCL/SQL |
| Drift between environments | discovered after the fact | computed as a diff from actual state |
| Reviewing a change | raw SQL file in a PR | a DDL plan, like terraform plan |
| Rollback | a separate down script, often untested | the same diff mechanism against a past state |
Protection from DROP | depends on author discipline | explicit --allow-destructive/policy gate |
| In Kubernetes | no native resource | AtlasSchema CRD with a reconcile loop |
What you need to connect Atlas to an existing CloudNativePG cluster
- The connection-string secret already exists — CloudNativePG creates a
<cluster>-appsecret with the-rwservice’s URI on its own, nothing extra to set up. - Install the Atlas Operator into the cluster (the
atlasgo/atlas-operatorHelm chart) — it installs theAtlasSchemaCRD and the controller that watches it. - Describe the schema in HCL for the tables you care about — start with one, exporting its current state with
atlas schema inspect -u "$DATABASE_URL" --format hclinstead of writing it from scratch. - Turn on
policy.lint.destructive.error: truein theAtlasSchemafrom the start, not after the first incident where a column disappears. - Add a gate to CI — run
atlas schema apply -u "$DATABASE_URL" --to file://schema.hcl --auto-approve=false --dry-runin the pipeline before merging a schema PR, so the plan is visible to a reviewer before it’s applied.
How to verify it works
Run a diff between the desired schema and the real database without applying anything — safe on any environment, production included:
atlas schema diff \
--from "postgres://user:[email protected]:5432/orders?sslmode=require" \
--to file://schema.hcl
An empty diff means the database already matches the described schema. Next, test the destructive gate on purpose: remove a column from the HCL, run atlas schema apply --auto-approve=false, and confirm the command stops with a warning instead of silently applying DROP COLUMN. In the cluster, kubectl get atlasschema -n prod should show Ready with a recent reconcile timestamp, not a stuck Reconciling.
Bottom line
Atlas doesn’t replace ordinary migrations entirely — complex data migrations (moving data between columns, backfills) still need a procedural script. But for schema structure it closes a specific, long-overdue gap: the last piece of infrastructure that at most teams still skips git-diff review entirely. A diff instead of a history of commands, a CI gate before merge, explicit protection from DROP, and AtlasSchema as a cluster resource — it’s the same principle GitOps already applied to manifests, just finally reaching the database.