Tutorial: remote state on S3, with locking (hands-on)
This walks you through the M2 team-ready features added in this release: keeping your state in a shared S3 object instead of a local file, and the lock that stops two applies from corrupting it.
To keep it focused on state (not cloud resources), the resources in this tutorial
are the offline fake providers — but the state is stored in a real S3
bucket. So you exercise the real remote-state path (and real locking, a real lock
object, a real force-unlock) without creating any billable infrastructure.
Prerequisites: Nix on your PATH, an S3 bucket you own, and AWS credentials in
your environment (the AWS default chain). Set AWS_PROFILE (or
AWS_ACCESS_KEY_ID/…). Credentials are never in the config: only the bucket/key/
region are.
Setup
Enter a shell with nivis and the fake providers, and point AWS at your profile:
nix shell github:nivis-project/nivis#nivis github:nivis-project/nivis#fake-providers
export AWS_PROFILE=your-profile
The config for this tutorial ships as the flake attribute nivis.remoteState
(nix/example/remote-state.nix). Open it and set the bucket and region to
one you own:
backend = {
type = "s3";
bucket = "your-state-bucket"; # <- yours
key = "nivis-tutorial/remote-state/app.json";
region = "eu-west-1"; # <- your bucket's region
};
That backend block is the whole feature: it tells Nivis to store state in
s3://your-state-bucket/nivis-tutorial/remote-state/app.json instead of a local
file.
State lives where your config says, not where a flag says. Declare a backend
on toIR and nivis reads and writes state there:
toIR {
backend = { type = "s3"; bucket = "your-state-bucket"; key = "prod/app.json"; region = "eu-west-1"; };
providers = { /* ... */ };
resources = [ /* ... */ ];
inherit ledger;
}
Credentials come from the AWS chain (AWS_PROFILE/keys); only the location is in
config. Absent backend keeps the local file store (unchanged).
1. Apply: state goes to S3
AWS_PROFILE=$AWS_PROFILE nivis apply --attr nivis.remoteState
Acquired state lock.
Applied 2 resource(s) across 2 phase(s):
Phase 1
+ alpha.alpha_token.app
Phase 2
+ beta.beta_record.app
Released state lock.
Notice the Acquired/Released state lock lines: apply took the lock for the
duration of the run. Now look in your bucket — the state object is there:
aws s3 ls s3://your-state-bucket/nivis-tutorial/remote-state/
# app.json
There is no local nivis.state.json: the state of record is the S3 object.
2. Plan reads state back from S3
AWS_PROFILE=$AWS_PROFILE nivis plan --attr nivis.remoteState
= alpha.alpha_token.app (alpha_token)
= beta.beta_record.app (beta_record)
No changes. 2 resource(s) up to date.
plan read the state straight from S3 and saw nothing to do. (Read-only commands
like plan do not take the lock.)
3. Locking: concurrent applies are kept apart
apply and destroy take an advisory lock (a small <key>.lock object created
atomically in S3) so two people or CI jobs cannot mutate the same state at once.
If a run is already holding it, the next one stops before doing anything:
error: state is locked by alice@ci-runner since 2026-06-22T10:31:04Z for "apply"; run `nivis force-unlock` to override
You can see this for yourself: in one terminal, hold the lock by pausing an apply
(or simulate a crashed run), and in another run nivis apply — it refuses with the
holder's name and time.
If a run crashes while holding the lock, the lock object is left behind and the next run is blocked. Clear it:
AWS_PROFILE=$AWS_PROFILE nivis force-unlock --attr nivis.remoteState
It confirms first (pass --force in CI). Only do this when you are sure no other
run is active.
4. Read outputs and clean up
AWS_PROFILE=$AWS_PROFILE nivis output --attr nivis.remoteState
AWS_PROFILE=$AWS_PROFILE nivis destroy --attr nivis.remoteState
destroy also takes the lock. When you are done, remove the state object (and any
leftover .lock) from your bucket:
aws s3 rm s3://your-state-bucket/nivis-tutorial/remote-state/app.json
aws s3 rm s3://your-state-bucket/nivis-tutorial/remote-state/app.json.lock 2>/dev/null || true
Notes
- State is Nivis's own format: it is not a Terraform/OpenTofu
tfstatefile and the two are not interchangeable. - Every write to the state object requests server-side encryption (AES256).
- To move an existing local state into S3, declare the backend and run
nivis state migrate --to-remote(and--from-remoteto bring it back). It copies the document, verifies it at the destination, and only then removes the source. - If the bucket is one this configuration creates itself, bootstrap it with
nivis apply --backend=local, thennivis state migrate --to-remote.
See Remote state (the S3 backend) for the full reference.