Remote state

State defaults to a local SQLite file, which only one machine can use. Point [state] at a shared backend and the same commands work for a whole team or a CI runner.

[state]
backend    = "s3"                      # "local" (default) | "s3" | "postgres"
bucket     = "acme-atlantide-state"
key        = "prod/atlantide.json"
lock_table = "atlantide-locks"         # DynamoDB table, hash key `node_id` (S)
region     = "eu-north-1"
kms_key_id = "alias/atlantide"         # optional; SSE-S3 (AES256) otherwise

lock_ttl            = 300              # seconds a lease lasts before it lapses
lock_renew_interval = 100              # how often a live run pushes that out
[state]
backend = "postgres"                   # needs the extra: pip install atlantide[postgres]
dsn     = "postgresql://…"             # or the ATLANTIDE_STATE_DSN env var
schema  = "atlantide"

Concurrency

Locks are held per node id rather than over the whole store, but an apply scope is wider than the changeset it starts from. Apply re-diffs once it holds the lease, and an action can appear or disappear between the two diffs, so the lease must cover the whole desired graph plus everything already recorded in state.

The practical consequence: two applies run concurrently only if both their configs and their prior state are disjoint. Two applies over the same stack serialize. Size CI concurrency accordingly.

A run that loses its lease — because the hold lapsed and another run claimed it — stops and reports that it has done so. It does not roll back, because a compensation is itself a state write and the lease no longer authorises one. Run atlantide refresh to recover.

Preparing the backend

The bucket and lock table are not created for you. They are the trust root for shared state, so they are expected to exist and to be configured deliberately:

  • Bucket versioning, so a bad state write can be recovered.
  • A DynamoDB TTL on expires_at, so abandoned leases evict themselves.

state check verifies all of this up front, rather than surfacing one failed API call at a time. It also probes whether the endpoint genuinely honours conditional writes — an S3-compatible store may accept the request and ignore the condition, which would silently defeat every compare-and-swap the backend relies on:

atlantide state check              # --no-probe skips the scratch write

Managing state

atlantide state list                     # every recorded resource
atlantide state show <id>                # one resource's stored inputs and outputs
atlantide state rm <id>                  # forget a row without destroying anything

atlantide state backup                   # ./atlantide-state-<serial>-<ts>.atlas-state
atlantide state restore snap.atlas-state
atlantide state migrate --from atlantide.db      # local -> remote
atlantide state migrate --to-local atlantide.db  # remote -> local

atlantide state unlock                           # who holds what
atlantide state unlock --owner ci-runner-7 -y    # break a dead run's holds

backup and restore run under the state lock, so a snapshot cannot capture half of a concurrent apply. restore refuses a snapshot if state has been written since it was taken, unless given --force, and previews which nodes it will stop tracking.

Restoring rewrites atlantide's record and touches no cloud resource. An old snapshot therefore creates drift rather than undoing it.

state rm is the repair for a row that has stopped describing anything real. It does not call the provider. A resource that does still exist becomes untracked, and the next apply will build a second one. The command snapshots state first and says so.

state migrate copies in a single write in either direction, so an interrupted migration cannot leave a half-populated destination.

Upgrading a shared backend

The schema is versioned and migrates forward automatically. Take a snapshot first, and upgrade every machine sharing the backend together — an older atlantide refuses a newer store rather than misreading it.

Secrets

Secret values can come from a remote store. The reference held in config, in the IR and in state is a name either way:

[secrets]
provider = "ssm"                       # "keyfile" (default) | "env" | "ssm"
prefix   = "/atlantide/prod/"          # secret `db_password` -> /atlantide/prod/db_password
region   = "eu-north-1"

Point secrets_key at a shared path when state is shared

The keyfile does more than encrypt the local value store. It also salts the rotation digests and seals sensitive outputs at rest.

A teammate without that keyfile cannot unseal those outputs, and will see every secret field as rotated. plan recognises the pattern — every secret in state mismatching, none intact — and warns rather than rendering a page of spurious updates. A KMS-backed key would be the better answer and is not implemented yet.