Tutorial: an S3 bucket on AWS
A genuinely from-scratch walkthrough. You start in an empty directory on your
own machine (not a checkout of Nivis), install the nivis CLI, scaffold a
fresh flake that uses Nivis as a dependency, declare one S3 bucket, and
drive it through plan → apply → inspect → destroy. By the end you'll have a
small infra flake you own and a real bucket created and torn down.
⚠️ This creates a real resource in your AWS account: a single S3 bucket (no objects, negligible cost) that you destroy at the end. The commands and outputs below come from real runs.
Prerequisites: Nix (with flakes enabled) on your PATH, and AWS credentials
you can use locally.
Part 1: Install nivis
The CLI is nivis. You don't need to clone anything; the quickest path is to run it
straight from the flake:
nix run github:nivis-project/nivis#nivis -- --version
If you'd rather have nivis on your PATH for the rest of this tutorial, install it
persistently or open a shell with it: see Installing Nivis for all
the options (nix run, nix shell, nix profile install, building from a clone). The rest of this tutorial writes nivis …; if you
chose the ad-hoc form, read that as nix run github:nivis-project/nivis#nivis -- ….
Part 2: A fresh infra flake
2.1 Scaffold the flake
mkdir my-infra && cd my-infra
nix flake init
nix flake init drops a placeholder flake.nix (a hello package). Replace its
contents with the infra flake below.
2.2 The boilerplate
{
description = "My infrastructure, as Nix code (Nivis).";
# Pull Nivis in as a dependency. `nix flake lock` (run automatically by
# the first nivis command) records the exact revision in flake.lock.
inputs.nivis.url = "github:nivis-project/nivis";
outputs =
{ self, nivis }:
let
# The Nivis Nix library: mkResource, mkProvider, toIR, str, …
lib = nivis.lib;
in
{
# `nivis` (the CLI) evaluates the attribute `nivis.plan` by default. It's a
# function of the outputs ledger (the apply-time values fed back in each
# phase).
nivis.plan =
ledger:
let
# A bucket. `bucket` (the name) is omitted, so AWS generates one.
bucket = lib.mkResource {
provider = "aws";
type = "aws_s3_bucket";
name = "demo"; # id becomes aws.aws_s3_bucket.demo
config = {
force_destroy = true; # let `nivis destroy` delete it even if non-empty
};
};
# A text file whose CONTENT is generated by Nix, from the bucket's own
# output. `bucket = bucket.refAttr "id"` makes the object depend on the
# bucket; `content` is a Nix string embedding the bucket's generated
# name, which doesn't exist until the bucket is applied. This is the
# round trip (see below).
note = lib.mkResource {
provider = "aws";
type = "aws_s3_object";
name = "note";
config = {
bucket = bucket.refAttr "id";
key = "hello-from-nix.txt";
content = lib.str [
"This file's content was generated by Nix.\n"
"It is stored in the bucket named: "
(bucket.refAttr "id")
"\n"
];
content_type = "text/plain";
};
};
in
lib.toIR {
# --- providers --------------------------------------------------
providers.aws = lib.mkProvider {
source = "registry.opentofu.org/hashicorp/aws";
config = {
region = "eu-central-1"; # set in Nix, not via AWS_REGION
# default_tags is a *list-nested* block in the AWS provider, so it
# takes a list (a bare attrset is rejected at configure time).
default_tags = [ { tags = { managed-by = "nivis"; }; } ];
};
};
# --- resources --------------------------------------------------
resources = [ bucket note ];
inherit ledger;
};
};
}
Reading it:
inputs.nivis.urlmakes Nivis a dependency;lib = nivis.libbinds its Nix library.nivis.planis the attributenivisevaluates by default: a functionledger → IR. (Name it something else and passnivis plan --attr <name>.)mkProviderdeclares the AWS provider:sourceis its registry address,regionlives in Nix, anddefault_tagsis a one-element list because that block is list-nested in the AWS provider.mkResourcedeclares theaws_s3_bucket(force_destroyfor easy teardown;bucketomitted so AWS picks a unique name) and anaws_s3_object.- The object is the interesting part.
bucket = bucket.refAttr "id"is a reference to the bucket's output, so Nivis knows the object depends on the bucket. Andcontent = lib.str [ … (bucket.refAttr "id") … ]builds the file's body in Nix, embedding the bucket's name, a value that does not exist until the bucket is applied. See A file whose content comes from Nix below.
2.3 Credentials
nivis uses the AWS SDK default credential chain (the same one the AWS CLI
uses). Point it at your account, typically a named profile:
export AWS_PROFILE=your-profile
aws sts get-caller-identity # sanity check
Only credentials come from the environment; the region is in the flake above.
2.4 Parameterise with a variable (optional)
Hard-coding eu-central-1 is fine for one environment. To set it per run,
declare a variable with nivis.mkVars and read it in the plan. mkVars takes a
declaration (type and optional default) and the values Nivis injects, and returns
the resolved, typed result:
nivis.plan =
ledger:
let
vars = lib.mkVars {
region = { type = "str"; default = "eu-central-1"; };
} (ledger.vars or { });
in
lib.toIR {
providers.aws = lib.mkProvider {
source = "registry.opentofu.org/hashicorp/aws";
config.region = vars.region; # was the hard-coded string
};
# ... resources ...
inherit ledger;
};
Then override it at the CLI:
nivis plan --var region=us-east-1
A variable with no default is required. For the full story (types,
defaults, --var-file, NIVIS_VAR_*, and precedence) see
Variables.
Part 3: Plan, apply, inspect, destroy
Run these from your my-infra directory.
Plan
nivis plan
+ aws.aws_s3_bucket.demo (aws_s3_bucket)
+ aws.aws_s3_object.note (aws_s3_object)
2 resource(s) to resolve across phases (+ create, ~ change). Run `nivis apply`.
The first nivis command resolves the nivis input (writing flake.lock)
and, on first use of a real provider, downloads it, so the first run is slower.
Apply
nivis apply
Applied 2 resource(s) across 2 phase(s):
Phase 1
+ aws.aws_s3_bucket.demo
Phase 2
+ aws.aws_s3_object.note
(On a terminal the markers are colored by change type: + create green, ~
update yellow, -/+ replace, - destroy red, = no-op dim, and a datasource
read shows a dim r. Piped or with NO_COLOR set, it is plain text.)
Two phases, not one. Phase 1 creates the bucket: nothing else can run,
because the object's content needs the bucket's name. Nivis then
re-evaluates the Nix config with the bucket's generated name injected, which
resolves the object's content; phase 2 uploads it. nivis writes the resulting
state to nivis.state.json in my-infra.
What appears in your working directory
The run writes two files next to your config:
nivis.state.json the state document
nivis.state.json.ledger the outputs ledger
Neither may be committed. Both can hold secrets that came back from a
provider: an access key, a generated password, a token. The ledger in particular
is where Nivis keeps sensitive provider outputs in plaintext, deliberately, to
keep them out of the world-readable Nix store. Both are written 0600, but file
permissions do not stop git add.
Add this to your .gitignore before your first commit:
nivis.state.json*
*.ledger
*.ledger is a suffix rule, so it still covers the ledger if you point --state
somewhere else. Nivis also names these paths once, the first time a run creates
them.
If one of them is already committed, adding it to .gitignore is not enough: the
file stays in your history. Remove it from the repository and rotate whatever it
contained.
Inspect the round trip
nivis state show aws.aws_s3_bucket.demo
arn = arn:aws:s3:::terraform-20260615181937557000000001
tags_all = map[managed-by:nivis]
force_destroy = true
region = eu-central-1
id = terraform-20260615181937557000000001
…
The bucket name (terraform-2026…) was generated by AWS and read back into
state: a value that didn't exist until apply is now concrete. tags_all shows
the provider's default_tags were applied.
A file whose content comes from Nix
This is the point of the project. Look at the object:
nivis state show aws.aws_s3_object.note
bucket = terraform-20260615181937557000000001
key = hello-from-nix.txt
content_type = text/plain
content = This file's content was generated by Nix.
id = terraform-20260615181937557000000001/hello-from-nix.txt
The content was built in Nix, and it embeds the bucket's
AWS-generated name, which did not exist until phase 1 created the bucket. Fetch
the actual object from S3 to see it landed in the real world:
aws s3 cp "s3://$(nivis state show aws.aws_s3_bucket.demo | awk '/^ id = /{print $3}')/hello-from-nix.txt" -
This file's content was generated by Nix.
It is stored in the bucket named: terraform-20260615181937557000000001
A value computed in the Nix domain became the body of a real resource in the Terraform/AWS domain, resolved across phases. That round trip, not just "Terraform from Nix," but Nix and provider state feeding each other, is why Nivis exists.
Destroy
nivis destroy
Destroyed 2 resource(s):
- aws.aws_s3_object.note
- aws.aws_s3_bucket.demo
Resources tear down in reverse dependency order: the object first, then the bucket. Confirm nothing's left:
aws s3api list-buckets --query 'Buckets[?contains(Name, `terraform-`)].Name'
Make it your own
- A specific bucket name: add
bucket = "globally-unique-name";to the resourceconfig. - A different region: change
regionin the providerconfig. - More resources: add more
mkResourceentries to theresourceslist; wire one resource's output into another with the reference helpers (refAttr) and Nivis resolves them across phases. - Pin Nivis: the input floats on the default branch;
flake.lockpins the exact revision. Re-pin deliberately withnix flake update nivis.
Troubleshooting
NoCredentialProviders/could not find credentials: the SDK chain found nothing. SetAWS_PROFILE(or the access-key vars) and confirm withaws sts get-caller-identity.this is a list-nested block; wrap the value in a one-element list: you wrote a provider block as a bare attrset{ … }, but it is list-nested, so it takes a one-element list, e.g.default_tags = [ { tags = { … }; } ]. The error names the attribute (e.g.["default_tags"]: …). The symmetric case, a single-nested block given a list, says to pass one attrset instead.- First run is slow / seems to hang: it's resolving the flake input and downloading the ~900 MB AWS provider once; later runs use the cache.
BucketAlreadyExists: you set an explicitbucketname someone already owns (S3 names are global). Omitbucket, or pick another.niviscan't find your flake: runnivisfrom the directory containingflake.nix, or passnivis plan --flake /path/to/my-infra.