Skip to content
Hogin Hogin
Go back

Database schema as code: Atlas on top of CloudNativePG instead of hand-rolled migrations

8 мин чтения

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

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.

Versioned migrations accumulate a history of commands and drift between environments; a declarative diff always compares against current state

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

CI gate: the schema change plan is visible in the PR and requires confirmation before apply, like terraform plan for DDL

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.

AtlasSchema CRD reads the desired schema from a ConfigMap; the Atlas Operator computes the diff and applies it to CloudNativePG via the -rw service

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

AspectVersioned migrations (Flyway, golang-migrate)Atlas (declarative)
Source of trutha chain of up/down filesdesired schema state in HCL/SQL
Drift between environmentsdiscovered after the factcomputed as a diff from actual state
Reviewing a changeraw SQL file in a PRa DDL plan, like terraform plan
Rollbacka separate down script, often untestedthe same diff mechanism against a past state
Protection from DROPdepends on author disciplineexplicit --allow-destructive/policy gate
In Kubernetesno native resourceAtlasSchema CRD with a reconcile loop

What you need to connect Atlas to an existing CloudNativePG cluster

  1. The connection-string secret already exists — CloudNativePG creates a <cluster>-app secret with the -rw service’s URI on its own, nothing extra to set up.
  2. Install the Atlas Operator into the cluster (the atlasgo/atlas-operator Helm chart) — it installs the AtlasSchema CRD and the controller that watches it.
  3. 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 hcl instead of writing it from scratch.
  4. Turn on policy.lint.destructive.error: true in the AtlasSchema from the start, not after the first incident where a column disappears.
  5. Add a gate to CI — run atlas schema apply -u "$DATABASE_URL" --to file://schema.hcl --auto-approve=false --dry-run in 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.


Share this post:

Next Post
Guardrails for AI SRE agents: automating remediation without losing control