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

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.url makes Nivis a dependency; lib = nivis.lib binds its Nix library.
  • nivis.plan is the attribute nivis evaluates by default: a function ledger → IR. (Name it something else and pass nivis plan --attr <name>.)
  • mkProvider declares the AWS provider: source is its registry address, region lives in Nix, and default_tags is a one-element list because that block is list-nested in the AWS provider.
  • mkResource declares the aws_s3_bucket (force_destroy for easy teardown; bucket omitted so AWS picks a unique name) and an aws_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. And content = 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 resource config.
  • A different region: change region in the provider config.
  • More resources: add more mkResource entries to the resources list; 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.lock pins the exact revision. Re-pin deliberately with nix flake update nivis.

Troubleshooting

  • NoCredentialProviders / could not find credentials: the SDK chain found nothing. Set AWS_PROFILE (or the access-key vars) and confirm with aws 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 explicit bucket name someone already owns (S3 names are global). Omit bucket, or pick another.
  • nivis can't find your flake: run nivis from the directory containing flake.nix, or pass nivis plan --flake /path/to/my-infra.