I wanted S3-compatible object storage on the same VPS that runs my n8n workflows. MinIO was the obvious choice, and I did not install it: the open-source edition was archived in April 2026 and its Docker images were pulled, so it gets no security patches.
I picked RustFS as the closest replacement. It runs as one container, speaks the S3 API, and has a web console. On my server it uses about 60–90 MB of memory under a 512 MB cap. This post is the deploy script I use, piece by piece.
What the setup looks like
- S3 API at
https://s3.example.com, proxied by nginx to127.0.0.1:9000. - Web console at
https://s3-console.example.com, proxied to127.0.0.1:9001. - Inside Docker, other containers reach it at
http://rustfs:9000over a shared network. My n8n container is on that network, so workflows never leave the server to store a file. - Data on the host at
/srv/rustfs/data, logs at/srv/rustfs/logs, settings in/etc/rustfs.env.
Everything is in one setup.sh that is safe to re-run.
Network, folders and the key pair
The image is pinned to a tag. The network is shared with n8n.
IMG=rustfs/rustfs:1.0.4
NET=automation
# Shared with n8n, so workflows reach it at http://rustfs:9000 without leaving the server
docker network inspect $NET >/dev/null 2>&1 || docker network create $NET >/dev/null
# The container runs as uid 10001; data and logs live on the host
mkdir -p /srv/rustfs/data /srv/rustfs/logs
chown 10001:10001 /srv/rustfs/data /srv/rustfs/logs
The chown matters. The container does not run as root, so the host folders must belong to its user.
The root key pair is generated once and written to an env file that only root can read. The same pair signs S3 requests and logs in to the console.
# Root login (also the S3 access key pair); generated once, changing it later locks out existing clients
if [ ! -f /etc/rustfs.env ]; then
umask 077
cat > /etc/rustfs.env <<EOF
RUSTFS_ACCESS_KEY=$(openssl rand -hex 10)
RUSTFS_SECRET_KEY=$(openssl rand -hex 24)
RUSTFS_ADDRESS=:9000
RUSTFS_CONSOLE_ENABLE=true
RUSTFS_CONSOLE_ADDRESS=:9001
RUSTFS_OBS_LOGGER_LEVEL=error
RUSTFS_OBS_LOG_DIRECTORY=/logs
EOF
umask 022
fi
The if [ ! -f ... ] guard is the important part. Re-running the script must not rotate the keys, because every client that already has them would be locked out.
Running the container
docker pull -q $IMG >/dev/null
# (Re)create the container only when it's missing or running an older image; data and settings persist.
want=$(docker image inspect -f '{{.Id}}' $IMG)
have=$(docker inspect -f '{{.Image}}' rustfs 2>/dev/null || true)
if [ "$want" != "$have" ]; then
docker rm -f rustfs >/dev/null 2>&1 || true
docker run -d --name rustfs --restart unless-stopped --memory 512m \
--network $NET -p 127.0.0.1:9000:9000 -p 127.0.0.1:9001:9001 --env-file /etc/rustfs.env \
-v /srv/rustfs/data:/data -v /srv/rustfs/logs:/logs $IMG >/dev/null
fi
Three choices here:
- Ports bind to
127.0.0.1only. Nothing reaches RustFS from outside except through nginx. --memory 512m. Real use is about 60–90 MB, so the cap is only a safety net for the other services on the box.- Recreate only when the image ID changes. To upgrade, I change the
IMGtag and re-run. One consequence: editing/etc/rustfs.envalone does nothing, because the image has not changed. After an env edit I rundocker rm -f rustfsand then the script again.
The nginx site for the S3 API
# S3 API. Clients sign the Host header, so it must reach RustFS unchanged.
server {
listen 80;
listen [::]:80;
server_name s3.example.com;
client_max_body_size 0;
location / {
proxy_pass http://127.0.0.1:9000;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
What each part is for:
proxy_set_header Host $http_host. S3 request signatures include theHostheader. If nginx rewrites it, the signature no longer matches.client_max_body_size 0and buffering off. There is no upload size limit, and large uploads stream straight to RustFS instead of being spooled by nginx first.- Long timeouts. A big upload or download can run for a while.
The console gets a second server block that proxies to port 9001. That one took two fixes to get right (a redirect and a rate limit), and it is a separate story.
HTTPS, and why the site file is written only once
The script writes the site as plain HTTP, then asks certbot for a certificate, but only if both names already resolve to this server:
if grep -q "listen 443" /etc/nginx/sites-available/s3; then
echo "already has HTTPS"
elif getent ahostsv4 "$API_DOMAIN" | grep -q "$HOST_IP" && getent ahostsv4 "$CONSOLE_DOMAIN" | grep -q "$HOST_IP"; then
certbot --nginx -d "$API_DOMAIN" -d "$CONSOLE_DOMAIN" --non-interactive --agree-tos --register-unsafely-without-email --redirect
else
echo "SKIPPED: $API_DOMAIN and $CONSOLE_DOMAIN must both point to $HOST_IP. Add the DNS A records, then re-run this script."
fi
So the order is flexible. I can run the script before DNS exists, add the two A records, and run it again to get the certificate.
The gotcha: certbot edits the nginx site file to add the listen 443 blocks. If the script rewrote that file on every run, it would wipe HTTPS. So the site is wrapped in if [ ! -f /etc/nginx/sites-available/s3 ] and written once. Later changes to the proxy config go into the live file by hand. Deleting the file and re-running also works, but it drops HTTPS until certbot runs again.
Checks at the end of the script
The script finishes by probing each layer, so a re-run tells me what is broken:
echo "== api local: HTTP $(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9000/health)"
echo "== console local: HTTP $(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9001/rustfs/console/)"
if docker ps --format '{{.Names}}' | grep -qx n8n; then
echo "== n8n -> rustfs over docker net: $(docker exec n8n wget -qO- http://rustfs:9000/health >/dev/null 2>&1 && echo OK || echo UNREACHABLE)"
fi
echo "== mem: $(docker stats --no-stream --format '{{.MemUsage}}' rustfs)"
The n8n line is the one I care about most. It proves the container-to-container path works, which is the path my workflows use.
Client settings
Every client needs the same four things:
- Endpoint:
https://s3.example.com - Region:
us-east-1(any value works) - Path-style addressing: on
- The access key and secret key
Path-style is the one that is easy to forget. With virtual-hosted style, the client puts the bucket name in the hostname, and I only have a DNS record for s3.example.com.
For n8n, I add an S3 credential with endpoint http://rustfs:9000, region us-east-1, Force Path Style on, and the keys. That traffic stays inside the VPS.
For rclone on the server itself:
. /etc/rustfs.env
export RCLONE_CONFIG_S3_TYPE=s3 RCLONE_CONFIG_S3_PROVIDER=Other RCLONE_CONFIG_S3_ENDPOINT=http://127.0.0.1:9000 \
RCLONE_CONFIG_S3_FORCE_PATH_STYLE=true RCLONE_CONFIG_S3_ACCESS_KEY_ID=$RUSTFS_ACCESS_KEY RCLONE_CONFIG_S3_SECRET_ACCESS_KEY=$RUSTFS_SECRET_KEY
rclone lsd S3:
For the AWS CLI:
aws --endpoint-url https://s3.example.com s3 ls
Things I would tell myself before starting
- Do not hand the root pair to apps. The console can create separate access keys and users. I give each app its own key with a policy for its own bucket.
- There is no redundancy. This is one disk with no copies. Anything important needs a backup elsewhere, for example
rclone sync S3:<bucket> <remote>:<path>. - A generated secret is stronger than one you pick. The script generates a long random secret. I later changed the main login to a short one I could remember. That works, but it makes the nginx rate limit on the console the main defence against guessing. A long random secret would be safer.
- Pin the image tag. With a pinned tag and the “recreate only if the image changed” check, re-running the script is boring, which is what I want from storage.
RustFS project page: rustfs.com.