Garage S3¶
Garage is a self-hosted, S3-compatible object store. It runs outside the Kubernetes cluster, in its own LXC container on the proxmox-01 hypervisor, so it can serve as a backup target that does not depend on the cluster it backs up.
Architecture¶
flowchart LR
subgraph PVE[proxmox-01 hypervisor]
subgraph CT[LXC 210 garage-01]
CADDY[Caddy\n:443 TLS] --> GARAGE[garage\nS3 API :3900]
GARAGE --> META[/var/lib/garage/meta\nLMDB/]
GARAGE --> DATA[/var/lib/garage/data\nblocks/]
end
end
CLIENT[S3 clients\naws / rclone / volsync] --> CADDY
META -.-> ZFS[(ZFS dataset\n500G mount point)]
DATA -.-> ZFS Why an LXC container rather than a VM: Garage is a single static binary with no kernel requirements, so a VM would only add a guest kernel and a virtual disk layer between it and the ZFS pool. The data volume is a plain ZFS dataset the host can snapshot and replicate directly.
| Container | LXC 210 / garage-01 on proxmox-01 |
| Address | garage-01.servers.local (VLAN 20, DHCP) |
| S3 endpoint | :3900 (plain HTTP, in-VLAN) |
| Region | garage |
| Data | /var/lib/garage -- dedicated 500G ZFS mount point |
| Version | pinned in ansible/roles/garage/defaults/main.yaml |
Ownership split¶
Two layers own this service, and the boundary matters when changing it:
terraform/proxmox/ct_garage.tfcreates the container shell -- CPU/memory, the 16G rootfs, the 500G/var/lib/garagemount point, the NIC, and the injected SSH key. Nothing inside the guest.ansible/roles/garageowns everything inside the guest -- the binary, config, systemd unit, and the one-time cluster bootstrap.
Provisioning¶
Terraform creates the container:
CI applies this automatically on merge to main (.github/workflows/terraform-proxmox.yaml), so in practice the container appears once the change lands.
Pin the DHCP lease with a UniFi reservation using the MAC:
Container features need root@pam
Proxmox only permits root@pam -- the literal login, not an API token derived from it -- to change LXC feature flags other than nesting. The bpg/proxmox provider always sends the whole feature map (fuse, keyctl, mknod, nesting), so applying features { nesting = true } fails with HTTP 403 changing feature flags (except nesting) is only allowed for root@pam.
Set it once on the host instead, after which Terraform reconciles to a no-op:
nesting is required because Debian 13 ships systemd 257, which wants user namespaces inside the guest (WARN: Systemd 257 detected. You may need to enable nesting).
Bootstrap¶
just ansible ping-garage # SSH reachability
just ansible check-garage # dry-run + diff
just ansible deploy-garage # apply
The role is idempotent and does the following:
- Installs the pinned, sha256-verified static musl binary to
/usr/local/bin/garage. - Creates the
garagesystem user and themeta/datadirectories under the mount point. - Templates
/etc/garage.tomland thegarage.servicesystemd unit, then enables and starts it. - On a fresh cluster only -- assigns this node a layout role and applies layout version 1.
- Ensures the buckets in
garage_bucketsand keys ingarage_keysexist, each key granted read/write/owner on its buckets.
Step 4 is guarded on the layout being empty, so re-runs never disturb an existing layout.
Secrets¶
rpc_secret and the admin API admin_token are SOPS-encrypted in ansible/roles/garage/vars/secrets.sops.yaml with the repo age key. Rotate them by editing that file with sops and re-deploying.
S3 keys are not in git
Garage stores S3 access keys durably in its own metadata, so they are deliberately not kept in this repo. Read a secret back on the host at any time -- see Managing keys.
Using the bucket¶
Get the credentials from the host:
ssh root@garage-01.servers.local \
'GARAGE_CONFIG_FILE=/etc/garage.toml garage key info --show-secret garage-admin'
Configure an aws CLI profile:
aws configure set aws_access_key_id <ACCESS_KEY_ID> --profile garage
aws configure set aws_secret_access_key <SECRET> --profile garage
aws configure set region garage --profile garage
aws configure set s3.addressing_style path --profile garage
Read and write:
aws --profile garage --endpoint-url http://garage-01.servers.local:3900 \
s3 cp ./file.txt s3://garage/
aws --profile garage --endpoint-url http://garage-01.servers.local:3900 \
s3 ls s3://garage/
To drop the repeated flag, add an endpoint_url to the profile in ~/.aws/config (supported by AWS CLI v2):
[profile garage]
region = garage
endpoint_url = http://garage-01.servers.local:3900
s3 =
addressing_style = path
Path-style addressing is required
The config sets root_domain = ".s3.garage.wibrow.dev", which would enable vhost-style bucket URLs (<bucket>.s3.garage.wibrow.dev) -- but no DNS exists for it. Without addressing_style = path, clients build vhost-style URLs that fail to resolve.
Verify a write landed from the Garage side -- Objects should increment:
TLS¶
Garage does not terminate TLS itself -- upstream expects a reverse proxy in front. The garage-tls role puts Caddy on :443 in front of the S3 API and serves https://s3.wibrow.dev.
Certificates come from certbot using the Let's Encrypt DNS-01 challenge, not Caddy's own ACME. Two reasons:
s3.wibrow.devresolves to an RFC1918 address, so HTTP-01 can never reach this host.- Debian's packaged Caddy has no Cloudflare DNS module, and the custom-build download URL serves a rolling artifact -- pinning a checksum against it would break the role every time Caddy publishes a release.
certbot installs its own renewal timer; a deploy hook reloads Caddy after each renewal.
Plain HTTP on :3900 is intentionally left listening for in-VLAN clients that do not want TLS.
Prerequisites¶
Both the certificate challenge and the DNS record need a Cloudflare API token on the host, scoped to Zone → Read and Zone → DNS → Edit for wibrow.dev. Store it once, SOPS-encrypted, where the community.sops vars plugin picks it up for every host:
cat <<'EOF' > ansible/inventory/group_vars/all.sops.yaml
cloudflare_api_token: "<token>"
EOF
sops -e -i ansible/inventory/group_vars/all.sops.yaml
DNS record (automatic)¶
The cloudflare-ddns role (deployed by the same garage-tls playbook) keeps an unproxied s3.wibrow.dev A record pointed at the container's current VLAN-20 address. A systemd timer reconciles it every 5 minutes, so a changed DHCP lease self-heals -- no manual record and no UniFi reservation required. The record must be unproxied because it is an RFC1918 address: Cloudflare cannot proxy to it, and the endpoint is internal-only.
Records, interface, and interval are configurable in roles/cloudflare-ddns/defaults/main.yaml. The role is host-agnostic and can publish records for any DHCP host.
Using the TLS endpoint¶
Proxmox host certificate¶
The hypervisor's own web UI serves a self-signed certificate, which is why PROXMOX_VE_INSECURE = "true" exists in the root mise.toml. Proxmox VE has a built-in ACME client that can replace it, configured by the same proxmox role and the same Cloudflare token:
proxmox_acme_enabled: true
proxmox_acme_domain: proxmox-01.wibrow.dev
It is opt-in (proxmox_acme_enabled: false by default) so it never fires unexpectedly on an unrelated run. Let's Encrypt cannot issue for .servers.local, so the host needs a name in a real zone with an A record pointing at its VLAN-20 address. PVE's daily timer renews it.
Change the endpoint only after verifying the certificate
Terraform reaches Proxmox at PROXMOX_VE_ENDPOINT. Only after confirming the new certificate serves correctly should you repoint that variable at the ACME domain and drop PROXMOX_VE_INSECURE -- getting it wrong breaks every terraform/proxmox plan and apply, including CI.
Operations¶
Every management command needs the config for its rpc_secret, so either export it or run as root where /etc/garage.toml is the default path:
Health and layout¶
garage status # nodes, capacity, version
garage layout show # current layout and version
garage stats # storage and request statistics
Managing buckets¶
garage bucket list
garage bucket create <name>
garage bucket info <name>
garage bucket delete <name> # must be empty
Managing keys¶
garage key list
garage key create <name>
garage key info --show-secret <name>
garage bucket allow --read --write --owner <bucket> --key <name>
garage bucket deny --read --write --owner <bucket> --key <name>
Prefer adding buckets and keys to garage_buckets / garage_keys in the role defaults rather than creating them by hand, so they are reproducible.
Upgrading¶
Bump garage_version and the matching entry in garage_checksums in ansible/roles/garage/defaults/main.yaml, then re-deploy. Get the checksum by downloading the release asset and hashing it:
curl -fsSL https://garagehq.deuxfleurs.fr/_releases/v<VERSION>/x86_64-unknown-linux-musl/garage \
| shasum -a 256
Check the release notes for migration steps before crossing a major version.
Redundancy¶
Single node -- no redundancy today
replication_factor = 1. There is exactly one copy of every object, on one disk, in one container. A lost dataset means lost data. Treat Garage as not yet durable and keep anything irreplaceable somewhere else as well.
A second replica on the Synology NAS is planned. Garage wants an odd number of nodes for metadata quorum, so a third node (or a lightweight tiebreaker) belongs in the plan before relying on it.
Raising replication_factor is not a config-only change -- the additional nodes must join and the layout must be recomputed first. When a second node joins, rpc_public_addr must also change from 127.0.0.1:3901 to the node's real VLAN-20 address so peers can dial it, and every node must share the same rpc_secret.
Troubleshooting¶
Service will not start
Permission denied (publickey) when deploying
The container's root authorized_keys holds whichever key ran the Terraform apply. Because CI runs on the self-hosted runner, that is the runner's key, not yours. var.ssh_public_keys is supplied from the age-encrypted TF_VAR_ssh_public_keys in the root mise.toml, but ct_garage.tf ignores changes to initialization[0].user_account, so changing it does not re-key an existing container. Add the key directly instead:
pct exec 210 -- bash -c 'install -d -m700 /root/.ssh && \
echo "<your-public-key>" >> /root/.ssh/authorized_keys'
Do not "fix" this by recreating the container -- a fresh create re-sends the full feature map and hits the same root@pam 403 described above.
Forbidden: Garage does not support anonymous access yet
This is the healthy response to an unauthenticated request; it confirms the API is up. Reaching it with curl is a quick liveness check:
Client errors mentioning a bucket-prefixed hostname
The client is using vhost-style addressing. Set addressing_style = path.
Reference¶
- Garage documentation
terraform/proxmox/ct_garage.tf-- container definitionansible/roles/garage/-- install and bootstrapansible/README.md-- role usage