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:wearetechnative/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:wearetechnative/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:wearetechnative/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.
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):
✓ aws.aws_s3_bucket.demo
✓ aws.aws_s3_object.note
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.
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.expected array … got map[string]interface {}for a provider block: that block is list-nested; wrap it in a list, e.g.default_tags = [ { tags = { … }; } ].- 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.