如何在 staging 服务器上自建 S3 兼容对象存储(使用 MinIO)并每月节省数百美元

TL;DR · AI 摘要
通过在 staging 环境中自建 MinIO 对象存储,可完全替代 AWS S3/Cloudflare R2 等云服务,节省每月数百美元成本,同时保持与生产环境一致的 S3 API 接口和上传逻辑。
核心要点
- 使用 Docker Compose 部署 MinIO 并配合 Traefik 实现 HTTPS 和自定义域名,成本为 $0/GB。
- 通过环境变量切换 S3_ENDPOINT、S3_ACCESS_KEY 等配置,实现生产与 staging 存储无缝切换,无需修改应用代码。
- MinIO 支持预签名 URL、mc 命令行工具和 aws s3 CLI,兼容主流 SDK,确保测试环境与生产行为一致。
结构提纲
按章节快速跳转。
staging 环境频繁上传测试文件导致云存储费用浪费,自建 MinIO 可避免此问题并降低成本。
生产使用 AWS S3 或 Cloudflare R2,staging 使用本地 MinIO,两者均支持 S3 API,应用代码无需更改。
通过 Docker Compose 部署 MinIO,并用 Traef克 提供 HTTPS 和自定义域名支持。
仅需修改 S3_ENDPOINT、S3_REGION、S3_ACCESS_KEY 等环境变量即可切换存储后端。
自建 MinIO 每 GB 成本为 $0,提供生产级测试体验,且无供应商锁定风险。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- 自建 S3 兼容对象存储
- 动机
- 降低 staging 存储成本
- 避免测试数据浪费
- 架构设计
- 生产:AWS S3 / Cloudflare R2
- staging:MinIO + Docker + Traefik
- 技术实现
- Docker Compose 部署
- HTTPS 与自定义域名
- 环境变量切换
金句 / Highlights
值得收藏与分享的关键句。
MinIO 是一个免费开源的 S3 兼容对象存储服务器,支持相同的 API、SDK、预签名 URL 和 mc/aws s3 CLI 工具。
staging 环境中每次测试上传都消耗真实云存储费用,而自建 MinIO 可将该成本降至零。
通过环境变量切换存储后端,应用代码无需任何修改,即可在生产与 staging 之间无缝迁移。
MinIO 部署在 VPS 上,通过 Docker Compose 和 Traefik 实现 HTTPS 和自定义域名,适合轻量级 staging 场景。

本文是一份完整的复制粘贴指南,教你如何仅使用 Docker Compose,在 Traefik 后端运行带 HTTPS、自定义域名和预签名上传/下载 URL 的 MinIO。
你的生产环境将继续使用托管的 S3 / Cloudflare R2 / Hetzner 对象存储,而所有预发布环境的上传、下载和预签名 URL 都将免费指向你自己的服务器。
目录
1. 为什么要在预发布环境中自托管对象存储?
如果你的应用处理文档——PDF 文件、头像图片、应用记录、录音等——那么每次 QA 团队进行测试上传,都会在 AWS S3、Cloudflare R2 或 Hetzner 对象存储中产生真实费用。虽然单个文件的价格不高,但预发布环境是:
- 运行自动化端到端测试,上传成千上万的虚拟文件,
- 每晚重置数据库(留下孤立的对象),
- 允许开发者用有缺陷的代码反复上传相同文件,
- 并长期保留无人删除的数月测试数据。
在生产环境中,这些成本是合理的。托管存储为你提供复制、可用性和他人维护的运维支持。但在预发布环境中,这些成本纯粹是浪费。
**MinIO** 是一个免费、开源、兼容 S3 的对象存储服务器。它拥有相同的 API、SDK、预签名 URL,以及相同的 mc/aws s3 命令行工具 —— 但运行在你自己的 VPS 上,每 GB 费用为 $0。将你的预发布应用指向 MinIO,生产应用指向 S3/R2,唯一需要更改的就是一个环境变量。
结果: 两个环境中的代码路径完全一致,预发布环境无存储账单,并且在云服务商出现故障时还能作为良好的备用方案。
2. 架构设计:生产 vs. 预发布
在实际应用中,通常不希望开发或预发布环境直接写入生产存储。
一种常见且经济高效的配置是:
- 生产环境:托管的云对象存储
- 预发布 / 开发环境:自托管的兼容 S3 的存储
好处在于,你的应用程序代码无需修改。
只要两个服务都兼容 S3,相同的 SDK 和上传逻辑即可在任何地方工作。唯一的区别是环境变量不同。
高层架构图

上图展示了同一应用程序如何根据部署环境与不同的存储提供商通信。
在 生产环境 中,上传内容存储于托管的对象存储服务(如 AWS S3、Cloudflare R2 或 Hetzner 对象存储)。这些服务负责持久性、可扩展性、备份和基础设施管理。
在 预发布环境 中,上传内容被定向到运行在 VPS 上 Docker 容器内的自托管 MinIO 实例。MinIO 实现了 S3 API,使其行为类似于生产存储,同时保持低成本。
由于两种存储系统均兼容 S3,应用程序在每个环境中使用相同的上传逻辑。唯一的区别是通过环境变量提供的配置。
为什么这种架构有用
该架构为你带来:
- 经济实惠的预发布环境
- 类似生产的测试体验
- 无存储供应商锁定
- 无需重写应用代码即可切换存储提供商
因为两个环境都使用 S3 协议,你的上传逻辑保持一致。
示例环境变量
你的应用程序只需读取如下环境变量:
S3_ENDPOINT=
S3_REGION=
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET=只需更改这些值,完全相同的应用程序就能将文件上传到不同的后端。
生产存储示例
在生产环境中,通常使用托管的对象存储服务,例如:
- AWS S3
- Cloudflare R2
- Hetzner 对象存储
示例:
S3_ENDPOINT=https://<region>.r2.cloudflarestorage.com其优势在于高度可扩展、全球可用、高持久性、具备托管备份,且无需自行维护基础设施。
预发布环境示例
对于预发布环境,轻量级的自托管 MinIO 容器通常就足够了。
Next.js 应用
↓
MinIO 容器(运行在 VPS 上的 Docker 中)示例域名:
| 服务 | 域名 | 内部端口 | | --- | --- | --- | | MinIO S3 API | `minio-staging.domain.com` | 9000 | | MinIO Web 控制台 | `minio-console-staging.domain.com` | 9001 |
这允许你:
- 安全地测试上传功能
- 避免生产存储费用
- 在本地重现类似生产的行为
3. 前提条件
你需要:
- 一台 Linux VPS(Hetzner、DigitalOcean、Contabo、OVH 等,需具备公网 IP)。
- 两个指向该 IP 的 A 记录(我们稍后会注册它们)。
- Docker + Docker Compose v2。
- Traefik v2 作为前端反向代理,已配置 Let's Encrypt(任何反向代理均可,以下标签为 Traefik 特定格式)。
- 防火墙开放
80和443端口,用于 Let's Encrypt 和 HTTPS。
- 至少 10 GB 的空闲磁盘空间用于 MinIO 数据卷。
如果尚未安装 Docker:
curl -fsSL https://get.docker.com | sh
sudo apt-get install -y docker-compose-plugin
docker --version && docker compose version4. 第一步 — DNS:将域名指向预发布服务器
在你的 DNS 提供商(Cloudflare、Route 53、Namecheap 等)中,创建两个指向预发布服务器公网 IP 的 A 记录:
minio-staging.domain.com A 203.0.113.45
minio-console-staging.domain.com A 203.0.113.45如果你使用 Cloudflare,请将 minio-staging.* 的代理状态设置为 仅 DNS(灰色云)。Cloudflare 免费计划上传限制为 100 MB,且会剥离 S3 签名头,因此不建议启用代理。控制台子域名可以保留代理,以便在前端添加 WAF。
等待片刻并验证:
dig +short minio-staging.domain.com
# 203.0.113.455. 第二步 — 使用 Docker Compose 运行 MinIO
将以下服务添加到你的预发布环境的 compose 文件(docker-compose.staging.yml)中。MinIO 只是一个容器,数据通过 Docker 卷挂载,以确保升级后数据不会丢失。
# docker-compose.staging.yml
networks:
proxy:
external: true
name: proxy
internal:
name: internal
volumes:
minio-data:
services:
minio:
image: minio/minio:latest
container_name: minio-staging
restart: unless-stopped
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-admin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-change-me-please}
# 告诉 MinIO 使用哪个公共域名来签名 URL
- MINIO_SERVER_URL=https://minio-staging.domain.com
- MINIO_BROWSER_REDIRECT_URL=https://minio-console-staging.domain.com
command: server /data --console-address ":9001"
volumes:
- minio-data:/data
networks:
- proxy
- internal
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web 控制台
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s有两个关键点需要注意:
MINIO_SERVER_URL是核心配置。如果没有设置,MinIO 会使用内部主机名(如http://minio:9000)生成预签名 URL,当浏览器访问公共域名时会导致验证失败。请将其设置为客户端实际使用的 HTTPS 地址。
MINIO_BROWSER_REDIRECT_URL对 Web 控制台同样重要(用于登录重定向、OIDC 回调等)。
启动服务:
docker compose -f docker-compose.staging.yml up -d minio
docker compose -f docker-compose.staging.yml logs -f minio你应该能看到类似 API: http://... 和 Console: http://... 的输出。
6. 第三步 — 通过 Traefik 以 HTTPS 暴露 MinIO
我们不会直接暴露端口 9000/9001 到公网,而是由 Traefik 来完成,它会使用免费的 Let's Encrypt 证书终止 TLS。
为 minio 服务添加以下标签:
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
# ---- S3 API (端口 9000) ----
- "traefik.http.routers.minio-staging.rule=Host(`minio-staging.domain.com`)"
- "traefik.http.routers.minio-staging.entrypoints=websecure"
- "traefik.http.routers.minio-staging.tls.certresolver=letsencrypt"
- "traefik.http.routers.minio-staging.service=minio-staging"
- "traefik.http.services.minio-staging.loadbalancer.server.port=9000"
# ---- Web 控制台 (端口 9001) ----
- "traefik.http.routers.minio-console-staging.rule=Host(`minio-console-staging.domain.com`)"
- "traefik.http.routers.minio-console-staging.entrypoints=websecure"
- "traefik.http.routers.minio-console-staging.tls.certresolver=letsencrypt"
- "traefik.http.routers.minio-console-staging.service=minio-console-staging"
- "traefik.http.services.minio-console-staging.loadbalancer.server.port=9001"你还需要配置一个 entryPoint 监听 :443,以及一个名为 letsencrypt 的 certificatesResolver。以下是最低限度的 Traefik 配置文件(traefik.staging.yml):
api:
dashboard: true
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
certificatesResolvers:
letsencrypt:
acme:
httpChallenge:
entryPoint: web
email: admin@domain.com
storage: /etc/traefik/acme.json
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: proxy重启并观察证书是否成功签发:
docker compose -f docker-compose.staging.yml up -d
docker compose -f docker-compose.staging.yml logs -f traefik | grep -i acme从你的笔记本电脑进行简单测试:
curl -I https://minio-staging.domain.com/minio/health/live
# HTTP/2 200现在你可以通过 https://minio-console-staging.domain.com 登录 Web 控制台,用户名为 admin,密码为 change-me-please。
重要上传大小调整:如果你在 Traefik 前面使用了 Cloudflare 或 NGINX,需要提高请求体大小限制。Traefik 本身没有默认限制,但 Cloudflare 免费版拒绝超过 100 MB 的请求。对于自托管边缘代理,可设置 client_max_body_size 0;(NGINX)或等效配置。
7. 第四步 — 创建存储桶和访问密钥
任何支持 S3 协议的工具都可以与 MinIO 通信。最简单的工具是 mc(官方 MinIO 客户端),它已包含在镜像中。
7.1 连接 mc 到你的服务器
docker exec -it minio-staging \
mc alias set local http://localhost:9000 admin change-me-please7.2 创建存储桶
docker exec -it minio-staging mc mb local/domain-files-staging7.3 选择存储桶策略
你有三种策略可选,根据存储内容选择:
| 策略 | 使用场景 | | ---------- | -------- | | private(默认) | 敏感数据 — 学生成绩单、合同、内部文档。只能通过预签名 URL 读取。 | | download | 公共读取,禁止列表。适用于 CDN 类型资源,如头像。 | | public | 任何人都可以读取和列出。仅用于真正公开的内容。 |
设置其中一个策略:
私有(推荐用于文档)
docker exec -it minio-staging \ mc anonymous set none local/domain-files-staging
或仅对静态资源开放公共读取权限:
docker exec -it minio-staging \ mc anonymous set download local/domain-files-staging
### 7.4 创建专用应用用户(不要使用 root 密钥!)
`admin` 账户可以删除所有内容。为你的应用创建一个最小权限的用户:
docker exec -it minio-staging mc admin user add local \ domain-app a-long-random-secret-key
附加内置的读写策略,通过 JSON 限定到单个 bucket:
cat > /tmp/policy.json <<'EOF' { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:*"], "Resource": [ "arn:aws:s3:::domain-files-staging", "arn:aws:s3:::domain-files-staging/*" ] } ] } EOF
docker cp /tmp/policy.json minio-staging:/tmp/policy.json docker exec -it minio-staging \ mc admin policy create local domain-rw /tmp/policy.json docker exec -it minio-staging \ mc admin policy attach local domain-rw --user domain-app
保存这两个值 —— 它们就是你的 `S3_ACCESS_KEY` 和 `S3_SECRET_KEY`。
## 8. 第5步 — 配置应用仅在预发环境使用 MinIO
实现“预发环境用 MinIO,生产环境用真实 S3”的关键在于:在代码中使用**相同的 S3 客户端**,仅切换环境变量。
你的 `staging.env`(由预发环境的 compose 栈加载):
---- 预发环境:自托管 MinIO ----
STORAGE_ENABLED=true S3_ENDPOINT=https://minio-staging.domain.com S3_PUBLIC_ENDPOINT=https://minio-staging.domain.com S3_BUCKET=domain-files-staging S3_ACCESS_KEY=domain-app S3_SECRET_KEY=a-long-random-secret-key S3_REGION=us-east-1 S3_FORCE_PATH_STYLE=true
你的 `production.env`:
---- 生产环境:Cloudflare R2 ----
STORAGE_ENABLED=true S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com S3_PUBLIC_ENDPOINT=https://files.domain.com S3_BUCKET=domain-files S3_ACCESS_KEY=<r2-access-key> S3_SECRET_KEY=<r2-secret-key> S3_REGION=auto S3_FORCE_PATH_STYLE=true
`S3_FORCE_PATH_STYLE=true` 对于 MinIO **和** R2/Hetzner 都至关重要。如果没有它,SDK 会尝试访问 `https://bucket.minio-staging.domain.com`(虚拟主机风格),这将无法解析。
现在在你的应用代码中(Node.js 示例,使用 AWS SDK v3):
// src/lib/s3.js import { S3Client } from "@aws-sdk/client-s3";
export const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION, credentials: { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY, }, forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true", });
export const BUCKET = process.env.S3_BUCKET; export const PUBLIC_ENDPOINT = process.env.S3_PUBLIC_ENDPOINT;
同一个 `s3` 实例现在可以在预发环境中连接 MinIO,在生产环境中连接 R2,无需更改任何代码。
## 9. 第6步 — 上传文件(三种方式)
### 9.1 从服务器上传(适用于可信后端)
import { PutObjectCommand } from "@aws-sdk/client-s3"; import { s3, BUCKET } from "./lib/s3.js"; import { readFile } from "node:fs/promises";
export async function uploadDocument(localPath, key, contentType) { const Body = await readFile(localPath); await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body, ContentType: contentType, // 可选:每对象元数据,便于审计 Metadata: { uploadedBy: "system", env: process.env.NODE_ENV }, })); return key; }
### 9.2 使用 mc CLI(适合一次性上传或迁移)
mc alias set staging https://minio-staging.domain.com domain-app a-long-random-secret-key mc cp ./report.pdf staging/domain-files-staging/reports/2026/report.pdf mc ls staging/domain-files-staging --recursive
### 9.3 通过浏览器直接上传,使用预签名 PUT URL
推荐的用户上传模式是:文件直接从浏览器上传到 MinIO,**零字节**经过你的 API 服务器。
我们将在下一步详细说明。
## 10. 第7步 — 生成预签名 URL(PUT 和 GET)
**预签名 URL** 是一个普通的 HTTPS URL,其查询字符串中包含限时签名。拥有该 URL 的任何人都可以在接下来的 N 分钟内执行该 URL 所签发的操作(如 PUT 此对象,或 GET 那个对象),除此之外无法做任何其他操作。
这正是“用户直接上传到存储”安全性的来源。
### 10.1 预签名 PUT(用于上传)
// src/lib/presign.js import { PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { s3, BUCKET } from "./s3.js"; import { randomUUID } from "node:crypto";
export async function presignUpload({ filename, contentType, userId }) { const key = users/${userId}/${randomUUID()}-${filename}; const cmd = new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: contentType, }); const uploadUrl = await getSignedUrl(s3, cmd, { expiresIn: 60 * 5 }); // 5 分钟有效 return { uploadUrl, key }; }
将其接入你的 API:
// POST /api/uploads/presign app.post("/api/uploads/presign", requireAuth, async (req, res) => { const { filename, contentType } = req.body; const result = await presignUpload({ filename, contentType, userId: req.user.id, }); res.json(result); // { uploadUrl, key } });
浏览器直接上传到 MinIO:
// 在前端代码中 async function uploadFile(file) { const { uploadUrl, key } = await fetch("/api/uploads/presign", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: file.name, contentType: file.type }), }).then(r => r.json());
await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, });
// 将 key 保存到数据库以便后续检索 await fetch("/api/documents", { method: "POST", body: JSON.stringify({ key, originalName: file.name }), }); }
在 PUT 请求中发送的 `Content-Type` **必须与签名时使用的完全一致**,否则 MinIO 会拒绝请求并返回 `SignatureDoesNotMatch`。这会让每个人第一次尝试时都踩坑。
### 10.2 预签名 GET(用于下载)
同样的思路,但使用 `GetObjectCommand`:
export async function presignDownload(key, expiresIn = 60 * 10) { const cmd = new GetObjectCommand({ Bucket: BUCKET, Key: key }); return getSignedUrl(s3, cmd, { expiresIn }); }
一个典型的“查看文档”接口:
app.get("/api/documents/:id/url", requireAuth, async (req, res) => { const doc = await db.documents.findById(req.params.id); if (!doc || !canUserSee(req.user, doc)) return res.sendStatus(403); const url = await presignDownload(doc.key, 600); res.json({ url }); });
前端只需打开该 URL —— 文件将直接从 MinIO 流式传输给用户。
### 10.3 为什么预签名 URL 优于“通过 API 代理”
| | 通过 API 代理 | 预签名 URL |
| --- | --- | --- |
| 经过你的应用的数据量 | 所有数据 | 零 |
| API 的 CPU/RAM 成本 | 高 | 无 |
| 吞吐量限制 | 你的 API | MinIO 的网卡 |
| 认证检查 | 你的代码 | 你的代码(仍然需要在签名前检查) |
## 11. 第 8 步 — 获取文档的公开 URL
有时你需要一个永久且无需认证的 URL —— 例如公开的头像图片。
如果存储桶策略允许匿名读取(`mc anonymous set download …`),则公开 URL 的格式为:
https://minio-staging.domain.com/<bucket>/<key>
因此 `users/42/avatar.png` 变为:
https://minio-staging.domain.com/domain-files-staging/users/42/avatar.png
在代码中:
export function publicUrl(key) { return \{process.env.S3_PUBLIC_ENDPOINT}/\{BUCKET}/${key}; }
对于 **私有** 存储桶(大多数文档),请完全不要使用公开 URL —— 始终通过 `presignDownload(key)` 来访问,这样可以在每次请求时重新检查权限,并设置链接过期时间。
## 12. 第 9 步 — 加强 CORS、生命周期和安全设置
### 12.1 允许前端源站(CORS)
浏览器上传需要在存储桶上配置 CORS 规则。通过 `mc` 工具导入以下 JSON:
cat > /tmp/cors.json <<'EOF' { "CORSRules": [ { "AllowedOrigins": [ "https://crm-staging.domain.com", "http://localhost:3000" ], "AllowedMethods": ["GET", "PUT", "POST", "HEAD"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3000 } ] } EOF
docker cp /tmp/cors.json minio-staging:/tmp/cors.json docker exec -it minio-staging \ mc cors set local/domain-files-staging /tmp/cors.json
### 12.2 自动删除旧测试文件(生命周期)
预发布环境会积累大量垃圾文件。告诉 MinIO 删除超过 30 天的文件:
docker exec -it minio-staging \ mc ilm rule add --expire-days 30 local/domain-files-staging
### 12.3 静态加密
docker exec -it minio-staging \ mc encrypt set sse-s3 local/domain-files-staging
### 12.4 安全硬性规则
* **永远不要** 将 `MINIO_ROOT_USER=admin` / `MINIO_ROOT_PASSWORD=admin123` 发布到可从互联网访问的服务器上。生成强密码,并将其存储在密钥管理器中。
* 根账户仅应由 `mc admin` 使用,绝不应被你的应用程序使用。应用程序应使用受限的 IAM 用户(第 7.4 步)。
* 如果控制台子域名是公开的,请通过 IP 白名单或 Traefik 中间件的基本认证进行保护。
* 至少每 90 天轮换一次应用程序的访问密钥。
## 13. 第 10 步 — 备份与监控
### 13.1 备份:每周镜像到廉价冷存储桶
设置一个小型定时任务,使用 `mc mirror` 将数据推送到 Backblaze B2、R2 或其他廉价 S3 端点:
mc alias set b2 https://s3.us-east-005.backblazeb2.com \(B2_KEY \)B2_SECRET mc mirror --overwrite --remove \ staging/domain-files-staging \ b2/domain-staging-backup
即使按每月 $6/TB 计费,对于预发布环境的数据量来说也几乎免费。
### 13.2 使用 Prometheus 监控
MinIO 默认在 `/minio/v2/metrics/cluster` 暴露 Prometheus 指标。抓取配置如下:
scrape_configs:
- job_name: minio
metrics_path: /minio/v2/metrics/cluster scheme: https static_configs:
- targets: ["minio-staging.domain.com"]
如果你使用 Grafana,导入仪表板 ID **13502** 可以快速获得概览(容量、请求速率、延迟、错误计数等)。
## 14. 故障排查速查表
| 症状 | 很可能的原因 | 解决方法 |
| --- | --- | --- |
| 预签名 PUT 返回 `SignatureDoesNotMatch` | 浏览器发送的 `Content-Type` 与签名时不一致 | PUT 时发送完全相同的 `Content-Type` 头部 |
| 预签名 URL 在本地有效但在浏览器中无效 | 未设置 `MINIO_SERVER_URL`,导致 URL 是针对 `minio:9000` 签名的 | 设置 `MINIO_SERVER_URL=https://minio-staging.domain.com` 并重启 |
| 经过 Cloudflare 后返回 `403 SignatureDoesNotMatch` | Cloudflare 剥离或修改了头部 | 将 DNS 记录设置为 **DNS-only**(灰色云) |
| `NoSuchBucket` | 应用程序指向了错误的端点或存储桶 | 重新检查环境变量中的 `S3_ENDPOINT` 和 `S3_BUCKET` |
| 浏览器 CORS 预检失败 | 存储桶未配置 CORS 规则 | 应用 §12.1 中的 CORS JSON 配置 |
| 小文件上传成功,100 MB 文件上传失败 | Cloudflare 免费版对请求体大小有限制 | 使用 Cloudflare 付费计划,或绕过 CF 代理 |
| 应用程序报错 `x509: certificate signed by unknown authority` | 应用容器不信任 Let's Encrypt 证书 | 更新 CA 证书包(`apt install ca-certificates`)或在 Docker 网络内使用 HTTP |
| Web 控制台重定向到 `http://minio:9001/login` | 缺少 `MINIO_BROWSER_REDIRECT_URL` | 设置为 `https://minio-console-staging.domain.com` |
有用的诊断命令:
检查 MinIO 健康状态
curl -I https://minio-staging.domain.com/minio/health/live
列出存储桶中的所有对象
docker exec -it minio-staging mc ls --recursive local/domain-files-staging
查看 MinIO 日志
docker compose -f docker-compose.staging.yml logs -f minio
解码预签名 URL,查看其签名内容
echo "<paste url>" | tr '&' '\n'
## 15. 总结
你现在拥有的是:
* 一个免费的、兼容 S3 的对象存储,运行在你自己的预发布服务器上。
* 通过 Traefik + Let's Encrypt 实现真实域名上的 HTTPS(如 `https://minio-staging.domain.com`)。
* 具有权限范围限制的最小权限应用用户 —— 根密钥始终被安全锁定。
* 预发布环境与生产环境使用完全相同的代码路径。在 MinIO / R2 / Hetzner / AWS S3 之间切换,只需修改环境文件中的四个变量。
* 提供预签名 PUT URL,使用户可直接上传至存储,无需经过你的 API。
* 提供预签名 GET URL,确保私有文档具有短生命周期并受授权控制。
* 自动清理旧测试文件的生命周期规则。
* 可选的每周镜像备份至冷存储桶。
生产环境继续使用托管存储服务,以保证 SLA 要求。而预发布环境现在每月每 GB 上传成本为 **0 美元** —— 你终于可以不再要求 QA 团队“用完后删除测试文件”了。
### 进一步阅读
* [MinIO 文档](https://min.io/docs/minio/container/index.html)
* [AWS SDK v3 — `getSignedUrl`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-s3-request-presigner/)
* [Traefik v2 Docker 提供商](https://doc.traefik.io/traefik/providers/docker/)
* [S3 存储桶策略参考](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-policies.html)
如果本指南为你的团队节省了几美元,请分享给另一个仍在将测试 PDF 上传到每月 90 美元 S3 存储桶的团队。祝你顺利发布!
* * *
* * *
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人获得开发者工作。[立即开始](https://www.freecodecamp.org/learn)