Replace MinIO with SeaweedFS on K8S S3 storage

Introduction
SeaweedFS is a distributed object store and file system that can expose an S3-compatible API. It is useful when you want to replace MinIO with lighter self-hosted object storage for applications such as photo backups, static hosting, or internal services that expect S3.
The reason to move away from MinIO is simple: the public minio/minio repository is archived and read-only, so I treat it as no longer maintained for new self-hosted deployments. SeaweedFS gives me an S3-compatible replacement that still fits well into Kubernetes and GitOps.
In this guide, we will deploy SeaweedFS on Kubernetes with the SeaweedFS operator, Longhorn-backed persistent volumes, cert-manager TLS, an external S3 endpoint, buckets, scoped S3 credentials, and a weekly maintenance job.
The SeaweedFS Kubernetes setup below is designed for a small/medium production cluster where applications need a private S3-compatible storage service.
The setup uses the SeaweedFS custom resources directly instead of a large values file. That makes the moving pieces explicit: Seaweed, Bucket, S3Identity, S3Credentials, S3Policy, S3PolicyBinding, and AdminScript.
Prerequisites
- A running Kubernetes cluster.
kubectlaccess to the cluster.- Kustomize support through
kubectl apply -kor Flux. - cert-manager with a
ClusterIssuernamedletsencrypt. - An Ingress controller. The example uses
cilium. - A storage class for SeaweedFS data volumes. The example uses
longhorn-single. - A ReadWriteMany storage class for the master metadata PVC. The example uses
longhorn-rwx. - DNS pointing
s3.example.comto your Ingress endpoint. - Sealed Secrets and
kubesealif you want to seal database credentials for an external filer metadata store.
Step-by-step
-
Create a working directory:
bashmkdir -p seaweed cd seaweed -
Install the SeaweedFS operator. The
Seaweedand S3 custom resources used later are reconciled by this operator:yaml--- apiVersion: v1 kind: Namespace metadata: name: seaweedfs-operator --- apiVersion: source.toolkit.fluxcd.io/v1 kind: HelmRepository metadata: name: seaweedfs-operator namespace: seaweedfs-operator spec: interval: 1h url: https://seaweedfs.github.io/seaweedfs-operator/ --- apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: name: seaweedfs-operator namespace: seaweedfs-operator spec: interval: 1h chart: spec: chart: seaweedfs-operator version: '0.1.36' sourceRef: kind: HelmRepository name: seaweedfs-operator namespace: seaweedfs-operatorIf you are not using Flux, install the operator with Helm instead:
bashhelm repo add seaweedfs-operator https://seaweedfs.github.io/seaweedfs-operator/ helm repo update helm upgrade --install seaweedfs-operator seaweedfs-operator/seaweedfs-operator \ --namespace seaweedfs-operator \ --create-namespace \ --version 0.1.36 -
Create
namespace.yaml:yamlapiVersion: v1 kind: Namespace metadata: name: seaweed -
Create
cert.yaml. This certificate is used by the S3 Ingress:yaml--- apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: seaweed namespace: seaweed spec: secretName: seaweed-tls issuerRef: name: letsencrypt kind: ClusterIssuer dnsNames: - s3.example.com -
Create
seaweed.yaml. This manifest defines the SeaweedFS cluster itself:yaml--- apiVersion: seaweed.seaweedfs.com/v1 kind: Seaweed metadata: name: seaweedfs namespace: seaweed spec: image: chrislusf/seaweedfs:4.40 volumeServerDiskCount: 1 master: replicas: 3 affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchExpressions: - key: app.kubernetes.io/component operator: In values: - master - key: app.kubernetes.io/instance operator: In values: - seaweedfs topologyKey: kubernetes.io/hostname volumes: - name: master-data persistentVolumeClaim: claimName: seaweedfs-master-data volumeMounts: - name: master-data mountPath: /data extraArgs: - -mdir=/data/$POD_NAME volumeSizeLimitMB: 1024 volume: storageClassName: longhorn-single replicas: 5 affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchExpressions: - key: app.kubernetes.io/component operator: In values: - volume - key: app.kubernetes.io/instance operator: In values: - seaweedfs topologyKey: kubernetes.io/hostname requests: storage: 750Gi filer: replicas: 1 persistence: enabled: true storageClassName: longhorn resources: requests: storage: 5Gi config: | [filer.options] defaultReplicaPlacement = "002" [leveldb2] enabled = true dir = "/data/filerldb2" s3: replicas: 1 ingress: enabled: true className: cilium host: s3.example.com tls: - hosts: - s3.example.com secretName: seaweed-tls admin: {} worker: replicas: 2 jobType: "all" maxExecute: 4 persistence: enabled: true resources: requests: storage: 1Gi --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: seaweedfs-master-data namespace: seaweed spec: accessModes: - ReadWriteMany storageClassName: longhorn-rwx resources: requests: storage: 30GiThe important values are:
master.replicas: 3gives the cluster a quorum-based control plane.volume.replicas: 5creates five volume-server pods.volume.requests.storage: 750Giis the size of each volume server PVC.defaultReplicaPlacement = "002"makes new writes use three copies.s3.ingress.hostexposes the S3 API outside the cluster.
This example uses
leveldb2for filer metadata. That is simple, but it is local to the filer pod. For a highly available filer metadata store, use PostgreSQL, MySQL, Redis, or another external metadata backend supported by SeaweedFS. -
If you want to use MySQL or Galera for filer metadata, create a sealed database secret first:
bashexport MYSQL_PASSWORD=FROM_DATABASE_SECRET kubectl -n seaweed create secret generic seaweedfs-db-secret \ --from-literal=user=seaweed \ --from-literal=password=$MYSQL_PASSWORD \ --dry-run=client -o yaml | kubeseal --format yaml > seaweedfs-db-secret.yamlThen update the filer config in
seaweed.yamlto use your external metadata store instead ofleveldb2. -
Create
buckets.yaml. This creates a few buckets:yaml--- apiVersion: seaweed.seaweedfs.com/v1 kind: Bucket metadata: name: ente namespace: seaweed spec: clusterRef: name: seaweedfs namespace: seaweed --- apiVersion: seaweed.seaweedfs.com/v1 kind: Bucket metadata: name: media namespace: seaweed spec: clusterRef: name: seaweedfs namespace: seaweed -
Create an admin S3 identity in
seaweed.creds.yaml. This identity can access every bucket:yaml--- apiVersion: seaweed.seaweedfs.com/v1 kind: S3Identity metadata: name: seaweed namespace: seaweed spec: seaweedRef: name: seaweedfs --- apiVersion: seaweed.seaweedfs.com/v1 kind: S3Credentials metadata: name: seaweed-creds namespace: seaweed spec: seaweedRef: name: seaweedfs identityRef: name: seaweed secretRef: name: seaweed-s3-secret --- apiVersion: seaweed.seaweedfs.com/v1 kind: S3Policy metadata: name: rw-all namespace: seaweed spec: seaweedRef: name: seaweedfs statements: - effect: Allow actions: - "*" resources: - "*" --- apiVersion: seaweed.seaweedfs.com/v1 kind: S3PolicyBinding metadata: name: seaweed-binding namespace: seaweed spec: seaweedRef: name: seaweedfs policyRef: name: rw-all subjects: - kind: S3Identity name: seaweed -
Create an application-scoped identity in
app.creds.yaml. This identity can only access theentebucket:yaml--- apiVersion: seaweed.seaweedfs.com/v1 kind: S3Identity metadata: name: ente namespace: seaweed spec: seaweedRef: name: seaweedfs --- apiVersion: seaweed.seaweedfs.com/v1 kind: S3Credentials metadata: name: ente-creds namespace: seaweed spec: seaweedRef: name: seaweedfs identityRef: name: ente secretRef: name: ente-s3-secret --- apiVersion: seaweed.seaweedfs.com/v1 kind: S3Policy metadata: name: rw-ente namespace: seaweed spec: seaweedRef: name: seaweedfs statements: - effect: Allow actions: - "*" resources: - ente - ente/* --- apiVersion: seaweed.seaweedfs.com/v1 kind: S3PolicyBinding metadata: name: ente-binding namespace: seaweed spec: seaweedRef: name: seaweedfs policyRef: name: rw-ente subjects: - kind: S3Identity name: enteThe operator writes the generated access key and secret key into
ente-s3-secret. Use that Secret when configuring applications such as Ente Photos. -
Create
admin.yaml. This runs a weekly balance and replication repair throughweed shell:yamlapiVersion: seaweed.seaweedfs.com/v1 kind: AdminScript metadata: name: weekly-balance namespace: seaweed spec: clusterRef: name: seaweedfs schedule: "0 2 * * 1" script: | lock volume.balance -force volume.fix.replication unlock -
Create
kustomization.yaml:yamlapiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - admin.yaml - buckets.yaml - cert.yaml - namespace.yaml - seaweed.yaml - seaweed.creds.yaml - app.creds.yaml -
Deploy SeaweedFS:
bashkubectl apply -k . -
Verify the operator reconciled the cluster:
bashkubectl -n seaweed get seaweed kubectl -n seaweed get pods kubectl -n seaweed get pvc kubectl -n seaweed get ingress kubectl -n seaweed get buckets kubectl -n seaweed get s3identities,s3credentials,s3policies,s3policybindingsCheck generated S3 credentials:
bashkubectl -n seaweed get secret seaweed-s3-secret -o yaml kubectl -n seaweed get secret ente-s3-secret -o yaml -
Test the S3 endpoint with an S3 client. Export the generated keys from the Secret, then point the client at your S3 host:
bashexport AWS_ACCESS_KEY_ID=REPLACE_WITH_ACCESS_KEY export AWS_SECRET_ACCESS_KEY=REPLACE_WITH_SECRET_KEY export AWS_EC2_METADATA_DISABLED=true aws --endpoint-url https://s3.example.com s3 ls aws --endpoint-url https://s3.example.com s3 cp ./test.txt s3://ente/test.txt aws --endpoint-url https://s3.example.com s3 rm s3://ente/test.txt
Conclusion
You now have SeaweedFS running on Kubernetes with persistent Longhorn storage, a public S3 endpoint, cert-manager TLS, managed buckets, scoped S3 credentials, lifecycle rules, and a scheduled maintenance script.
Before storing important data, test uploads and downloads, check replication with volume.fix.replication, and make sure your backup plan covers both the object data and the filer metadata backend.
If you found this useful, you can buy me a coffee! Thanks for the support!