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
};
};
roleArnis required whenever the block is present. A block without it would assume nothing while reading as configured, so it is refused.sessionNameis 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.externalIdis 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 STS | where it is declared | spelling | |
|---|---|---|---|
| provider | the AWS provider binary | providers.aws.config | assume_role, role_arn |
| backend | nivis itself | backend | assumeRole, 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:
sseAlgorithm | what is sent | the object is encrypted with |
|---|---|---|
absent / AES256 | x-amz-server-side-encryption: AES256 | SSE-S3, an S3-managed key |
"bucket-default" | nothing | whatever the bucket's default encryption rule says |
"aws:kms" | aws:kms plus kmsKeyId | SSE-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
- Takes the state lock on both sides (for backends that support locking), so a
migration cannot race an
apply. - Copies the document to the destination.
- Verifies the destination by reading it back.
- Only then removes the source document — the local state file (along with its
derived
.ledgerand.locksiblings), 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:
| Destination | Result |
|---|---|
| No state document, or an empty one | proceeds |
| Exactly the document being migrated | proceeds (finishes the move) |
| Different resources | refused — needs --force |
| Content that is not a Nivis state document | refused — 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.