Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Remote state (the S3 backend)

By default Nivis keeps state in a local JSON file (--state, default ./nivis.state.json). For a team or CI, declare a remote backend so state lives in a shared store instead. The first backend is S3.

State stays Nivis's own format. There is no tfstate compatibility (a deliberate design choice): Nivis state is not a Terraform/OpenTofu state file and the two are not interchangeable.

Configure it in the flake

The backend is part of your configuration, not a flag or an env var. Declare it with backend on your top-level config (it flows through the IR to the executor):

{ nivis }:
ledger:
nivis.toIR {
  backend = {
    type = "s3";
    bucket = "my-company-nivis-state";
    key = "prod/app.json";          # the object key for THIS stack's state
    region = "eu-west-1";
  };
  providers = { /* ... */ };
  resources = [ /* ... */ ];
  inherit ledger;
}

Then nivis plan / nivis apply read and write state in s3://my-company-nivis-state/prod/app.json instead of a local file. The whole state document is stored as that one object.

Keys

  • type (required): "s3".
  • bucket, key, region (required for s3): where the state object lives.
  • endpoint (optional): override the S3 endpoint (for an S3-compatible store or a test server). Unset in production, where the AWS SDK resolves the real endpoint.
  • sseAlgorithm, kmsKeyId (optional): how the objects are server-side encrypted. See Encryption.

This list is the complete set of keys the s3 backend accepts. Any other key is an error rather than being ignored, so a misspelling is reported instead of silently taking effect as its default. A local backend accepts only type.

The backend is static: its values must be plain (no references to resource outputs), because the executor has to know where state lives before it evaluates anything.

The local files a run writes

Even with a remote backend, a run writes the outputs ledger into your working directory, next to where --state points (<state path>.ledger). With the local store it writes the state document there too.

Both can hold secrets from provider outputs, and the ledger is where Nivis keeps sensitive ones in plaintext by design, to keep them out of the world-readable Nix store (see docs/IR-CONTRACT.md, "Sensitive values across the boundary"). They are written 0600; that stops another user on the machine, not git add. Ignore them:

nivis.state.json*
*.ledger

Nivis names these paths once, the first time a run creates them. A file already committed needs removing from the repository and its secrets rotating; ignoring it from now on leaves it in your history.

Credentials

Secrets are never in the config. The S3 backend uses the AWS default credential chain (the same chain the AWS CLI/SDK use): environment variables (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_PROFILE), the shared credentials/config files, or an instance/role profile. Set AWS_PROFILE (or the keys) in your shell or CI.

The config may name a role to assume (see below). That is an identity, not a credential: the credentials that authorize assuming it still come from the chain.

Assuming a role for state access

In a landing zone an operator usually authenticates once, as a management-account user, and reaches each workload account by assuming a role there. Declare that in the backend and the mapping lives in the repository instead of in every operator's ~/.aws/config:

backend = {
  type = "s3";
  bucket = "terraform-state-104144963194-production";
  key = "nivis/app.json";
  region = "eu-central-1";

  assumeRole = {
    roleArn = "arn:aws:iam::104144963194:role/landing_zone_devops_user";
    sessionName = "nivis";        # optional, defaults to "nivis"
    externalId = "...";           # optional, when the trust policy requires one
  };
};
  • roleArn is required whenever the block is present. A block without it would assume nothing while reading as configured, so it is refused.
  • sessionName is what appears in CloudTrail against every state write. The default names the tool; set it to something identifying the operator or the CI job when state changes need to be attributable to a person.
  • externalId is sent only when set. Third-party and cross-account trust policies commonly require it.

The credentials from the default chain are the source identity for the assumption, so an operator still needs their base profile; they no longer need a per-account assume-role profile. The assumed credentials are cached and refreshed, so a long apply does not make an STS call per request and does not fail partway through when the first session ages out.

Assuming a role for state needs sts:AssumeRole on the role, and the role itself needs access to the bucket (and to its KMS key, if the bucket is CMK-encrypted).

This is not the provider's assume_role

They look alike and are different mechanisms:

who calls STSwhere it is declaredspelling
providerthe AWS provider binaryproviders.aws.configassume_role, role_arn
backendnivis itselfbackendassumeRole, roleArn

Provider config follows the provider's schema, which is why it is snake_case and passed through untouched. The backend block is nivis's own schema. A config that switches accounts for both will carry both, and they are set independently.

Why there is no profile key

profile is refused deliberately. A role ARN means the same thing from any machine and any CI runner; a profile name points into one operator's ~/.aws/config, resolves differently elsewhere, and is usually absent in CI entirely. The backend block is committed configuration, so it carries the globally meaningful identifier and leaves the machine-local one to the environment. Use AWS_PROFILE for the base identity.

Encryption

Both objects the backend writes (the state object and the lock object) are server-side encrypted. How is set by sseAlgorithm:

sseAlgorithmwhat is sentthe object is encrypted with
absent / AES256x-amz-server-side-encryption: AES256SSE-S3, an S3-managed key
"bucket-default"nothingwhatever the bucket's default encryption rule says
"aws:kms"aws:kms plus kmsKeyIdSSE-KMS with that key

kmsKeyId is required when sseAlgorithm is "aws:kms", and refused otherwise.

None of these modes writes state unencrypted. Every S3 bucket has had default encryption applied since January 2023, so "bucket-default" still produces an encrypted object: SSE-S3 at minimum, and the bucket's KMS key when one is configured.

Sharing a bucket that enforces SSE-KMS

Hardened state buckets commonly carry a policy that denies any write whose encryption header is present and is not the bucket's own default, for example:

{ "Effect": "Deny", "Principal": "*", "Action": "s3:*",
  "Condition": {
    "Null": { "s3:x-amz-server-side-encryption": "false" },
    "StringNotEquals": { "s3:x-amz-server-side-encryption": "aws:kms" } } }

"Null": "false" means "the header is present", so the statement fires only on a header that is there and wrong. Against such a bucket the default AES256 is denied and "bucket-default" is the mode to use:

backend = {
  type = "s3";
  bucket = "terraform-state-123456789012-production";
  key = "nivis/app.json";
  region = "eu-central-1";
  sseAlgorithm = "bucket-default";
};

Sending no header satisfies the policy and lets the bucket encrypt the object with its own CMK, which is also what Terraform does against these buckets when its backend sets no encrypt.

Use "aws:kms" instead when the bucket policy requires the key to be stated explicitly (the variant that denies a write whose key-id header is missing). Then kmsKeyId must be the exact key ARN the policy compares against: such policies test it as a string, so an alias or a bare key id names the same key and is still denied.

The symptom

A bucket that refuses your encryption mode fails the lock object first, since that is the first write of a plan or apply, and S3 answers an explicit policy Deny with a bare AccessDenied that never mentions encryption:

error: state: s3: acquire lock my-bucket/nivis/app.json.lock: ... AccessDenied
  This may be a server-side encryption mismatch rather than a credentials problem:
  nivis requested "AES256". A bucket whose policy enforces its own default
  encryption (commonly SSE-KMS with a fixed key) denies that write. If the bucket
  already defaults to the right key, set backend.sseAlgorithm = "bucket-default".

KMS permissions

Reading and writing a KMS-encrypted object needs permission on the key, not only on the bucket: kms:GenerateDataKey to write and kms:Decrypt to read. A run that can list the bucket but lacks kms:Decrypt fails on the read with another bare AccessDenied.

Enable bucket policies/versioning on your side as you would for any state bucket.

Locking

nivis apply and nivis destroy take an advisory lock on the state before they run, so two people (or two CI jobs) cannot mutate the same stack at the same time and corrupt it. The lock is a small sibling object next to your state object (<key>.lock), created atomically with an S3 conditional write (no DynamoDB or other service is needed). It is released automatically when the run finishes, including when it fails.

Read-only commands (plan, refresh, output, state pull) do not lock.

If a run is already holding the lock, the next apply/destroy stops before doing anything and tells you who holds it and since when:

error: state is locked by alice@ci-runner since 2026-06-22T10:31:04Z for "apply"; run `nivis force-unlock` to override

force-unlock

If a run crashes (or is killed) while holding the lock, the lock object is left behind and the next run is blocked. Clear it with:

nivis force-unlock

It confirms first in an interactive shell; pass --force (or --yes) to skip the prompt in CI. Only force-unlock when you are sure no other run is active, or you risk two concurrent applies. The local file store does not use this lock (it is single-machine and has its own per-operation file lock), so force-unlock there reports there is nothing to clear.

Moving state between backends

nivis state migrate moves the whole state document between your local state file and the backend your configuration declares, in the direction you choose:

nivis state migrate --to-remote     # local state file  ->  the declared backend
nivis state migrate --from-remote   # the declared backend  ->  local state file

It reports both sides before it does anything, and on success it says how many resources moved:

Migrating the state document
  from the local state file ./nivis.state.json
  to   s3://my-company-nivis-state/prod/app.json
Moved 7 resource(s) to s3://my-company-nivis-state/prod/app.json; removed the source document at ./nivis.state.json.

You must pass exactly one direction — Nivis will not guess which way your state of record should move. If your configuration declares no backend, there is nothing to migrate to or from and the command says so.

What it does, in order

  1. Takes the state lock on both sides (for backends that support locking), so a migration cannot race an apply.
  2. Copies the document to the destination.
  3. Verifies the destination by reading it back.
  4. Only then removes the source document — the local state file (along with its derived .ledger and .lock siblings), or the remote state object.

If anything fails before step 4, the source document is untouched and the error says so, so nothing is lost and you can re-run the command. If a run is interrupted between steps 3 and 4, the destination already holds the document: re-running finishes the job (it reports "an interrupted migration") instead of complaining that the destination is occupied.

Because the source is removed, you never end up with a stale local state file sitting next to a live remote backend — the single most confusing state a project can be in. If you want a copy first, take one with nivis state pull --out state.json (below).

When it refuses

A migration will not silently destroy state you did not mean to overwrite:

DestinationResult
No state document, or an empty oneproceeds
Exactly the document being migratedproceeds (finishes the move)
Different resourcesrefused — needs --force
Content that is not a Nivis state documentrefused — needs --force

A refusal reports the resource count on both sides and changes nothing:

error: the destination already holds 3 resource(s) and the source holds 7;
refusing to overwrite state that is not a copy of the source (pass --force to overwrite it anyway)

Pass --force (or --yes) only when you are sure the destination's state is disposable. Enable bucket versioning on your state bucket: it is the backstop both for a forced overwrite and for the source-removal step.

The lower-level pair: pull and push

nivis state pull / state push still move the document as bytes, and remain the tool for anything migrate does not cover — taking a backup, editing a document, or seeding a backend from a file:

nivis state pull --out state.json          # export through the selected backend
nivis state push --in state.json --force   # replace through the selected backend

Both operate on whichever backend the configuration currently selects, so migrate is the one-step version of the old export/edit-config/import dance.

Bootstrapping a self-managed state bucket

A common setup has a configuration create the very bucket it declares as its own backend. That is a chicken-and-egg problem: on the first run the bucket does not exist, so state cannot live there yet. Bootstrap it in three commands:

# 1. Apply with local state, creating the bucket (the declared backend is ignored
#    for this run only; the configuration is not modified).
nivis apply --backend=local

# 2. Move the state document into the bucket that now exists.
nivis state migrate --to-remote

# 3. From here on, ordinary runs use the declared backend.
nivis apply       # reports no changes

--backend=local is the escape hatch: it makes a single run use the local state file at --state even though the configuration declares a remote backend. When it overrides a declared backend, the run says so, so it is never ambiguous which state a run operated on:

Using local state at ./nivis.state.json (--backend=local overrides the s3 backend this configuration declares).

It applies to every state-using command (plan, apply, destroy, refresh, and the state subcommands) and affects only the run it is given on. local is its only accepted value.

A missing bucket is an error, not empty state

Nivis distinguishes two things that look similar and are not:

  • A missing state object — the bucket exists, but no state has been written yet. This is a fresh stack: reads return an empty state document and the first write creates the object.
  • A missing state bucket — the state location itself is unreachable. This is an error on every operation:
error: state bucket "acme-nivis-state" does not exist in eu-west-1: the state LOCATION is
missing, not just the state document, so this is not treated as an empty state (that would
re-create resources you already own).
  If this configuration creates that bucket, bootstrap it with:
      nivis apply --backend=local
      nivis state migrate --to-remote
  Otherwise correct backend.bucket / backend.region in your configuration.

The reason for the distinction is blunt: "empty state" and "I cannot see your state" are opposite instructions to apply. The first means create; the second, if mistaken for the first, means re-create infrastructure you already own. So a missing bucket never reads as an empty stack — it stops the run and tells you which of the two situations you are in. A failure that is neither (a permission denial, say) keeps reporting its own cause.