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

b2

Manages the Backblaze B2 bucket K8up keeps its restic repository in, and the application key it authenticates with. Applying this module leaves a bucket with the right lifecycle rules and a freshly minted key whose two values you then file into Infisical.

Both the bucket and the key were made by hand in the console before this module existed, so a first apply on an existing account adopts rather than creates. See Adopting the existing bucket.

Prerequisites

  • A state bucket, a state encryption passphrase and two Proton Pass items, all created by hand. See Before the first apply.
  • The b2 CLI, installed by just ops deps, authorized against the Backblaze account.
  • The tofu.b2 section of config/sops/ops.sops.yaml, written from its .example.
  • The common module rules in Rules for every module.

Applying

just tf init b2
just tf plan b2
just tf apply b2

Verify: the plan is a no-op on a second run, and just bak jobs still shows backup jobs completing once the new key is filed.

The bucket’s name and region are not defined here. refs.env reads them from config/sops/cluster.sops.yaml, the file Flux substitutes into K8up’s operator environment. Flux has to hold the name, and this module provisions what it names rather than naming it a second time.

Before the first apply

A module cannot create the bucket holding its own state, nor the keys it authenticates with, so both come first and by hand. Run this once per Backblaze account, from the operator machine.

Every step uses the b2 CLI, which just ops deps installs. The console can do steps 2 and 3; it cannot do step 5, because its key form has no capability list.

1. Authorize the CLI

b2 account authorize          # prompts for the master applicationKeyId and applicationKey
b2 account get                # verify: prints the account id, s3ApiUrl and allowed capabilities

b2 account get prints the authorized key and its auth token alongside those. Do not paste its output anywhere.

The master key is what you use here, and only here. It works on B2’s native API, which the CLI speaks; it is the one credential Backblaze refuses on the S3 API the tofu backend speaks. Read it from the console under Account > Application Keys.

2. Create the state bucket

b2 bucket create <name> allPrivate     # prints the new bucket id
b2 bucket get <name>                   # verify: bucketType is allPrivate

The name must be globally unique across all of Backblaze, at least six characters, and distinct from the backups bucket. No object lock, and no default server-side encryption: OpenTofu encrypts the state before it is uploaded, per State is remote, and why.

From the console it is B2 Cloud Storage > Buckets > Create a Bucket, with Files in Bucket set to Private and Object Lock left off.

3. Read the region

A bucket’s Endpoint, on its console page or as s3ApiUrl in b2 account get, reads s3.<region>.backblazeb2.com. That <region> is B2_REGION in config/sops/cluster.sops.yaml. .just/tofu.just builds AWS_ENDPOINT_URL_S3 from it for the backend, Flux renders it into BACKUP_GLOBALS3ENDPOINT, and The region check is what catches a wrong one.

4. Mint the state encryption passphrase

Any value of at least 16 characters, which is the pbkdf2 key provider’s minimum. Generate it in Proton Pass, or:

openssl rand -base64 36

It goes in the state passphrase field of backblaze-tofu-state. Losing it loses the state, which is recoverable by re-import — see State is remote, and why.

5. Create the two application keys

b2 key create backblaze-tofu \
  listBuckets,readBuckets,writeBuckets,deleteBuckets,listKeys,writeKeys,deleteKeys,listFiles,readFiles,writeFiles,deleteFiles

b2 key create --bucket <state bucket> backblaze-tofu-state \
  listBuckets,listFiles,readFiles,writeFiles,deleteFiles

b2 key list --long    # verify: both keys, with those capabilities and those bucket restrictions

Each command prints the key id and then the key itself, and the key itself is printed once only.

The first takes no --bucket. Creating a bucket and minting a key are account-level capabilities, and a bucket-restricted key cannot hold them. It also carries every capability keys.tf grants K8up, because a key can only mint a key whose capabilities are a subset of its own.

The console cannot do this step. Its key form offers a bucket scope and an access type of Read and Write, Read Only or Write Only, and no capability list; Backblaze does not document which capabilities those map to, so a key made there cannot be shown to match the two above.

6. File both keys into Proton Pass

Two items in the vault. The tofu.b2 section of config/sops/ops.sops.yaml.example names every field, and the key id and key from each b2 key create go into the item of the same name.

ItemHolds
backblaze-tofuthe provider’s key id and key
backblaze-tofu-statethe backend’s key id and key, and the state passphrase

7. Record the state bucket’s name

TF_BACKEND_BUCKET, under tofu.b2 in config/sops/ops.sops.yaml, as the name itself rather than a pass:// URI. It identifies a bucket, it is not a credential, and it is the only record of that name anywhere in the repository.

Then, in that order

just ops setup runs just tf init at step 3 of the cold bootstrap, before config/sops/ops.sops.yaml exists. This module is the only one with a backend, so it is the only one skipped, with a message saying so. Run just tf init b2 yourself once step 5 has written that file.

Adopting the existing bucket

The bucket already exists, so the first plan wants to create it. Import instead:

just tf init b2
just tf plan b2                                     # wants to CREATE the bucket, do not apply
just tf adopt b2 b2_bucket.backups <bucket id>      # id from the console, or `b2 bucket get`
just tf plan b2                                     # now a no-op but for lifecycle_rules
just tf apply b2

The old application key is not imported. Let the apply mint a fresh one, then follow Credential rotation from the filing step: file the new key, prove a backup works, revoke the old key in the console, and prove a backup works again.

After this, every other operator machine needs just tf init b2 and nothing else.

Filing K8up’s key

The module mints the key and stops there. Two sensitive outputs carry it, and Infisical /infra/k8up is where it goes. infra/backup/app/secret.yaml syncs it from there into the Secret the operator reads its BACKUP_GLOBAL* credentials from.

just tf output b2 -raw k8up_b2_key_id           # -> B2_KEY_ID
just tf output b2 -raw k8up_b2_application_key  # -> B2_APPLICATION_KEY

A bare tofu output in tofu/b2 does not work: the state is remote, so reading it needs the same composed environment plan and apply get. The recipe supplies it.

Replacing the key later is the same loop, forced. Both capabilities and bucket_ids force replacement. The full procedure, with its verification and revoke ordering, is Credential rotation.

Filing brokkr’s key

brokkr gets a second bucket and a second key, scoped to it alone. Two repositories rather than one with two prefixes, because restic has no per-path access control: a key that can write into a repository can read every snapshot in it, and the cluster’s repository is not something a node outside the cluster should be able to read.

Its key goes to Proton Pass, not Infisical, because brokkr runs no Infisical operator and holds no store credential at all. ansible/roles/forge is what puts it on the node, as /etc/futhark/restic.env.

just tf output b2 -raw brokkr_b2_key_id           # -> brokkr-restic/key id
just tf output b2 -raw brokkr_b2_application_key  # -> brokkr-restic/application key

Then just ans setup brokkr --tags podman, which rewrites that file. Until it runs, the node is still using the old key. See The standalone Podman plane.

The bucket name comes from brokkr.B2_BUCKET in config/sops/ops.sops.yaml rather than from cluster.sops.yaml, because no Flux workload substitutes it and the cluster has no business knowing the bucket exists. The region is shared: a B2 region is a fact of the account, and both buckets are in one account.

What buckets.tf sets, and what it does not

Both buckets get the same settings, and everything below applies to each.

The bucket is allPrivate, has no object lock, and has no lifecycle rule touching live objects. Retention belongs to the prune schedule and its keep policy. A B2-side deletion of a live object corrupts the repository, and the corruption surfaces at restore time. The two rules present only reap what restic has already abandoned: unfinished large uploads after a day, hidden versions after 30. That last one is also why a prune reclaims no billed storage for a month.

State is remote, and why

This is the one module whose state is not local, against the rule in Rules for every module. b2_bucket can be imported by bucket id and nothing else, and B2 bucket names are globally unique. On a second operator machine, empty state does not mean “adopt the bucket”, it means an apply that fails with duplicate_bucket_name. The state lives in a B2 bucket instead, and any machine with the two Proton Pass items reaches it with just tf init b2 and nothing more.

That bucket is created by hand and stays unmanaged, because a module cannot create the bucket holding its own state. Nothing in the repository names it. TF_BACKEND_BUCKET under tofu.b2 in config/sops/ops.sops.yaml is the only record, and .just/tofu.just passes it to tofu init as -backend-config, so it is never written into a .tf file.

Two things about that backend are load-bearing:

  • State encryption. b2_application_key.k8up puts a live credential in state in plaintext, which is exactly what the local-state rule exists to prevent. OpenTofu’s own state encryption is what stops that: backend.tf derives an AES-GCM key from TF_VAR_state_passphrase, so the object is already ciphertext when it reaches B2, and the local .terraform copy is encrypted too. Lose the passphrase and you lose the state, not the backups. Recover by re-importing.

    This was SSE-C until 2026-08-10, and it never worked. OpenTofu validates the length of AWS_SSE_CUSTOMER_KEY and then writes the object without the customer-key headers, so the state sat readable in the bucket with the backup B2 key in it — a fetch with the key set returned 400, and a fetch with no key at all returned the JSON. Client-side encryption is checkable by eye, which is the reason to prefer it: fetch the object and it is not JSON. The backups themselves have no such caveat: restic encrypts client-side, so nothing in the bucket depends on a server-side header being honoured.

  • use_lockfile = false. B2’s S3 API does not honour the If-None-Match conditional write the S3 backend’s lockfile is built on, so with locking on no apply ever acquires the lock. This is safe only because there is one operator running one apply at a time. If that stops being true, the lock has to come from somewhere else.

The region check

account.tf asserts that the account’s own S3 endpoint matches the region in config/sops/cluster.sops.yaml. Nothing else does. Flux will happily render a wrong region into BACKUP_GLOBALS3ENDPOINT, and the symptom is every backup job failing against a host that does not resolve. A check block warns on plan rather than failing it, which is the right weight: a mismatch there does not make the plan for the bucket wrong.