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
b2CLI, installed byjust ops deps, authorized against the Backblaze account. - The
tofu.b2section ofconfig/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.
| Item | Holds |
|---|---|
backblaze-tofu | the provider’s key id and key |
backblaze-tofu-state | the 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.k8upputs 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.tfderives an AES-GCM key fromTF_VAR_state_passphrase, so the object is already ciphertext when it reaches B2, and the local.terraformcopy 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_KEYand 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 returned400, 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 theIf-None-Matchconditional 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.