Back to Learnings

Learnings / Building with MinIO

S3 storage on your own NAS.

How we run MinIO on our NAS for documents and backups, how we got the full admin console back, and how each app gets a key that only reaches its own bucket.

The reason

Storage you own can still speak S3.

Our apps store files the way they would in the cloud, through the S3 API. The bucket just sits on a NAS in our office. Each app gets its own bucket and a key that cannot see anything else.

In 2025 MinIO removed user, policy and key management from its free console. We now run the last full version of that console from a community fork, next to the server. It takes two small fixes to build, and we show both below.

01 / The concept

One storage layer for documents, backups and models

Our document app processes scans and invoices on a Mac. Finished documents should move to the NAS instead of filling the Mac's disk. Its nightly backups need a home with their own retention rules. We also keep copies of the language models we run locally, with Model Library, our open-source model archive. That deserves its own post.

Model Library's archive bucket in the restored console: 2.8 TiB in 2,186 objects, one folder per model publisher. Select the image to enlarge it.

All of it goes through MinIO, an S3-compatible object store. The app only knows an endpoint, a bucket and a key. It does not care whether the bucket lives on our NAS or at a cloud provider, and that is the point. If MinIO stops being the right choice, we change an endpoint, not the app.

On the Mac
Document appProcesses scans locally, then archives them to its bucket after 7 days.archiving planned
Nightly backupsDatabase dump and file archives.
Model LibraryKeeps our copies of local language models.
macOS KeychainHolds each app's key. The start command passes it to the app.planned
S3 API requests, signed with the app's own key
Browser to the admin console
Private network only
On the NAS
Docker
MinIO serverAnswers the S3 API and checks every request against the key's policy.
documentsOnly the document app's key.Versioned, old versions removed after 30 days.
documents-backupsAdmin only, the app key cannot reach it.Objects removed 30 days after upload.
model bucketsModel Library's archive of model files.One bucket per model runtime.
↑ admin API on the internal Docker network
OpenMaxIO console v1.7.6Users, policies and access keys. Logs in with its own admin user, never root.
The NAS disks hold the objects. MinIO adds the S3 API and the access rules on top.
Offsite copyWasabi in the cloud, fed from the documents bucket.planned

02 / The problem

The admin pages left the free console

In March 2025 MinIO merged a change that reduced the Community console to an object browser. Users, policies and access keys moved to the mc command line. Our console showed buckets and files. Nothing else.

A community fork, OpenMaxIO, set out to keep the full console. We hit two traps on the way.

The fork's main branch already contains MinIO's change. We built it first and got the same stripped object browser, just with a different logo. The last version with the admin pages is the tag v1.7.6, from February 2025.

That tag no longer builds as it is. Its front end pulls MinIO's design library from github.com/minio/mds, and that repository is gone. OpenMaxIO keeps a copy at github.com/openmaxio/mds with the exact commit v1.7.6 expects, so one line in the build points the dependency there.

A smaller one cost us a build too. Yarn 4 could not clone that dependency inside the Alpine-based Node image and failed with invalid key: core.autocrlf. The Debian-based image works.

03 / How it works

One console, one key per app, nothing deleted unchecked

The full console from v1.7.6

We build the console from source in a container. The two fixes are the base image and the redirected dependency.

FROM node:20-bookworm-slim AS web
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates
WORKDIR /src
RUN git clone https://github.com/OpenMaxIO/openmaxio-object-browser . && git checkout v1.7.6
WORKDIR /src/web-app
RUN sed -i 's#github.com/minio/mds.git#github.com/openmaxio/mds.git#g' package.json yarn.lock
RUN corepack enable && yarn install && yarn build

FROM golang:1.23-alpine AS go
COPY --from=web /src /src
WORKDIR /src
RUN CGO_ENABLED=0 go build -trimpath --tags=kqueue -o /console ./cmd/console

FROM alpine:3.20
COPY --from=go /console /usr/local/bin/console
ENTRYPOINT ["console", "server", "--port", "9090"]

The console runs as its own container on MinIO's Docker network and talks to the S3 API internally. MinIO itself stays as it was. We log in with a separate admin user, not the root account, so the root credentials never pass through the console.

The console from v1.7.6 with the administrator menu back: buckets, policies, identity, access keys and configuration. Select the image to enlarge it.

One bucket, one user, one key per app

For the document app we wrote a policy that allows listing one bucket and reading, writing and deleting objects inside it.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:GetBucketLocation"],
      "Resource": ["arn:aws:s3:::documents"]
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": ["arn:aws:s3:::documents/*"]
    }
  ]
}

A user gets that policy, and the app gets an access key belonging to that user. Before handing the key over we tested it both ways. It lists, writes, reads and deletes in its bucket. Listing another bucket, calling the admin API and creating a bucket all return Access Denied.

Test the denials. A key that can do everything passes the positive test too.

Versioning instead of a cleanup script

With versioning on, MinIO keeps the previous version when the app overwrites or deletes a file. A lifecycle rule removes those old versions after 30 days, so the bucket does not grow forever.

Backups go to a second bucket that the app key cannot reach, with a rule that deletes objects 30 days after upload. A bug in the app, or a leaked app key, cannot delete its own backups.

mc version enable nas/documents
mc ilm rule add nas/documents --noncurrent-expire-days 30
mc ilm rule add nas/documents-backups --expire-days 30

Keys in a file nobody reads

While setting this up we found the keys for the app's offsite backup to Wasabi. They had been in .env.local since June. Docker Compose only reads .env, and the compose file never passed them to the containers. The backup job existed and looked configured, and it had never uploaded a single document.

So the MinIO key went into the macOS Keychain instead of a file. Containers cannot read the Keychain, so the next step is a start command that reads the key and hands it to the containers as environment variables. That part is not built yet.

Read it back before you delete

We moved 17 GB of the app's backups into the backup bucket. Before deleting a local copy, we downloaded each file again and compared SHA-256 checksums. All 42 files matched.

One trap if you take a shortcut. For files uploaded in parts, the ETag MinIO reports is not the MD5 of the file. Comparing ETags with local MD5 sums flags those files as different even when they are identical.

The risk we accept

v1.7.6 is from February 2025, and the fork has not moved since June 2025. We get no new console features, so the console stays on our private network and never faces the internet.

The server has the same problem. MinIO archived its open-source repository in April 2026. The last release, from October 2025, fixes a high-severity privilege escalation for access keys, and no further fixes will come. Run that release, not an older one.

Build the last server release yourself

MinIO no longer publishes images, so we build that release from its source tag, the same way as the console. Copy the environment defaults from MinIO's own Dockerfile.release at that tag, so the server behaves as before. Back up .minio.sys in the data directory first. It holds users, policies and keys.

FROM golang:1.24-alpine AS build
RUN apk add --no-cache git
WORKDIR /src
RUN git clone --depth 1 --branch RELEASE.2025-10-15T17-29-55Z https://github.com/minio/minio .
RUN CGO_ENABLED=0 go build -tags kqueue -trimpath -ldflags "$(go run buildscripts/gen-ldflags.go)" -o /minio

FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=build /minio /usr/bin/minio
COPY --from=build /src/dockerscripts/docker-entrypoint.sh /usr/bin/docker-entrypoint.sh
ENTRYPOINT ["/usr/bin/docker-entrypoint.sh"]
CMD ["minio"]

One trap along the way. We had set a custom region on the server, and the console could no longer log in. The server answered with the region is wrong; expecting 'us-east-1', although it expected our custom region. With a region set, MinIO only accepts requests signed for that region. Leave it empty unless you need it, or set CONSOLE_MINIO_REGION on the console to match.

We accept this for now because our apps only use the S3 API. If we have to leave, Garage and SeaweedFS speak the same API, and the apps keep their code.

04 / Installation

Run the console next to your MinIO

You need MinIO in Docker, the mc client, and 10 to 15 minutes for the first build.

  1. Create an admin user for the console with a policy that allows admin:* and s3:*.
  2. Build the image from the Dockerfile above and start it on MinIO's Docker network, with CONSOLE_MINIO_SERVER, CONSOLE_PBKDF_PASSPHRASE and CONSOLE_PBKDF_SALT set.
  3. For each app, create a policy for its bucket, a user with that policy and an access key. Test what the key may do and what it may not.
  4. Turn on versioning and add lifecycle rules.
services:
  console:
    build: .
    environment:
      CONSOLE_MINIO_SERVER: http://minio:9000
      CONSOLE_PBKDF_PASSPHRASE: ${CONSOLE_PBKDF_PASSPHRASE}
      CONSOLE_PBKDF_SALT: ${CONSOLE_PBKDF_SALT}
    ports: ["9102:9090"]
    networks: [minio]
networks:
  minio: { external: true, name: minio_default }
mc admin policy create nas consoleAdmin console-admin.json
mc admin user add nas console-admin "$CONSOLE_ADMIN_PASSWORD"
mc admin policy attach nas consoleAdmin --user console-admin

Delegate this guide

Give this to your agent

Copy this prompt into your coding agent. It should check the prerequisites, carry out this guide and report what it verified.

Help me run the OpenMaxIO admin console next to my existing MinIO and give one app its own bucket and key, as described in this article.

First check that MinIO already runs in Docker, find its Docker network and confirm that the mc client reaches it with an admin account. If there is no MinIO yet, stop and tell me. This guide does not cover a fresh install, because MinIO's open-source project is archived. If the server is older than RELEASE.2025-10-15T17-29-55Z, tell me before going further. Do not change the MinIO server or its data.

Build OpenMaxIO from the tag v1.7.6 with the two fixes from the article: a Debian-based Node image, and the mds dependency redirected to github.com/openmaxio/mds. Do not build the main branch, it lacks the admin pages. Generate CONSOLE_PBKDF_PASSPHRASE and CONSOLE_PBKDF_SALT, keep them in a local .env that stays out of git, and start the console on MinIO's Docker network. Create a separate console admin user instead of using the root account.

Then ask me which app and bucket to set up. Create a policy limited to that bucket, a user with that policy and an access key for the user. Test the key both ways. It must list, write, read and delete a test object in its bucket, and it must be denied on another bucket, on the admin API and when creating a bucket. Remove the test object afterwards.

Ask before enabling versioning, adding lifecycle rules or deleting anything. Never print secrets in full, and store them where I tell you. Report the console URL, what passed, what failed and how to stop the console.

05 / Takeaways

Why you might care

If you already run MinIO

The admin pages are not gone for good. OpenMaxIO v1.7.6 brings back users, policies and access keys, once you apply the two fixes above. Move your server to the last MinIO release from October 2025 as well. Nothing newer will come.

If you build systems that produce a lot of data

You know how it goes. Generated files pile up on the laptop until the disk is full, and every project invents its own way to copy them somewhere else. An S3 store on your NAS gives every project the same interface. The code talks to a bucket with a key, and versioning and expiry become settings instead of scripts.

For a new setup today we would look at Garage or SeaweedFS first, because MinIO's open-source project is archived. The code you write against the S3 API stays the same either way.