Building a Private S3 Storage Server Behind NAT with MinIO, Nginx and SFTPGo
Running a MinIO storage server behind NAT using Proxmox containers and solving FTP passive mode problems with SFTPGo.
Recently I wanted to build a cheap storage infrastructure using only one public IP address.
I already had a VPS running inside a Proxmox container (CT1) and I decided to create another container as a storage server (CT2). The storage server should not have a public IP, so it stays isolated and only accessible through the first server.
The idea was simple:
Internet
│
▼
CT1 (Public VPS)
│
│ Reverse Proxy
▼
CT2 (Private Storage Server)
CT1 will act as:
- reverse proxy
- public entry point
- FTP gateway
CT2 will only run the storage backend.
Creating the storage server (CT2)
On CT2 I installed MinIO to create an S3 compatible storage server.
I created a systemd service for MinIO:
/etc/systemd/system/minio.service
[Unit]
Description=MinIO
Documentation=https://docs.min.io
Wants=network-online.target
After=network-online.target
[Service]
User=minio-user
Group=minio-user
EnvironmentFile=-/etc/default/minio
ExecStart=/usr/local/bin/minio server --address ":9000" --ftp="address=:21" /data --console-address ":9001"
Restart=always
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.targetSince I run MinIO as a non-root user, I had to allow it to bind to port 21:
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/minioAfter starting the service, MinIO listens on:
9000 → S3 API
9001 → Web console
21 → FTPReverse proxy using Nginx (CT1)
On CT1 I already had nginx-proxy running in Docker, so I used it to proxy requests to CT2.
My configuration:
/etc/nginx/conf.d/s3.domain.com.conf
server {
listen 80;
server_name s3.domain.com us-east-1.s3.domain.com *.us-east-1.s3.domain.com;
return 301 https://$host$request_uri;
}MinIO S3 API
server {
listen 443 ssl http2;
server_name us-east-1.s3.domain.com *.us-east-1.s3.domain.com;
ssl_certificate /etc/nginx/certs/domain.com/fullchain.cer;
ssl_certificate_key /etc/nginx/certs/domain.com/domain.com.key;
client_max_body_size 0;
proxy_buffering off;
proxy_request_buffering off;
location / {
proxy_pass http://10.10.10.3:9000;
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_connect_timeout 300;
proxy_http_version 1.1;
}
}MinIO Web Console
server {
listen 443 ssl http2;
server_name console.us-east-1.s3.domain.com;
ssl_certificate /etc/nginx/certs/domain.com/fullchain.cer;
ssl_certificate_key /etc/nginx/certs/domain.com/domain.com.key;
location / {
proxy_pass http://10.10.10.3:9001;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}Now when I open:
console.us-east-1.s3.domain.comI can access the MinIO admin dashboard and manage buckets.
One thing that disappointed me a bit was that MinIO removed several features from the dashboard, including user management.
S3 access
S3 clients can access the storage using:
us-east-1.s3.domain.comor
bucket.us-east-1.s3.domain.comBoth styles are supported by:
- MinIO
- my nginx configuration
Next step: FTP support
Now I needed FTP support.
Some tools (like DirectAdmin) cannot backup to S3-compatible storage, while cPanel supports S3 natively.
So FTP was necessary.
MinIO has a built-in FTP server, so I enabled it by adding these options:
--ftp="address=:21"
--ftp="passive-port-range=30000-40000"Now MinIO listens on:
21 → FTP control
30000-40000 → passive portsNAT problem
Since CT2 is behind NAT, I couldn't directly access these ports.
So I forwarded them from CT1.
Instead of using iptables, I used firewalld with a small Python helper script.
Why?
Because Docker modifies iptables rules automatically.
Every time Docker restarts, it may rewrite iptables chains, which can break manual rules.
Docker itself even recommends using firewalld integration.
Port forwarding script
I wrote a small Python script to automate the rules. before going forward, one small note about why I wrote this in Python.
Python is already installed on most Linux servers by default, so I can run the script immediately without installing anything. That makes it a very convenient choice for quick automation tasks like this.
Of course, the language itself doesn't matter much here. If your server already has another runtime installed, you can use that instead.
In fact, if PHP had already been installed on this server, I probably would have written it in PHP instead. :D
("tcp", "21", "10.10.10.3")
("tcp", "30000-40000", "10.10.10.3")After enabling the rules:
CT1:21 → CT2:21
CT1:30000-40000 → CT2:30000-40000And finally...
FTP worked!
Hooray!
The passive mode problem
But then I faced another issue.
When the FTP client enters passive mode, the server responds with:
Entering Passive Mode (10,10,10,3,a,b)The port is correct (a*b).
But the IP is wrong.
It returns the private IP of CT2:
10.10.10.3Which is not accessible from the internet.
Why this happens
This problem appears when an FTP server runs behind NAT.
Normally FTP servers allow configuring an external passive IP, but unfortunately:
MinIO does not support this option.
Even worse:
The MinIO implementation on Github was archived on Feb 13, 2026, and it is no longer actively maintained.
So there was no way to fix this from configuration.
Later I found a possible workaround for this.
See the update.
The better solution: SFTPGo
But I didn't give up.
Instead, I used a very powerful tool:
SFTPGo
SFTPGo can expose:
- FTP
- FTPS
- SFTP
- Web file manager
And it supports S3-compatible backends.
Perfect.
New architecture
Instead of exposing MinIO FTP, I installed SFTPGo on CT1.
Then I connected it to MinIO using the S3 endpoint:
http://10.10.10.3:9000Now the architecture looks like this:
Internet
│
▼
CT1
├─ nginx (reverse proxy)
├─ SFTPGo (FTP/SFTP)
│
▼
CT2
└─ MinIO (S3 storage)With this setup:
- FTP works correctly
- no NAT problems
- users get a web dashboard
- MinIO stays private
And most importantly:
I no longer need any port forwarding at all.
Final result
I ended up with a fully functional storage platform:
| Component | Role |
|---|---|
| Proxmox | virtualization |
| CT1 | public gateway |
| nginx | reverse proxy |
| SFTPGo | FTP/SFTP access |
| CT2 | private storage server |
| MinIO | S3 backend |
All running with only one public IP address.
Exactly what I wanted. ;)
Update (2 days later)
After publishing this post, I still couldn't accept that MinIO doesn't provide an option to configure the public IP address for FTP passive mode.
So I went back and started digging again. I read the official documentation one more time and even checked the MinIO source code on GitHub again to see if there was any hidden option for this.
Today my best friend (who is also a great programmer) sent me a link to an issue in the MinIO repository that was created on Jun 19, 2023 [minio/minio#17457].
In that issue, one of the MinIO contributors (jiuker) replied with a very short suggestion:
--ftp="address=192.168.0.10:8021" try this args.At first I thought this argument was only meant to bind the FTP server to a specific local IP address. But based on the response from the issue creator, it seems to behave differently.
The issue creator replied:
"Yes! That works like a charm! I would expect it to bind to the tcp/ip stack on that address (which it can't because it's not a local address), but it works!"
So apparently MinIO may use the IP address provided in that argument as the external IP for passive mode, even if that address is not actually assigned to the server.
I haven't tested this yet, and a lot of things may have changed since that issue was posted.
At this point I'm actually happier with the SFTPGo setup and the web panel it provides, so I don't feel the need to switch back.
But if you're trying to run MinIO FTP behind NAT, this option might be worth testing.