Since September 2026, docker run minio/minio on a host without a cached image ends like this, quoted from ODK Central issue #2255:
docker: Error response from daemon: pull access denied for minio/minio, repository does not exist or may require 'docker login': denied: requested access to the resource is denied
Logging in won't help: Docker Hub no longer serves the repository. So I run RustFS 1.0.4 next to MinIO with Docker Compose, copy the buckets with rclone over the S3 API, verify the copy and switch restic.
What is behind "pull access denied" for minio/minio
Measured on 29 October 2026
Per the Moby source code, "denied" stands for a 401 response from the registry with error="insufficient_scope", which is why the script below shows 401. Affected projects such as Documenso report that minio/minio and minio/mc vanished from Docker Hub on 11 September 2026; in the README and on the release page of minio/minio I found no announcement from MinIO on 29 October 2026. I check the registry directly, without docker pull:
#!/usr/bin/env bash
# regcheck.sh IMAGE [TAG]: HTTP status of the manifest, without docker pull
img="$1"; tag="${2:-latest}"
token=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:${img}:pull" \
| sed -E 's/.*"token":"([^"]+)".*/\1/')
curl -sI -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer ${token}" \
-H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \
"https://registry-1.docker.io/v2/${img}/manifests/${tag}"
$ ./regcheck.sh minio/minio latest
401
$ ./regcheck.sh minio/mc latest
401
$ ./regcheck.sh example/does-not-exist latest
401
$ ./regcheck.sh rustfs/rustfs 1.0.4
200
$ ./regcheck.sh rustfs/rustfs v1.0.4
404
minio/minio gets the same answer as a made-up repository: for both, the anonymous token carries no pull permission (empty access field). A 404, by contrast, means: repository present, wrong tag. dl.min.io answers downloads of the community binaries with 410.
Why keeping it running is not an option
Since 15 October 2025, new Community Edition releases have been source-only; even the last release, with the fix for the privilege escalation CVE-2025-62506 (CVSS 8.1), tells container users to build their own image. Since 12 February 2026 the README has read "THIS REPOSITORY IS NO LONGER MAINTAINED.", and since 25 April 2026 the repository has been archived. dl.min.io rules out further security updates, and AIStor, which MinIO recommends, needs a licence according to its container installation docs, even in the free tier for a single machine.
Where MinIO still runs, the image exists only in the local cache unless you mirrored it to your own registry, and per the Docker docs docker image prune -a removes all images without an associated container, so after a docker compose down this one too. If the storage then fails, the downtime cost calculator estimates the cost. Before any change, I back up the image:
docker image ls minio/minio
docker save -o minio-image.tar minio/minio:<TAG>
# emergency on a new host:
# docker load -i minio-image.tar
Starting RustFS 1.0.4 next to MinIO
Compose service with mandatory credentials
RustFS is an S3-compatible object store in Rust; 1.0.0 came out on 16 September 2026, the current 1.0.4 on 25 October 2026. I add the service to the MinIO project's compose.yaml so both share a network:
services:
# minio: stays unchanged until the migration is verified
rustfs:
image: rustfs/rustfs:1.0.4
restart: unless-stopped
security_opt:
- "no-new-privileges:true"
# enable only once MinIO has released ports 9000/9001
# ports:
# - "127.0.0.1:9000:9000" # S3 API, from outside via reverse proxy
# - "127.0.0.1:9001:9001" # console
environment:
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?set in .env}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:?set in .env}
RUSTFS_CHECK_UPDATE: "false"
volumes:
- rustfs-data:/data
- rustfs-logs:/logs
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# merge with an existing volumes block
volumes:
rustfs-data:
rustfs-logs:
The credentials go into the .env next to compose.yaml (chmod 600):
RUSTFS_ACCESS_KEY=<YOUR_ACCESS_KEY>
RUSTFS_SECRET_KEY=<YOUR_SECRET>
docker compose up -d rustfs
docker compose ps rustfs
${VAR:?...} is deliberate: without credentials, the entrypoint starts with rustfsadmin/rustfsadmin and only logs a warning. RUSTFS_CHECK_UPDATE (default true) contacts version.rustfs.com on every start; I learn about new tags through Diun instead of Watchtower. The image ships no HEALTHCHECK; the test above uses the unauthenticated /health endpoint, the bundled curl and the timings from the project's example file, more in the post on Docker HEALTHCHECK and restart_policy.
UID 10001 and the permission error with bind mounts
If a host directory used instead of a named volume does not belong to UID 10001, startup aborts with this error (RustFS issue #2396):
Server encountered an error and is shutting down: Io error: Permission denied (os error 13)
The container runs as user rustfs with UID and GID 10001, and the entrypoint leaves existing mounts untouched. The fix, before the first start:
mkdir -p ./data ./logs
sudo chown -R 10001:10001 ./data ./logs
# then in compose.yaml: ./data:/data and ./logs:/logs
# alternative per README: user: "<host-uid>:<host-gid>" on the service
The named volumes in the example sit at /data and /logs, which already belong to UID 10001 in the image. The docs examples, however, set RUSTFS_OBS_LOG_DIRECTORY=/var/log/rustfs/, in the docker run example without a mount, which is where issue #6844 failed with "Permission denied". I leave the variable at the image default /logs.
Migrating over the S3 API instead of the file system
Two shortcuts I don't take
The obvious move would be to mount the MinIO volume straight into RustFS. RustFS now labels its March 2026 post on this a historical example and points to its migration guide, revised in October 2026: back up, stop writes, copy over S3, verify, keep MinIO for rollback. On top of that:
- The 1.0.4 README rates compatibility with MinIO's on-disk format as Preview. The interop docs call the route one-way: whatever RustFS has written, MinIO no longer reads.
- No published RustFS image reads MinIO-encrypted objects, and with keys from KES, a KMS plugin or MinKMS not even a custom
rio-v2build does.
The second shortcut, the built-in On-Demand Migration, fetches objects from MinIO only when they are read. MinIO thus stays a dependency until a backfill job has fetched the rest, and by default listings show only local objects, because list_through is off. With rclone, MinIO stays untouched as a fallback.
rclone instead of mc, with two remotes
mc mirror is out: minio/mc is gone from Docker Hub too, and its repository has been archived since 14 July 2026. I use rclone 1.75.2 from 9 October 2026. The remotes use the root credentials of both sides and follow the scheme RCLONE_CONFIG_<NAME>_<OPTION>:
# rclone.env (chmod 600), hostnames = Compose service names
# remote names in variables: uppercase letters, digits and _ only
RCLONE_CONFIG_MINIO_TYPE=s3
RCLONE_CONFIG_MINIO_PROVIDER=Minio
RCLONE_CONFIG_MINIO_ACCESS_KEY_ID=<MINIO_KEY>
RCLONE_CONFIG_MINIO_SECRET_ACCESS_KEY=<MINIO_SECRET>
RCLONE_CONFIG_MINIO_REGION=us-east-1
RCLONE_CONFIG_MINIO_ENDPOINT=http://minio:9000
RCLONE_CONFIG_RUSTFS_TYPE=s3
RCLONE_CONFIG_RUSTFS_PROVIDER=Other
RCLONE_CONFIG_RUSTFS_ACCESS_KEY_ID=<RUSTFS_KEY>
RCLONE_CONFIG_RUSTFS_SECRET_ACCESS_KEY=<RUSTFS_SECRET>
RCLONE_CONFIG_RUSTFS_REGION=us-east-1
RCLONE_CONFIG_RUSTFS_ENDPOINT=http://rustfs:9000
RCLONE_CONFIG_RUSTFS_FORCE_PATH_STYLE=true
The RustFS docs recommend Other with force_path_style = true, because without RUSTFS_SERVER_DOMAINS RustFS only understands path-style.
Copy, write freeze, final sync
Compose names the network <project-name>_default; the example project lives in the folder minio, and /work brings the lists from rclone check to the host. I repeat the first pass for every bucket, here with uploads as the example, and freeze writes only once every bucket has been copied once:
rcl() {
docker run --rm --network minio_default --env-file ./rclone.env -v "$PWD:/work" \
rclone/rclone:1.75.2 "$@"
}
rcl lsd minio:
rcl mkdir rustfs:uploads
# create a bucket with Object Lock (e.g. for restic) like this instead:
# rcl mkdir rustfs:<BUCKET> --s3-bucket-object-lock-enabled
# enable versioning where MinIO had it:
# rcl backend versioning rustfs:<BUCKET> Enabled
# 1. first pass, MinIO stays online (copy never deletes)
rcl copy minio:uploads rustfs:uploads --checksum --metadata -v
# Object Lock buckets: set the mode and duration of the MinIO bucket's
# default retention explicitly, on copy AND sync (see text):
# --s3-object-lock-mode GOVERNANCE --s3-object-lock-retain-until-date 365d
# for objects under legal hold: --s3-object-lock-legal-hold-status copy
# there, sync and the re-copy below also need --s3-bypass-governance-retention
# 2. stop writing services, wait for a final backup,
# then deltas including deletions
docker compose stop <APPLICATION>
sudo systemctl stop restic-backup.timer
# final backup, returns only after it has finished
sudo systemctl start restic-backup.service
# sync and the check below for every bucket, restic included
rcl sync minio:uploads rustfs:uploads --checksum --metadata --dry-run
rcl sync minio:uploads rustfs:uploads --checksum --metadata -v
copy never deletes and may run while MinIO still takes writes. Without --no-block, systemctl start waits until the start-up is completed, and per the systemd docs a oneshot service only counts as started once its process has exited; so the command returns only after the final backup. sync and the check run for every bucket, restic included, otherwise exactly that backup is missing. Deletions are mirrored only by sync after the write freeze, after a --dry-run as the rclone docs advise. --metadata carries user metadata and headers such as Cache-Control, but no retention period.
In Object Lock buckets I don't set --s3-object-lock-retain-until-date to copy: according to its source, RustFS 1.0.4 also rejects a PutObject whose date is not in the future with InvalidArgument ("The retain until date must be in the future"), so a period already expired in MinIO would make the upload fail. I set mode and duration explicitly, at least as strict as the strictest object in the bucket, which rcl lsjson -M -R minio:<BUCKET> shows via object-lock-mode and object-lock-retain-until-date; the period then starts afresh on the day of the copy. Objects in GOVERNANCE mode can be deleted or overwritten by sync and the later re-copy only with --s3-bypass-governance-retention, per the rclone docs.
Verify, switch over, clean up
rclone check and the multipart trap
rcl check minio:uploads rustfs:uploads
# summary reports "N hashes could not be checked": compare byte by byte,
# write the result per path to check.txt
rcl check minio:uploads rustfs:uploads --download --combined /work/check.txt
# copy differing (*), missing on destination (+) and failed (!) objects again
# (another sync --checksum would treat equally sized ones as identical)
grep -E '^[*+!] ' check.txt | sed 's/^. //' > redo.txt
rcl copy minio:uploads rustfs:uploads --files-from-raw /work/redo.txt --ignore-times --metadata -v
# switch over only when check reports "exit=0" and grep prints nothing
rcl check minio:uploads rustfs:uploads --download --combined /work/check.txt; echo "exit=$?"
grep -v '^= ' check.txt
rclone check compares size and MD5. For multipart objects, though, the ETag is no MD5; rclone then knows the hash only from X-Amz-Meta-Md5chksum, which it adds to its own uploads provided the source supplies an MD5. If another client uploaded the object to MinIO, it is missing on both sides, check compares size only and reports "N hashes could not be checked". If N is above zero, I compare byte by byte with --download. --combined marks every path: = identical, * different, + missing on the destination, - only on the destination, ! read or hash error. The objects marked *, + and ! I copy again with --ignore-times, because another sync --checksum would, per the rclone source, treat equally sized objects without a comparable hash as identical. --files-from-raw takes the names unchanged, while --files-from strips leading and trailing whitespace and skips lines starting with # or ;. I switch over only when the second check ends with exit code 0 and returns nothing but = lines, because per the docs and the source rclone reports differences and errors with a non-zero exit code.
Switching restic, removing MinIO
Then I stop MinIO and hand the ports to RustFS:
docker compose stop minio
# compose.yaml: comment out the ports of minio, uncomment those of rustfs;
# change depends_on: minio in the applications to rustfs
docker compose up -d rustfs
I reach the console on port 9001 through an SSH tunnel (ssh -L 9001:127.0.0.1:9001 <USER>@<HOST>), since it only listens on 127.0.0.1. There I recreate users, policies and bucket settings, including a dedicated user for restic, because rclone transfers only objects in their current version. In the EnvironmentFile from the post on encrypted backups with restic to S3 I only change endpoint and keys:
# /etc/restic/env
RESTIC_REPOSITORY=s3:http://127.0.0.1:9000/restic
AWS_ACCESS_KEY_ID=<RESTIC_KEY>
AWS_SECRET_ACCESS_KEY=<RESTIC_SECRET>
RESTIC_PASSWORD=<UNCHANGED>
# as root
set -a; . /etc/restic/env; set +a
restic snapshots
restic check --read-data-subset=10%
systemctl start restic-backup.timer
# applications: endpoint http://rustfs:9000 and new keys, then
docker compose up -d <APPLICATION>
Until the first backup through RustFS and a successful test restore, the way back stays open. A docker compose start minio is not enough, because the existing container still has its bindings to 9000 and 9001; docker compose up -d minio recreates it from the changed compose.yaml without ports. Then the way over runs in reverse: stop the applications and the backup timer, wait for the final backup with systemctl start, for every bucket run rcl sync rustfs:uploads minio:uploads --checksum --metadata first with --dry-run, then for real, bearing in mind that sync deletes from MinIO whatever was deleted on RustFS. After that come check --download, the re-copy and the second check with the remotes swapped, because here too --checksum misses changed multipart objects of equal size. Only when that check ends cleanly do I swap ports, endpoint and keys back. If the migration is confirmed, I remove the minio service, clean up with docker compose up -d --remove-orphans and then delete minio-image.tar, the rclone.env holding both root keys, and the volume.
RustFS, Garage and SeaweedFS compared
Key figures on 29 October 2026
The sizes come from the Docker Hub API (images[].size, linux/amd64, compressed), rounded to whole megabytes.
| RustFS | Garage | SeaweedFS | MinIO Community | |
|---|---|---|---|---|
| Version | 1.0.4 (2026-10-25) | v2.5.0 (2026-10-24) | 4.51 (2026-10-25) | RELEASE.2025-10-15T17-29-55Z, source only |
| Licence | Apache 2.0 | AGPL 3.0 | Apache 2.0 | AGPL 3.0 |
| Docker tag | rustfs/rustfs:1.0.4 |
dxflrs/garage:v2.5.0 |
chrislusf/seaweedfs:4.51 |
no image any more |
| Image amd64, compressed | 115 MB | 28 MB | 197 MB | n/a |
When Garage or SeaweedFS is the better fit
Garage has the smallest image, but its own compatibility list shows no versioning, Object Lock or bucket policies: fine for upload buckets, not for immutable backups. SeaweedFS fits when you need a FUSE mount besides S3, which its filer servers provide. In my view, RustFS comes closest to MinIO with versioning, Object Lock and IAM, but its compatibility matrix, as of 9 August 2026, covers only a tested subset of the S3 API, without ACL authorisation.
Questions about moving off MinIO
Can I fall back to quay.io or a self-built image?
quay.io/minio/minio also returns 401 anonymously. Building it yourself works, but there are no more security updates.
What do I need to watch out for behind a reverse proxy?
Without RUSTFS_SERVER_DOMAINS, clients need path-style, and nginx must pass the Host header on exactly as the client signed it. The nginx guide in the RustFS docs sets proxy_set_header Host $http_host; for this, and a maintainer recommended the same in issue #1184 against SignatureDoesNotMatch. More in the post on nginx as a reverse proxy in a container.
Why 1.0.4 and not latest?
On 29 October 2026 latest points to the same digest as 1.0.4, and with a fixed tag I decide on updates myself. 1.0.1 and older are out: five RustFS security advisories affect 1.0.1 and are fixed as of 1.0.2, among them one with CVSS 8.1 in which WebDAV, FTP and SFTP access bypasses bucket policies. For 1.0.2 through 1.0.4, RustFS lists no open advisory as of 29 October.
Official sources for the migration
RustFS and rclone
All variables and options are documented in the RustFS Docker installation, the environment variables, the S3 compatibility matrix, the rclone S3 backend and rclone check.
MinIO status and alternatives
The archived MinIO repository shows the maintenance notice; Garage's S3 compatibility list and the SeaweedFS repository describe the alternatives. If you'd rather not migrate alone, I can do it as part of my IT support.
Note: The articles on this blog are produced with the help of AI and are editorially reviewed before publication. Editorial responsibility lies with Emre Yurtbay (see the legal notice).