RustFS
rustfs is a Rust reimplementation of
S3-compatible object storage: the same S3 API, a web console, erasure-coded
multi-drive and multi-node layouts — and a completely different runtime model
from MinIO/silo. pgcli runs it as a standalone, top-level addon with the
same CLI surface as minio and silo (install, TLS,
BYO certs, drives, logs, autostart), sharing the one port pool, but with three
differences that shape this page:
- A fixed container user. Upstream rustfs bakes
User=rustfs(uid/gid 10001) into the image. pgcli works around this entirely inside its own wrapper image — see Privileges and ownership below — so a rustfs store needs no host-side ownership dance. - Three topologies, no multi-node single-drive. rustfs speaks SNSD / SNMD / MNMD only — see Deployment modes.
- Its own TLS filenames. rustfs reads
rustfs_cert.pem/rustfs_key.pemfromRUSTFS_TLS_PATH, not MinIO’spublic.crt/private.key.
Pick rustfs when you want a lean, Rust-native S3 endpoint; it can coexist with minio and silo on one host (all three draw from the same port pool).
Platform support: the rustfs addon is Linux only, in practice. The runtime model (a fixed container uid, drives on distinct block devices, host networking) is a Linux container story and is only exercised on Linux; the macOS bridge path is code-complete and the wrapper image is dual-arch, but it has not been tested on a Mac yet.
How It Works
One container per instance. The data lives in a bind-mounted host directory
(default <base-dir>/addon/rustfs/<name>/data, or one directory per --drive),
so it outlives the container. Unlike the minio/silo addons, pgcli does not
drive the rustfs binary directly — it runs the image’s own /entrypoint.sh
(via the wrapper below) because that entrypoint is what expands the multi-drive
brace range in RUSTFS_VOLUMES, creates the per-drive directories, and assembles
the server argv.
The image pgcli pulls is not the bare upstream one — it is
ghcr.io/mars-base/pgcli/pgcli-rustfs:1.0.0, a thin pgcli wrapper built on top
of docker.io/rustfs/rustfs:1.0.0 (the first GA release). pg addon install rustfs just pulls the wrapper; pgcli never builds it at run time. The tag tracks
the pinned upstream version.
Credentials are handled like minio/silo:
root_userdefaults toadmin(rustfs imposes no minimum key length; anything non-empty works, andadminsidesteps therustfsadminwarning);root_passwordis generated on first install (or supplied via--root-password), stored inpg.yaml(addons.rustfs.<name>.root_password) and printed once in the install summary.
They are rustfs’s root access key / secret key, passed via the
RUSTFS_ACCESS_KEY / RUSTFS_SECRET_KEY env vars — what every S3 client
(including pg mc alias set) calls the access key and secret key.
Privileges and ownership
This is the one place rustfs genuinely differs from minio/silo, and pgcli has absorbed it into the image so it is invisible in practice.
Upstream rustfs runs as a fixed, non-configurable uid/gid (10001). Under
rootless podman a host user has no claim on that uid, so the naive way to make a
bind-mounted data directory writable by the process is a host-side chown dance
(podman unshare chown 10001:10001 …) — fragile, and it fights with the fact
that the operator may not be root. pgcli sidesteps all of it:
- The wrapper image’s entrypoint starts as container root,
chowns its own bind-mounted data directories to 10001, thensu-drops to therustfsuser andexecs the unmodified upstream/entrypoint.sh. pgcli passes no--userflag and does no host-side ownership work at all. - The result is the same on both daemon modes; only the host-visible numeric
uid differs, because that is a property of podman’s user namespace, not of
pgcli:
- rootful podman → the directories land on the real host uid
10001; - rootless podman → they land on a subordinate host uid
(
subuid_start + 10001, e.g.110001), with10001only visible inside the container.
- rootful podman → the directories land on the real host uid
Neither case needs the operator to be root or to run chown by hand. The one
convention still worth knowing is that when you mount drives yourself
(--drive), the mount point should be owned by the user who runs pg — under
rootless podman a root-owned mount point is outside the namespace’s uid range and
cannot be claimed by the in-container chown, exactly as with any bind mount.
TLS is copied, never re-owned. rustfs must read its key+cert as 10001, but
the cert directory pgcli generated (0700, pgcli-owned) has to stay pgcli-owned
so pg cert, CA refresh and pg backup fetch-ca keep working on it. So the
wrapper does not chown that directory: it mounts it read-only and, as
container root, copies the two required files into a fresh container-local
directory (owned by 10001) that RUSTFS_TLS_PATH points at. No host file is
ever mutated, and the BYO path uses the same copy mechanism, so BYO key/cert are
mounted read-only at the required names and never chowned either.
Install
The output reports endpoints and the root credentials:
Sign in to the console at the Console: URL with the printed root user and
password. Point S3 clients (including pgBackRest) at the S3 API: URL, or
drive them from the terminal with pg mc.
Re-running install against a live instance is a no-op: the container is not
recreated (a stopped one is simply started, with a notice), the flags are merged
into the stored config, and the existing root password is kept. Pass --force
to recreate the container so changed ports, listen address, endpoint list, or
credentials take effect — without losing the data directory.
Bind address: the default
127.0.0.1keeps the store local.--listen 0.0.0.0(or thelistenkey inpg.yaml) exposes it on the network. Anyone who can reach the port can then attempt the root credentials, so only do this behind a firewall or with TLS (--tls).
TLS (--tls)
--tls makes rustfs serve HTTPS. pgcli generates a self-signed CA and a leaf
cert with the stdlib — SANs cover the loopback names, localhost, and every NIC
IP of the host — into <base_dir>/tls/rustfs/<name>/: rustfs_cert.pem /
rustfs_key.pem (rustfs’s required names, also mirrored as public.crt /
private.key for the CA-friendly tooling) and ca.crt for distribution. The
cert dir is mounted read-only and copied into the container as described in
Privileges and ownership; the endpoint URL becomes
https://.
Why you need it: pgBackRest forces HTTPS for S3 repositories, so a rustfs
meant to receive Patroni archive-push must speak TLS. Point
backup.repo.s3.ca_file at ca.crt and pgBackRest connects with full
certificate verification — see Backup → S3 object storage
repository. Everything that page
says about a MinIO repo applies to a rustfs one: the S3 contract is identical
(path-style, repo1-s3-uri-style=path).
Getting the CA onto a remote host — no scp needed. The server cert is served as a leaf+CA chain, so a consumer on another machine can pull the root straight out of a TLS handshake:
The fetch is trust-on-first-use — compare the printed SHA-256 against the store
host’s sha256sum ~/.pgcli/tls/rustfs/<name>/ca.crt before trusting it.
Bring your own certificate (--tls-cert / --tls-key)
--tls only ever serves pgcli’s own self-signed pair. To serve a certificate you
already hold, pass --tls-cert <leaf(+chain).pem> --tls-key <key.pem>. Together
with --tls (implied), this replaces the generated certs: the two files are
mounted read-only and copied into the container at the names rustfs requires
(/opt/rustfs/certs/rustfs_cert.pem, …/rustfs_key.pem); pgcli never re-owns
them and never writes to their source directory.
No certificate yet? pg cert mints a self-signed leaf with whatever SANs you
name, and rustfs serves it as-is (verified on a real install: the leaf rustfs
presents matches the pg cert output off the live handshake, and pg mc does a
full put/get round-trip against it over TLS). See Self-Cert
for the mint flags, client trust-anchor, and renewal.
Everything else about BYO mode — why renewal needs --force (a single-file
mount pins the source inode), how clients pick their trust anchor, and pg cert
as a test-cert mint — is identical to the MinIO addon; see MinIO → Bring your
own certificate. Turning BYO off: remove
cert_file/key_file under this addon in pg.yaml and recreate with
pg addon install rustfs --name store --force.
Deployment modes
rustfs has three layouts — there is no multi-node single-drive mode:
| Mode | Shape | Use it for |
|---|---|---|
| SNSD (single-node, single-drive) | one node, one data directory — the default when no --endpoint or --drive is given |
dev, test, demos |
| SNMD (single-node, multi-drive) | one node, several drives — --drive per drive, see SNMD |
surviving a disk loss on a single host |
| MNMD (multi-node, multi-drive) | several nodes, several drives each — --drive for this node’s drives + the --endpoint list |
surviving a disk loss and a node loss |
There is deliberately no MNSD (multi-node single-drive) in rustfs. Passing
--endpoint without --drive is rejected at install time: rustfs derives its
per-drive volume range itself, so a distributed node must say how many drives it
has.
rustfs hard-requires distinct physical disks. Under SNMD/MNMD, every
--drivemust sit on its own block device. If two drives share a device (st_dev), the rustfs process[FATAL]s at startup. pgcli launches detached, sopg addon installstill exits 0 and the failure surfaces as a container that never becomes healthy — checkpg logs addon rustfs --name storeif a multi-drive install won’t come up. (This is stricter than MinIO/silo, which only warn.)
Single-Node Multi-Drive (SNMD)
One rustfs process, several host directories, erasure-coded across them. Pass
--drive once per drive instead of --data-dir:
Each --drive is a host directory on its own device; drive N (0-indexed) is
bind-mounted at container path /data/rustfsN, and RUSTFS_VOLUMES is set to
the brace range /data/rustfs{0...3} that the image’s /entrypoint.sh expands
into the individual directories. --drive is mutually exclusive with
--data-dir; combining --drive with --endpoint is MNMD.
pg addon remove rustfs --name store --clean-data deletes each drive directory
— but refuses any drive that is still a mount point, so an accidental
--clean-data can never rm -rf through a live mount into the disk below.
Unmount first if the data below is really disposable.
Distributed / Cluster Mode (MNMD)
Every node runs its own pgcli with its own pg.yaml; each pg.yaml carries the
same full endpoint list and the same root credentials. For rustfs the
--endpoint list is one scheme://host:port per node (no path — rustfs
derives the /data/rustfsN volume range from --drive), and each node passes
its own drives:
Two things matter beyond the identical config: --listen must be 0.0.0.0 (or
the node’s own reachable address), not the loopback default — under podman host
networking the endpoints advertise each node’s real IP, and a node listening
only on 127.0.0.1 can never be joined. And every node must carry the same
endpoint list and root credentials: rustfs derives one shared erasure set from
them, so a mismatch splits the ring into independent standalones.
--endpoint values are scheme://host:port with no path, repeated once per
node (a comma-joined --endpoint a,b,c is equivalent — it is a string-slice
flag). pgcli appends the per-node /data/rustfs{0...N-1} drive range and joins
the four URLs with spaces into a single RUSTFS_VOLUMES. (The
RUSTFS_VOLUMES value itself must be space-joined, not comma-joined: the rustfs
binary mis-splits it, collapsing http:// to http:/ so a node can’t
resolve its own disks — the first-listed node aborts with VolumeNotFound and
the peers hang at waiting for storage_quorum, never forming a writable
cluster. Space-separated literals parse exactly like the documented compact
http://node{1...4}:9000/data/rustfs{0...3} brace form, but let you use plain
per-node IPs with no /etc/hosts or DNS.)
Cross-host MNMD is verified on a real 4-node × 4-drive cluster (Linux, rootful
podman): all four serve /health 200, and an object written once reads back
byte-identical from every node — the erasure shards spread across all 16
drives.
Using the mc Client
pg mc runs MinIO’s mc client in a throwaway container and speaks rustfs’s S3
API like any other store:
The S3 data plane (PUT/GET) is proven end-to-end against rustfs via pgBackRest
and a basic mc round-trip, including a full PITR sequence — base backup,
archive-push of the WAL, and a --time restore that replayed the archived WAL
out of rustfs and stopped exactly at the target (Linux, rootless podman). Note
honestly: the administrative mc surface (alias-set validation, bucket
policies, admin commands) has not been exhaustively exercised against rustfs.
Use it for plain object put/get with the understanding that some
MinIO-specific admin commands may not map onto rustfs.
Ports
Each instance takes two consecutive ports from one pool, starting at
minio_start_port (default 9000): the S3 API first, the console second.
The pool is shared across minio, silo, and rustfs — all three are assigned
from one cursor, so they coexist on a host without collision (minio first by
name, then silo, then rustfs):
Configuration
Instances live under the top-level addons.rustfs map in pg.yaml:
Edits to listen, ports, root_user, root_password, image_tag, data_dir,
tls/cert_file/key_file, or endpoints take effect after the next
pg addon install rustfs --name store --force.
List
pg addon list never prints the password — read it from pg.yaml. Health is on
GET /health (returns 200), unlike MinIO’s /minio/health/live.
Start and stop
install skips a still-present container (starting it if stopped); start only
starts an existing one (and self-heals an improper state by recreating it from
the config). In TLS generated mode start also re-validates and, if needed,
re-signs the leaf.
Auto-start on Boot
Containers carry a --restart unless-stopped policy (crashes, not reboots). To
bring instances up after a host reboot:
rustfs’s fixed container uid is handled inside pgcli’s wrapper image, so a boot-time start needs no special host privileges beyond running podman itself. Boot is start-only; rustfs is independent of the PostgreSQL stack, so it is started last.
Remove
The data directory is the object storage — losing it means losing every
bucket in it — so remove keeps it by default. --clean-data deletes it (and
prunes the now-empty default-layout parent directory). Under rootless podman the
objects are owned by a subordinate uid that plain rm cannot unlink, so
--clean-data transparently falls back to podman unshare rm to reclaim them.
Logs
Troubleshooting
pulling rustfs image ... : ...on install. The wrapper tagghcr.io/mars-base/pgcli/pgcli-rustfs:1.0.0is not reachable — check network access to ghcr.io, or pre-pull it withpodman pull.- A multi-drive install won’t become healthy. rustfs
[FATAL]s when two drives share a physical device. Checkpg logs addon rustfs --name storefor the disk check firing, and make sure each--driveis on its own device (a separate disk or its own loop mount). - Rootless podman can’t write a drive. A drive’s mount point is owned by
root, outside the namespace’s uid range, so the container can’t claim it.
chownthe mount point to the user who runspg. - Port collision on manual
--api-port. The pool is shared across minio/silo/rustfs; an explicit port must be dodged by the auto-assigner for all three. Each store needs a consecutive pair. --clean-dataleaves a directory behind. Under rootless podman a subordinate-uid tree is removed via thepodman unshare rmfallback; if that path is unavailable, remove it yourself withpodman unshare rm -rf <dir>.