这篇文章记录在 Ubuntu 云服务器上部署 OtterIO、Gitea 和 Traefik 的方法,将图片、下载文件和代码仓库放到一套自己可以管理的服务里。

写在前面

前几篇文章中,我们分别介绍了如何可靠地安装部署 Docker,以及开源对象存储项目 OtterIO 的维护与使用。这篇文章就把这些内容组合起来,从零搭建一套文件存储、下载与代码托管服务。完整配置代码开源在 soulteary/file-code-stack,可以自取,也欢迎“一键三连”。

接下来,以一台 Ubuntu 26.04 云服务器为例,所有应用都使用 Docker Compose 部署。

配置放在 /app/file-code-stack,持久化数据统一放在 /data 挂载点。有独立数据盘时挂载数据盘;只有系统盘时,将专用数据目录绑定挂载到这里。需要加速公开图片和文件下载时,可以再接入云服务商的 CDN。

云厂商提供的初始账号可能是 root、ecs-user 或 ubuntu 等。为了让后面的操作保持一致,我们会先创建 deploy 用户,再使用这个账号进行日常运维。

OtterIO 使用固定版本 RELEASE.2026-10-07T14-09-17Z。标签中的时间使用 UTC,对应北京时间十月七日 22:09:17。这个版本接续十月初维护记录中的版本,主要修复 HTTP 生命周期、编码对象路径和管理接口,具体见新版本发布说明。本文以它作为部署示例,不使用会随发布变化的 latest 标签。

一台服务器上运行多个服务,需要共享 80 和 443 端口,并按域名将请求转发给对应应用。这里继续使用 Traefik,统一处理 HTTPS 和入口路由。如果你还不熟悉它,可以先阅读《Docker 环境下使用 Traefik 3 的最佳实践:快速上手》。自动证书和服务接入的配置,也可以参考《Traefik 阿里云使用方案:自动证书与服务接入》。

Traefik 能否自动发现容器,取决于使用的 provider。本文不挂载 Docker socket,后面的路由通过文件配置声明;参考文章中的自动发现配置需要按这里的方案调整。

Gitea 的部署思路可以参考《使用 Docker 和 Traefik v3 搭建轻量代码仓库(Gitea 1.20+)》,本文使用 Gitea 28.1.0 的 rootless 镜像,具体参数以本文配置为准。

服务器建议从 2 vCPU、4 GB 内存起步,再按实际负载调整。流量较小,或者只部署部分应用,也可以使用更低的配置。配置示例中的域名、镜像摘要、邮箱和管理 IP 都是占位值,执行前需要替换。

各类服务的入口和访问权限规划如下:

场景 入口 权限边界
图片与静态资源 img 域名,images 桶 公开 GET/HEAD,使用专用账号上传
发布包与文档下载 files 域名,downloads 桶 公开 GET/HEAD,不允许匿名列桶或写入
程序对象存储 s3 域名,独立私有桶 来源 IP 限制与签名/IAM 权限校验,应用不使用 root 密钥
代码托管 git 域名 初始化完成并开放入口后,公开仓库允许匿名浏览和 clone,写入需要认证

公开文件可以通过固定链接下载,程序可以通过 S3 API 读写对象,读者也可以浏览或克隆公开代码仓库。

下面,我们按新服务器的初始化顺序展开。

1 先确定服务入口

公网请求统一交给 Traefik,服务器只开放 TCP 80、443,以及限制来源 IP 的 SSH 端口。OtterIO 的 API、控制台和 Gitea 的后端端口只绑定宿主机 127.0.0.1,需要直接访问时,通过 SSH 隧道连接。Gitea 的日常浏览和 HTTPS clone 仍走 Traefik。

Traefik 同时加入 s3_proxy 和 git_proxy 两个网络,分别连接 OtterIO 和 Gitea。两个后端不共享网络,减少直接互访。这里的网络划分主要用于隔离服务;普通 Docker 桥接网络仍可能访问公网和部分宿主机地址,如需限制出站访问,还要单独配置规则。

服务 容器 UID/GID 宿主机端口映射 可写持久化目录
Traefik 20003:20003 80 → 8080、443 → 8443 /data/traefik/acme
OtterIO 20002:20002 127.0.0.1:19000 → 9000、127.0.0.1:19001 → 9001 /data/otterio
Gitea rootless 镜像 20001:20001 127.0.0.1:13000 → 3000 /data/gitea/data、/data/gitea/config

这些 UID/GID 用于容器进程及挂载文件的归属,不需要创建同号的宿主机登录账号。宿主机已有的 ubuntu、lighthouse 等账号保持原编号,后面的初始化脚本会检查服务预留编号是否被占用。

Traefik 在容器内监听 8080 和 8443,由 Docker 映射到宿主机的 80 和 443。这样应用进程无需绑定容器内的低位端口,也就不需要为此保留额外的 capability。

三个容器都以非 root 用户运行,移除全部 capabilities,设置 no-new-privileges,并限制内存和进程数。保留 Docker 默认的 seccomp 配置;宿主机启用 AppArmor 时,也保留默认容器配置。

Traefik 和 OtterIO 使用只读根文件系统,需要的临时写入通过单独的临时目录提供。Gitea 暂时保留可写根层,待选定镜像完成初始化、确认实际写入位置后再收紧。数据库、仓库、对象和证书等持久化内容,都写入表中对应的挂载目录。

Traefik 使用文件配置声明路由,无需挂载 Docker socket。其他容器也不挂载宿主机根目录、/proc、SSH 私钥或云账户管理员凭据,不启用 privileged、host network 或 host PID。关于默认隔离机制,可以参考 Docker 的 seccomp 和 AppArmor 文档。

这里使用的是由 root 运行的 Docker daemon,应用进程则使用各自的非 root UID。Gitea rootless 镜像描述的是容器内部的运行方式,不代表 Docker daemon 也以非 root 用户运行。如果后续需要进一步隔离 daemon 权限,可以评估 rootless Docker,同时重新验证端口映射、挂载目录权限和资源限制。

2 准备域名和部署参数

配置服务前,先确认服务器的 CPU、内存、架构、公网 IPv4/IPv6,以及计划用于 /data 的存储容量和挂载方式。管理电脑和上传程序的公网出口 IP 也要记录下来,后面会用于 SSH、初始化页面和 S3 入口的访问限制。

这套服务使用四个域名,分别提供图片访问、文件下载、S3 API 和代码托管。如果接入 CDN,还要确定回源地址、回源 Host 和 HTTPS 配置。

参数 示例 使用位置
图片域名 img.example.com .env、CDN
文件下载域名 files.example.com .env、下载链接、可选 CDN
S3 API 域名 s3.example.com .env、上传及应用客户端
Git 域名 git.example.com .env
ACME 邮箱 admin@example.com .env
管理来源 实际公网 IPv4 /32 或 IPv6 /128 云防火墙、Gitea 初始化路由
S3 客户端来源 上传程序或应用的公网出口 IP/CIDR S3 路由
镜像引用 repository@sha256:… .env

表中的域名、邮箱和镜像引用都是占位值,部署前需要替换。填写访问来源时,应使用请求实际到达服务器时的出口地址。

对象总量在几个 GB 到几十 GB、访问量较小时,可以从前面的配置起步。代码托管还需要考虑完整 Git 历史、附件容量,以及多人同时 clone 时的 CPU、内存和带宽消耗。存储容量和访问流量分别估算,再决定是否增加服务器资源。

本文配置先关闭 Actions、LFS 和软件包仓库。基本服务运行稳定后,再按需要逐项启用,并调整容量、资源限制和备份范围。

3 创建运维账号并配置 SSH

修改账号和 SSH 配置前,先确认云控制台的救援登录可用,并保留当前 SSH 连接。后面另开终端验证新账号的登录和 sudo 权限,确认成功后再关闭旧会话。

3.1 检查系统并准备工具

先在服务器的初始账号会话中检查系统状态:

cat /etc/os-release
uname -m
free -h
df -hT
lsblk -f
timedatectl status

阿里云和腾讯云以及其他不同云服务供应商的镜像可能提供不同的初始账号,实际入口以镜像说明和云控制台提示为准。

初始账号 取得管理权限 后续运维方式
阿里云 root 登录后已是 root deploy + SSH 密钥 + sudo
阿里云 ecs-user 执行 sudo -i deploy + SSH 密钥 + sudo
腾讯云 ubuntu 执行 sudo -i deploy + SSH 密钥 + sudo
腾讯云轻量服务器 lighthouse 执行 sudo -i deploy + SSH 密钥 + sudo

当前是 root 时直接继续,其他账号先执行:

sudo -i

下面的初始化命令在 root shell 中执行,确认是 root 身份后,更新系统并安装工具:

if [ "$(id -u)" -ne 0 ]; then
  printf '%s\n' '请先执行 sudo -i,进入 root shell。'
  exit 1
fi

apt update
apt upgrade
apt install sudo openssh-server ca-certificates curl openssl \
  ufw unattended-upgrades apparmor-utils restic python3 vim rsync

软件包升级可能出现确认提示,根据实际情况,完成变更检查后继续,如果有弹出提示,不要强制覆盖已有配置。

即使我们安装了“无人值守更新” unattended-upgrades 也不代表完成了自动更新策略,后面还需要检查它的配置。

更新结束,如果系统提示需要重启,先重启、重新登录并恢复 root shell,再继续部署。这里尤其需要确认时间同步正常,避免时钟偏差影响 S3 签名校验。至于服务器上预安装的云厂商初始化组件和厂商监控服务先保留,后续根据实际需求再决定是否调整。

3.2 准备并上传管理公钥

我们在自己的电脑或者管理服务器上准备一把专用的 Ed25519(ssh-ed25519)密钥,并将公钥上传到这台需要完成部署的服务器上,上传前后注意核对公钥内容和指纹。

如果已有符合要求的密钥,可以直接使用。下面的命令仅在私钥和公钥文件都不存在时生成新密钥:

# 在自己的电脑上执行。
mkdir -p "$HOME/.ssh"
chmod 0700 "$HOME/.ssh"

ADMIN_KEY="$HOME/.ssh/file-code-stack_ed25519"

if [ ! -e "$ADMIN_KEY" ] && [ ! -e "$ADMIN_KEY.pub" ]; then
  ssh-keygen -t ed25519 \
    -f "$ADMIN_KEY" \
    -C file-code-stack-admin
else
  printf '%s\n' '密钥文件已存在,请确认后使用,不要覆盖。'
fi

生成时建议为私钥设置一个口令。当使用其他已有密钥时,可以将上面的 ADMIN_KEY 改为对应的私钥路径。

公钥和私钥通常成对出现,如果缺失了某一个文件,建议先停下来检查原因。先使用命令确认公钥文件存在,查看内容和指纹:

test -f "$ADMIN_KEY.pub"
cat "$ADMIN_KEY.pub"
ssh-keygen -lf "$ADMIN_KEY.pub"

公钥文件应只包含自己的那一条公钥。记录下指纹,稍后在服务器上再次核对。

接下来,在自己的电脑上下载配套配置。下面固定到本文对应的仓库提交,避免后续 main 分支变化影响操作步骤。需要 curl 和 tar;在一个新的工作目录中执行,不覆盖已有配置:

(
set -euo pipefail

STACK_REF=d98350968df3a03706116441a84edb6554a4f439
STACK_DIR="$(mktemp -d "$PWD/file-code-stack-source.XXXXXX")"

curl -fL "https://codeload.github.com/soulteary/file-code-stack/tar.gz/$STACK_REF" \
  -o "$STACK_DIR/source.tar.gz"
mkdir "$STACK_DIR/file-code-stack"
tar -xzf "$STACK_DIR/source.tar.gz" \
  --strip-components=1 -C "$STACK_DIR/file-code-stack"

test -f "$STACK_DIR/file-code-stack/compose.yaml"
test -f "$STACK_DIR/file-code-stack/scripts/render-config.py"
printf '配置目录:%s\n' "$STACK_DIR/file-code-stack"
)

下载完成后,进入输出的配置目录的上一级,再上传公钥和整个 file-code-stack 目录。下面的 INITIAL_USER 和 SERVER_IP 需要按实际环境填写:

# 在自己的电脑上执行。
INITIAL_USER=ubuntu
SERVER_IP=203.0.113.10

scp "$ADMIN_KEY.pub" \
  "$INITIAL_USER@$SERVER_IP:~/deploy-admin.pub"

# 在解压后的 file-code-stack 目录所在位置执行。
# 远端 staging 目录应尚不存在,避免重复上传形成嵌套目录。
scp -r file-code-stack \
  "$INITIAL_USER@$SERVER_IP:~/file-code-stack-staging"

203.0.113.10 是示例地址,执行前需要替换。初始账号可能是阿里云的 root、ecs-user,或腾讯云的 ubuntu 或其他服务商的各种用户名,以实际登录账号为准。

首次连接时,通过云控制台等可信渠道核对服务器的 SSH 主机密钥指纹,不关闭主机密钥校验。这里核对的服务器主机密钥,与前面生成的管理员登录密钥是两种不同的密钥。

3.3 创建用户并分配权限

回到服务器的 root shell,通过初始账号定位上传的公钥。下面的 INITIAL_USER 必须与上传时使用的账号一致:

INITIAL_USER=ubuntu
INITIAL_HOME=$(getent passwd "$INITIAL_USER" | cut -d: -f6)
PUBLIC_KEY="$INITIAL_HOME/deploy-admin.pub"

test -n "$INITIAL_HOME"
test -f "$PUBLIC_KEY"

cat "$PUBLIC_KEY"
ssh-keygen -lf "$PUBLIC_KEY"

将这里显示的内容和指纹与自己电脑上的结果比较。确认文件只包含自己的公钥,且指纹一致后,再执行下面的创建命令。

下面按首次部署创建新账号;若已经存在 deploy 用户或组,命令会停止,不覆盖原账号:

(
set -euo pipefail

[ "$(id -u)" -eq 0 ] || {
  printf '%s\n' '请在 root shell 中执行。'
  exit 1
}

INITIAL_USER=ubuntu
INITIAL_HOME=$(getent passwd "$INITIAL_USER" | cut -d: -f6)
test -n "$INITIAL_HOME"
PUBLIC_KEY="$INITIAL_HOME/deploy-admin.pub"
test -f "$PUBLIC_KEY"

if getent passwd deploy >/dev/null || getent group deploy >/dev/null; then
  printf '%s\n' 'deploy 用户或组已存在,请先核对,不继续创建。'
  exit 1
fi

DEPLOY_ID=1001
while getent passwd "$DEPLOY_ID" >/dev/null ||
      getent group "$DEPLOY_ID" >/dev/null ||
      [ "$DEPLOY_ID" -eq 20001 ] ||
      [ "$DEPLOY_ID" -eq 20002 ] ||
      [ "$DEPLOY_ID" -eq 20003 ]; do
  DEPLOY_ID=$((DEPLOY_ID + 1))
done

groupadd --gid "$DEPLOY_ID" deploy
useradd --uid "$DEPLOY_ID" --gid deploy \
  --create-home --shell /bin/bash deploy
passwd --lock deploy
chmod 0750 /home/deploy

install -d -m 0700 -o deploy -g deploy /home/deploy/.ssh
install -m 0600 -o deploy -g deploy \
  "$PUBLIC_KEY" /home/deploy/.ssh/authorized_keys

id deploy
)

新创建的账号将从 1001 起选择未占用的 UID/GID,并跳过服务预留的 20001、20002、20003。例如,宿主机已有 ubuntu=1000、lighthouse=1001 时,新建的 deploy 会使用 1002,不需要修改默认账号编号。

创建的账号会锁定本地密码,只允许通过 SSH 密钥登录。

这里有个细节,容器服务使用的 UID/GID 与登录账号分开,后面也不要将服务预留编号用于其他登录账号。

如果创建账号过程中会话中断或某条命令失败,账号可能只完成了部分配置。再次执行时,如果提示 deploy 用户或组已存在,先在原来的 root 会话查看账号和用户组,不直接删除账号:

getent passwd deploy || true
getent group deploy || true

下面的命令会备份并删除 /home/deploy,必须在 root shell 中执行,不要从部分创建就绪的 deploy 账号的 SSH 会话切换到 root 后执行:

(
set -euo pipefail

[ "$(id -u)" -eq 0 ] || {
  printf '%s\n' '请从初始管理员会话进入 root shell 后执行。'
  exit 1
}

if id deploy >/dev/null 2>&1; then
  if [ "$(getent passwd deploy | cut -d: -f6)" != /home/deploy ]; then
    printf '%s\n' 'deploy 家目录不是 /home/deploy,请先人工核对。'
    exit 1
  fi

  if [ -d /home/deploy ]; then
    tar -czpf "/root/deploy-home-$(date +%Y%m%d-%H%M%S).tar.gz" \
      -C /home deploy
  fi

  loginctl terminate-user deploy 2>/dev/null || true
  pkill -TERM -u deploy 2>/dev/null || true
  sleep 2
  pkill -KILL -u deploy 2>/dev/null || true
  userdel --remove deploy
fi

if getent group deploy >/dev/null; then
  groupdel deploy
fi

getent passwd deploy || true
getent group deploy || true
)

最后两条查询都没有输出后,再执行创建命令。

账号删除不会清理 /app、/data 和 sudoers 规则。如果之前已经创建配置目录,重建账号后还要核对这些目录的用户组归属。这里的清理仅用于首次部署的误操作;已有业务数据时,按实际状态修复,不通过重置服务器处理。

完成账号创建之后,我们将 deploy 作为受信任的管理员,配置免密码 sudo,便于后面的运维脚本运行。有这个账号 SSH 登录权限的人可以进一步取得 root 权限,因此这个账号要注意保管,以及不要配置给 CI 使用。

下面的命令仍在 root shell 中执行。使用命令生成固定授权的文件,先创建临时文件,然后再安装到 sudoers 目录:

(
set -euo pipefail

[ "$(id -u)" -eq 0 ] || {
  printf '%s\n' '请在 root shell 中执行。'
  exit 1
}

id deploy

if [ -e /etc/sudoers.d/deploy ] || [ -L /etc/sudoers.d/deploy ]; then
  printf '%s\n' '/etc/sudoers.d/deploy 已存在,请先核对原授权。'
  exit 1
fi

SUDOERS_TMP=$(mktemp)
trap 'rm -f "$SUDOERS_TMP"' EXIT

cat > "$SUDOERS_TMP" <<'EOF'
deploy ALL=(ALL:ALL) NOPASSWD: ALL
EOF

chmod 0440 "$SUDOERS_TMP"
visudo -cf "$SUDOERS_TMP"
install -o root -g root -m 0440 \
  "$SUDOERS_TMP" /etc/sudoers.d/deploy
visudo -c

# 从 root 会话验证 deploy 的免密码 sudo。
su - deploy -c 'sudo -n true && sudo -n id -u && sudo -n -l'
)

如果系统中已有 /etc/sudoers.d/deploy 配置,上面的配置命令会自动停止。我们可以先在 root 会话查看原授权的详细情况:

cat /etc/sudoers.d/deploy
grep -RnsE '(^|[[:space:]])%?deploy([[:space:]]|$)' \
  /etc/sudoers /etc/sudoers.d

如果只包含我们配置的账号内容,可以直接删除,再次执行命令进行生成。如果还包含其他账号,不要直接删除。可以使用 Vim 修改规则文件内容,并检查规则语法是否正确:

EDITOR=vim VISUAL=vim visudo -f /etc/sudoers.d/deploy
visudo -c

修改其他配置文件里的授权信息,调整实际路径即可。

如果 deploy 可以登录,但 sudo -n true 提示 user deploy may not run sudo,说明账号可以登录,sudo 授权尚未生效。我们回到原来的 root 会话检查授权文件、权限和 visudo -c 的结果,不必重新创建用户。

本文不倾向将 deploy 加入 docker 组,Docker 操作统一使用 sudo docker。

因为 deploy 已有免密码 sudo 权限,执行 sudo docker 就能管理容器,再加入 docker 组没有必要。同时,docker 组也不是普通的应用权限:成员可以启动容器并挂载宿主机目录,实际能够取得接近 root 的控制能力。

这里统一使用 sudo docker,主要是让需要管理员权限的操作在命令中明确体现,并减少一处重复授权。它不会限制 deploy 的权限,因为这个账号本来就能通过 sudo 执行任意管理命令。

3.4 验证新账号

保留 SSH 连接状态,在自己的电脑上另开一个终端会话,使用上文提到的专用密钥建立全新连接。新终端需要重新设置 SERVER_IP 和 ADMIN_KEY 变量;如果前面使用了其他密钥,这里的路径也要保持一致:

SERVER_IP=203.0.113.10
ADMIN_KEY="$HOME/.ssh/file-code-stack_ed25519"

ssh \
  -o IdentitiesOnly=yes \
  -o PreferredAuthentications=publickey \
  -o ControlMaster=no \
  -o ControlPath=none \
  -i "$ADMIN_KEY" \
  "deploy@$SERVER_IP"

上面的命令里,明确使用公钥进行认证,并通过 IdentitiesOnly=yes 选择指定身份,避免 SSH agent 中的其他密钥造成额外认证尝试。我们关闭了连接复用,确保进行的是一次新的登录验证。

如果刚刚的私钥设置了口令,连接时可能需要输入私钥口令。这与服务器账号的登录密码不同。

登录后,在新的 deploy 会话中检查身份和 sudo 权限:

id
sudo -n true
sudo -n id -u
sudo -n -l

id 应显示当前用户为 deploy;sudo -n true 应成功退出且没有输出;sudo -n id -u 应输出 0;sudo -n -l 应包含为 deploy 配置的 NOPASSWD: ALL 授权。

确认新登录和 sudo 都成功后,再继续收紧 SSH 配置。如果登录或 sudo 检查失败,继续使用保留的原会话排查,先不修改 SSH 策略。

3.5 验证登录后,收紧 SSH

新账号验证成功后,我们调整 SSH 配置,只允许 deploy 账号登录;多人管理的机器,需要先将其他授权账号也加入 AllowUsers。继续保留原 SSH 连接,并确认云控制台救援入口可用。

在新的 deploy 会话中创建配置文件:

sudo install -d -o root -g root -m 0755 /etc/ssh/sshd_config.d

# 只创建新文件;如果目标已存在,命令会失败。
# 此时先检查文件来源并备份,不直接覆盖。
sudo bash -c '
set -eu
target=/etc/ssh/sshd_config.d/00-service-hardening.conf
umask 022
set -C
cat > "$target"
' <<'EOF'
PermitRootLogin no
PubkeyAuthentication yes
AuthenticationMethods publickey
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
AllowUsers deploy
X11Forwarding no
AllowAgentForwarding no
MaxAuthTries 3
AllowTcpForwarding local
EOF

上面的命令中,AuthenticationMethods publickey 要求使用 SSH 密钥登录,AllowTcpForwarding local 允许我们通过 ssh -L,从自己的电脑访问服务器上的管理控制台;不允许通过 ssh -R 在服务器一侧建立转发入口。

不过,拥有 shell 和 sudo 权限的管理员仍然可以运行其他程序进行转发,因此这项配置不能用于限制管理员权限。

执行完毕命令后,先检查配置语法是否正确:

sudo /usr/sbin/sshd -t

成功时通常没有输出。如果检查失败,不重新加载服务,先修正配置或删除本次新建的文件。

接着按当前连接的实际来源检查最终配置。以下命令在 deploy 的 SSH 会话中执行:

# SSH_CONNECTION 的顺序为:
# 客户端地址、客户端端口、服务器本地地址、服务器端口。
test -n "$SSH_CONNECTION"
read -r CLIENT_IP CLIENT_PORT SERVER_LOCAL_IP SSH_PORT <<< "$SSH_CONNECTION"

CLIENT_HOST="$CLIENT_IP"

sudo /usr/sbin/sshd -T \
  -C "user=deploy,host=$CLIENT_HOST,addr=$CLIENT_IP,laddr=$SERVER_LOCAL_IP,lport=$SSH_PORT" \
  | grep -E '^(permitrootlogin|pubkeyauthentication|authenticationmethods|passwordauthentication|kbdinteractiveauthentication|permitemptypasswords|allowusers|denyusers|allowgroups|denygroups|x11forwarding|allowagentforwarding|maxauthtries|allowtcpforwarding|disableforwarding|permitopen) '

服务器地址取自当前连接的 SSH_CONNECTION。云服务器的公网 IP 经过 NAT 映射时,sshd 实际接收连接的地址可能是私网 IP,检查配置时应使用这个地址。如果已有配置包含 Match Host,还需要将 CLIENT_HOST 设置为 sshd 实际识别的客户端主机名。

检查输出是否符合预期:

参数 预期值
permitrootlogin no
pubkeyauthentication yes
authenticationmethods publickey
passwordauthentication no
kbdinteractiveauthentication no
permitemptypasswords no
allowusers 只有 deploy,或事先确定的授权账号
x11forwarding no
allowagentforwarding no
maxauthtries 3
allowtcpforwarding local
disableforwarding no
permitopen any,或明确允许后面隧道所需的目标地址与端口

如果最终配置中出现 DisableForwarding yes,即使设置了 AllowTcpForwarding local,本地隧道也无法使用。PermitOpen 和 authorized_keys 中的公钥选项还可能限制转发目标,因此后面需要通过 SSH 隧道实际访问控制台,确认配置生效。

同时检查 DenyUsers、AllowGroups 和 DenyGroups,确认这些规则不会阻止 deploy 登录。然后将检查命令中的 user=deploy 分别替换为 root 和实际初始账号名,查看这些账号是否有单独的 Match 规则。

OpenSSH 的多数参数采用先读到的值,但也有例外,例如多条 AllowUsers 会累加允许登录的账号。因此,将文件命名为 00-service-hardening.conf 并不意味着能覆盖所有已有配置,仍需检查最终结果。具体规则可以参考 OpenSSH 配置手册。

输出不一致时,检查主配置和片段文件:

sudo grep -RInE \
  '^[[:space:]]*(Include|Match|AllowUsers|DenyUsers|AllowGroups|DenyGroups|PermitRootLogin|AuthenticationMethods|PasswordAuthentication|KbdInteractiveAuthentication|PubkeyAuthentication|AllowTcpForwarding|DisableForwarding|PermitOpen)[[:space:]]' \
  /etc/ssh/sshd_config /etc/ssh/sshd_config.d

如果 Include 引用了其他目录中的配置文件,也要一并检查。修改前先备份,再使用 Vim 调整;完成后重新检查语法和最终生效的配置,确认结果符合预期。Ubuntu 上的具体配置方法可以参考 OpenSSH 服务配置说明。

确认检查通过后,重新加载服务:

sudo /usr/sbin/sshd -t && sudo systemctl reload ssh.service

然后在自己的电脑上再开一个终端,重新设置 SERVER_IP 和 ADMIN_KEY,使用第 3.4 节的 SSH 命令建立第三个全新 deploy 会话,再执行:

id
sudo -n true
sudo -n id -u

确认新的 deploy 会话可以登录并正常使用 sudo 后,再用初始账号建立一次全新的 SSH 连接,确认它已无法登录。测试时同样关闭连接复用,避免复用仍然有效的旧连接。两项验证都完成后,再关闭旧会话。

这里不修改 SSH 端口、监听地址或 socket activation 配置。未列入最终 AllowUsers 的账号将无法通过 SSH 登录,但账号和云初始化配置仍然保留。先确认云厂商的救援功能是否依赖这些账号,再决定是否锁定或删除。

如果语法检查失败,或重新加载后无法建立新的 deploy 连接,在保留的旧会话中删除本次新建的配置文件,再检查语法并重新加载服务:

sudo rm -- /etc/ssh/sshd_config.d/00-service-hardening.conf
sudo /usr/sbin/sshd -t && sudo systemctl reload ssh.service

上面的命令只撤销本次新增的配置文件。如果还改动了其他配置,也需要恢复对应的备份,再检查语法并重新加载服务。

3.6 用命令生成配置,编辑时使用 Vim

固定配置尽量通过命令生成,再用 install 设置文件归属和权限。前面用到的 <<'EOF' 会保留内容中的变量和特殊字符,不让当前 shell 提前展开。需要手动修改配置或排查问题时,统一使用 Vim。

前面已经安装了 vim,接下来将它设为系统默认编辑器,并为后续登录会话设置编辑器变量:

sudo update-alternatives --set editor /usr/bin/vim.basic

sudo tee /etc/profile.d/10-service-editor.sh >/dev/null <<'EOF'
export EDITOR=vim
export VISUAL=vim
export SUDO_EDITOR=vim
EOF

sudo chown root:root /etc/profile.d/10-service-editor.sh
sudo chmod 0644 /etc/profile.d/10-service-editor.sh

# 让当前会话立即生效。
export EDITOR=vim
export VISUAL=vim
export SUDO_EDITOR=vim

编辑普通系统配置文件时,可以使用 sudoedit,将下面的示例路径替换为实际路径:

SUDO_EDITOR=vim sudoedit /etc/某配置文件

编辑 sudoers 文件时使用 visudo,它会在保存时检查语法。修改独立配置文件后,再检查整套 sudo 配置:

sudo env EDITOR=vim VISUAL=vim SUDO_EDITOR=vim \
  visudo -f /etc/sudoers.d/deploy

sudo visudo -c

不要用 sudo echo ... > /etc/文件 写入系统配置:sudo 只作用于 echo,重定向仍由当前用户的 shell 执行,可能因权限不足而失败。可以改用 sudo tee,或者先生成临时文件,再用 sudo install 保存。

修改服务配置后,先检查语法,再重新加载对应服务。密码和私钥不放进命令参数、shell 历史或 Git。

4 云防火墙和 UFW

先在云安全组或轻量服务器防火墙中限制入站访问:TCP 22 只允许管理电脑的公网出口地址,TCP 80、443 用于 Web 访问。如果实际 SSH 端口不是 22,下面的规则也要相应调整。

OtterIO、Gitea 的后端端口,以及 Docker API 和 Traefik 内部端口,都不向公网开放。IPv6 的防火墙规则也要单独检查;暂不发布 AAAA 记录并不能阻止别人通过服务器的 IPv6 地址访问。

接下来配置服务器上的 UFW。将下面的 ADMIN_CIDR 换成实际管理出口,单个 IPv4 地址使用 /32,单个 IPv6 地址使用 /128。如果出口地址经常变化,先准备稳定的管理入口,并保留云控制台救援方式。

# 替换为实际管理出口地址。
ADMIN_CIDR='203.0.113.10/32'
SSH_PORT=22

# 先检查是否已有规则。
sudo ufw status numbered

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from "$ADMIN_CIDR" to any port "$SSH_PORT" proto tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

# 确认管理地址和 SSH 端口填写正确后启用。
sudo ufw enable
sudo ufw status verbose

设置默认拒绝入站不会删除已有的允许规则。如果之前存在面向所有来源的 SSH 放行规则,需要核对并删除,才能将 SSH 访问限制到管理出口。

启用后保留当前连接,在自己的电脑上使用前面的 SSH 命令建立一次全新连接,确认 deploy 登录和 sudo 仍然正常,再关闭旧会话。

Docker 发布端口时会配置自己的转发规则,容器流量可能绕过 UFW 的常规入站规则。因此,除了配置 UFW,还要限制云防火墙、减少端口发布,并将后台端口绑定到 127.0.0.1。服务启动后,从另一台机器检查实际暴露的端口。不要通过设置 iptables=false 解决这个问题,它可能破坏容器网络,具体原因可以参考 Docker 防火墙说明。

以后需要限制容器出站或增加转发过滤时,先确认 Docker 使用的防火墙后端。DOCKER-USER 的配置方法适用于 iptables 后端,不能直接照搬到 Docker 原生 nftables 后端;系统使用 iptables-nft 也不等于 Docker 已启用原生 nftables。相关区别可以参考 Docker nftables 配置说明。修改规则前保留备份和救援会话。

5 安装 Docker Engine

已经按上一篇 Docker 安装文章装好 Engine、Buildx 和 Compose 的读者,可以跳过安装步骤,直接检查版本和服务状态。

新服务器可以使用下面的官方 APT 源。Ubuntu 26.04 使用自己的发行版代号 resolute,不替换成 Ubuntu 24.04 的 noble。支持范围和安装方法可以参考 Docker 的 Ubuntu 安装说明。

安装前,先检查机器是否预装了 Docker、containerd 或其他容器运行时。docker.io、podman-docker、发行版提供的 Compose,以及单独安装的 containerd、runc 等包可能与官方包冲突,需要确认用途后再处理。已经运行容器的机器,不直接卸载原有运行时。

下面按全新服务器安装:

sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update
apt-cache policy docker-ce

确认 docker-ce 的候选版本来自预期的 Docker 仓库,再安装:

sudo apt install docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

sudo systemctl enable --now docker

官方源访问不稳定时,按上一篇文章选择阿里云或腾讯云镜像源,不通过修改发行版代号解决下载问题。

安装完成后,检查组件版本和服务状态。已经安装过 Docker 的读者也从这里开始检查:

sudo systemctl status docker --no-pager
sudo docker version
sudo docker buildx version
sudo docker compose version
sudo docker info
sudo aa-status

docker version 应同时显示客户端和服务端信息,Buildx 和 Compose 也应正常返回版本。

在 docker info 中检查 Security Options,确认默认 seccomp 配置和 AppArmor 已启用,同时检查 cgroup 状态,以及是否出现不支持内存限制等警告。aa-status 用于查看宿主机的 AppArmor 状态,不能单独代替容器配置检查。

遇到权限拒绝时,先查看 Docker 服务、内核和 AppArmor 日志,不直接设置 unconfined 跳过限制。

系统安全更新继续使用 Ubuntu 的 unattended-upgrades,可以先启用,再核对实际允许自动更新的软件源:

sudo dpkg-reconfigure -f noninteractive unattended-upgrades

自动更新的范围取决于配置中的允许来源,不能只看软件包是否安装。确认 Ubuntu 安全更新已启用,并按维护计划决定是否纳入 Docker 的第三方源,具体配置可以参考 Ubuntu 自动更新说明。

Docker Engine 和应用镜像安排在维护窗口升级,升级后重新验证服务。这个规模先保留默认内核配置,不急着调整网络缓冲或启用 HTTP/3。

6 放置配置并挂载数据盘

6.1 root 持有配置,deploy 可进入运维目录

接下来,我们把之前上传的配置复制到 /app/file-code-stack。

下面在 deploy 会话中执行,STAGING_DIR 按 3.2 章节初始账号的实际目录填写。例如,如之前使用 lighthouse 上传,路径为 /home/lighthouse/file-code-stack-staging;使用 root 上传时,通常为 /root/file-code-stack-staging。

复制和权限设置放在同一个命令块中,任何一步失败都会停止。下面只用于首次放置配置,目标目录已经存在时,先检查内容,不要直接覆盖。

STAGING_DIR=/home/ubuntu/file-code-stack-staging

sudo bash -s -- "$STAGING_DIR" <<'EOF'
set -euo pipefail

source_dir=$1
target=/app/file-code-stack

test -d "$source_dir"
test -f "$source_dir/compose.yaml"
test -f "$source_dir/.env.example"
test -f "$source_dir/scripts/init-state.sh"

if [ -e "$target" ] || [ -L "$target" ]; then
  printf '%s\n' '/app/file-code-stack 已存在,请先检查,不直接覆盖。'
  exit 1
fi

# 首次部署使用尚未初始化的配置目录。
if [ -e "$source_dir/.env" ] || [ -e "$source_dir/secrets" ]; then
  printf '%s\n' '上传目录包含 .env 或 secrets,请先核对,不执行批量权限调整。'
  exit 1
fi

install -d -m 0750 -o root -g deploy "$target"
cp -a "$source_dir/." "$target/"

chown -R root:root "$target"
find "$target" -mindepth 1 -type d -exec chmod 0755 {} +
find "$target" -type f -exec chmod 0644 {} +
chmod 0755 "$target"/scripts/*.sh "$target/scripts/render-config.py"

chown root:deploy "$target"
chmod 0750 "$target"
EOF

命令成功后进入配置目录:

cd /app/file-code-stack

# 继续完成下文 /data 挂载步骤,此处暂不运行初始化。

顶层目录设置为 root:deploy、0750 后,deploy 可以进入并读取普通配置,修改仍需要 sudo。Traefik 读取的配置子目录保留 0755,供容器内 UID 20003 访问。

初始化后还要核对一次下面的权限。不要重新执行前面的批量权限调整,它会放宽 .env 和秘密文件的访问权限。

对象 所有者 权限 / 用途
/home/deploy deploy:deploy 0750
/home/deploy/.ssh deploy:deploy 0700
/home/deploy/.ssh/authorized_keys deploy:deploy 0600
/app/file-code-stack root:deploy 0750,deploy 可进入
Compose、普通配置、脚本 root:root 文件 0644;可执行脚本 0755;仅 root 可写
.env root:root 0600,由 sudo 执行的 Compose 读取
secrets 目录 root:root 0700
secrets/root_user、secrets/root_password 20002:20002 0400;只读挂入容器
/data/otterio 20002:20002 0750
/data/gitea/data、/data/gitea/config 20001:20001 0750
/data/traefik/acme 20003:20003 0750
/data/traefik/acme/acme.json 20003:20003 0600
后续创建的备份配置和口令文件 root:root 0600

这里没有启用用户命名空间重映射,容器进程的 UID/GID 与宿主机文件权限使用相同的数字。本项目为 Gitea、OtterIO、Traefik 分别预留 20001、20002、20003,不需要创建对应的宿主机登录账号,也不与 ubuntu、lighthouse 等账号共用。

初始化和预检查会确认这些 UID/GID 没有被宿主机用户或用户组占用,发现冲突就停止。选择较大的编号是为了避开常见登录账号,本身不提供额外隔离。如果后续启用用户命名空间重映射或 rootless Docker,需要重新核对挂载目录的权限。

初始化脚本不会覆盖已有用户名和密码文件。secrets 目录归 root,权限为 0700;两个凭据文件归 20002:20002,权限为 0400,OtterIO 使用相同身份读取挂入容器的文件。这里的 Compose secrets 是文件挂载,不是加密存储,宿主机 root 和 Docker 管理员仍能读取。

服务数据统一放在 /data:OtterIO 使用 /data/otterio,Gitea 使用 /data/gitea/data 和 /data/gitea/config,证书保存在 /data/traefik/acme。配置和秘密文件仍留在 /app/file-code-stack。前面的递归归属和权限调整只用于新复制的配置,不对 /data 执行。

这里不迁移 Docker 的 data-root。即使服务数据使用独立数据盘,镜像、容器可写层和日志仍可能占用系统盘。使用 containerd image store 时,还要检查 containerd 的独立存储目录,通常为 /var/lib/containerd;修改 Docker 的 data-root 不会自动迁移这部分内容,具体可以参考 Docker 存储目录说明。

6.2 选择 /data 的挂载方式

先查看磁盘和挂载情况:

sudo mkdir -p /data

lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINTS,UUID
findmnt --target /data
mountpoint /data

findmnt --target /data 显示的是承载这个目录的文件系统。如果挂载目标显示为 /,说明 /data 当前位于根文件系统中,尚未单独挂载。

下面根据是否有独立数据盘选择一种方式,完成后再初始化。已经正确挂载 /data 的机器,可以直接进入第 6.3 节。

当我们使用独立数据盘时,先根据 lsblk 核对数据分区、UUID 和文件系统。下面按分区已经有文件系统的情况配置,已有数据的分区不执行 mkfs。如果磁盘尚未分区或格式化,先完成磁盘准备,再继续。

挂载前检查 /data:

mountpoint /data
sudo ls -la /data

如果已经挂载,先核对来源;如果目录里有文件,先处理这些文件,不直接挂载覆盖。挂载不会删除原文件,但会让挂载点下的原内容暂时不可见。

编辑挂载配置前保留备份:

sudo cp -a /etc/fstab "/etc/fstab.backup-$(date +%Y%m%d-%H%M%S)"
SUDO_EDITOR=vim sudoedit /etc/fstab

如果已经有 /data 的配置,先核对原记录,不重复添加。ext4 数据分区可以使用下面的格式,实际 UUID 从 lsblk 输出中获取:

UUID=实际UUID /data ext4 defaults 0 2

使用 XFS 等文件系统时,按实际类型填写,最后的文件系统检查项也要相应调整,不能直接照抄 ext4。

保存后挂载并检查:

(
set -euo pipefail

sudo systemctl daemon-reload
sudo mount /data

mountpoint /data
findmnt --mountpoint /data
df -h /data
)

确认挂载来源、文件系统和容量符合预期。挂载失败时先处理,不继续初始化或启动应用。使用独立数据盘的读者,到这里可以进入第 6.3 节。

而当我们只使用系统盘的时候,部分轻量云服务器只有一块系统盘。如果暂时使用系统盘保存服务数据,我们在 /srv/file-code-stack-data 创建专用目录,再将它绑定挂载到 /data。

这里的 /srv/file-code-stack-data 是数据存放位置,/data 是提供给 Compose 和运维脚本的统一入口。挂载后,两条路径访问的是同一份文件,不会额外复制数据。

这种方式满足项目的挂载检查,但数据、镜像和系统仍然共享同一块磁盘的空间与故障风险,不等于增加了一块独立数据盘。

下面用于首次部署,要求 /data 尚未挂载且为空。如果 fstab 已有 /data 配置,命令会停止,先检查原配置:

(
set -euo pipefail

sudo mkdir -p /data

if mountpoint -q /data; then
  printf '%s\n' '/data 已挂载,请核对现有挂载,不重复配置。'
  exit 1
fi

if [ -n "$(sudo find /data -mindepth 1 -maxdepth 1 -print -quit)" ]; then
  printf '%s\n' '/data 非空,请先检查其中内容。'
  exit 1
fi

if grep -qE '^[^#[:space:]]+[[:space:]]+/data[[:space:]]' /etc/fstab; then
  printf '%s\n' 'fstab 已包含 /data 配置,请先核对。'
  exit 1
fi

sudo install -d -o root -g root -m 0755 /srv/file-code-stack-data

sudo cp -a /etc/fstab "/etc/fstab.backup-$(date +%Y%m%d-%H%M%S)"
printf '%s\n' '/srv/file-code-stack-data /data none bind 0 0' |
  sudo tee -a /etc/fstab >/dev/null

sudo systemctl daemon-reload
sudo mount /data

mountpoint /data
findmnt --mountpoint /data
df -h /data
)

如果挂载失败,先检查错误和刚写入的 fstab 记录,不反复追加配置。

服务启动后,数据会通过 /data 写入,实际保存在 /srv/file-code-stack-data 中。服务运行期间不卸载挂载点,也不直接清理这个源目录。

6.3 挂载检查与初始化

确认 /data 的挂载来源无误后,安装仓库附带的 Docker 启动保护:

sudo install -d -o root -g root -m 0755 \
  /etc/systemd/system/docker.service.d

sudo install -o root -g root -m 0644 \
  /app/file-code-stack/scripts/docker-data.conf \
  /etc/systemd/system/docker.service.d/20-service-data.conf

sudo systemctl daemon-reload
sudo systemctl cat docker

这份配置让 Docker 服务启动时依赖 /data,并在启动前检查它是否为挂载点。它作用于整个 Docker daemon,因此也会影响同一台机器上的其他容器。

daemon-reload 只让 systemd 重新读取配置,不会重启正在运行的 Docker。这里在首次启动应用前安装,后续需要重启 Docker 时再安排维护窗口。

挂载检查只能确认 /data 是挂载点,不能保证挂载了正确的设备或目录,仍然需要核对 findmnt 的结果。如果 fstab 配置错误导致启动失败,通过云控制台救援入口恢复。

接下来初始化数据目录、凭据和 ACME 文件。初始化与检查放在同一个命令块中,初始化失败后就停止,不继续检查尚未创建的文件:

(
set -euo pipefail

cd /app/file-code-stack
sudo scripts/init-state.sh

sudo stat -c '%U:%G %a %n' \
  /app/file-code-stack \
  /app/file-code-stack/.env \
  /app/file-code-stack/secrets

sudo stat -c '%u:%g %a %n' \
  /data/otterio \
  /data/gitea/data \
  /data/gitea/config \
  /data/traefik/acme

sudo stat -c '%u:%g %a %n' \
  secrets/root_user \
  secrets/root_password \
  /data/traefik/acme/acme.json
)

对照第 6.1 节的权限表检查结果。OtterIO 凭据应为 20002:20002、0400,ACME 文件应为 20003:20003、0600。

初始化脚本会创建 .env、生成尚不存在的管理员凭据,并准备数据目录和证书文件,不会启动容器。已有凭据不会被覆盖,后面继续填写部署参数和核对镜像摘要。

如果脚本提示 /data must be a mounted filesystem,说明它已在写入前停止。此时 .env、secrets 或数据目录尚未创建属于预期结果。回到上一节完成挂载,再执行初始化,不跳过检查。

初始化、预检查和备份脚本都会在 /data 未挂载时停止。后续重启服务器前先备份,重启后再检查挂载和容器状态。权限不匹配时,按第 6.1 节核对对应文件和目录,不用 chmod 777 解决,也不要随意复用服务预留的 UID/GID。

7 选择镜像并核对摘要

先拉取文章使用的固定版本 RELEASE.2026-10-07T14-09-17Z,查看镜像摘要和检查运行平台:

OTTERIO_RELEASE=RELEASE.2026-10-07T14-09-17Z
OTTERIO_TAG="ghcr.io/soulteary/otterio:${OTTERIO_RELEASE}"

sudo docker pull "$OTTERIO_TAG"

sudo docker image inspect "$OTTERIO_TAG" \
  --format '{{range .RepoDigests}}{{println .}}{{end}}'

sudo docker image inspect "$OTTERIO_TAG" \
  --format '{{.Architecture}} {{.Os}} {{json .Config.User}}'

从这个版本的 Release 页面下载 release-manifest.json,核对其中记录的版本、镜像仓库和摘要。将确认过的完整镜像引用保存下来,稍后填入 .env 的 OTTERIO_IMAGE:

OTTERIO_IMAGE=ghcr.io/soulteary/otterio@sha256:完整64位摘要

这里使用 RepoDigests 中的仓库摘要,不使用 docker images 显示的短 IMAGE ID。遇到多平台镜像时,还要区分镜像索引摘要和具体平台的摘要,不能只比较数字而忽略它们对应的对象。

Traefik、OtterIO 和 Gitea 都使用 repository@sha256:完整64位摘要。Gitea 使用官方 28.1.0-rootless 变体,Traefik 选择仍受支持的 v3 正式版。

下面使用明确版本拉取 Traefik 和 Gitea,查看各自的镜像摘要。配套模板没有预填生产镜像,需要将核对后的完整摘要写入 .env,不使用 latest 或只固定主版本的标签:

(
set -euo pipefail

TRAEFIK_TAG='ghcr.io/traefik/traefik:3.7.13'
GITEA_TAG='ghcr.io/go-gitea/gitea:28.1.0-rootless'

sudo docker pull "$TRAEFIK_TAG"
sudo docker pull "$GITEA_TAG"

for image in "$TRAEFIK_TAG" "$GITEA_TAG"; do
  sudo docker image inspect "$image" \
    --format '{{.Architecture}} {{.Os}} {{json .RepoDigests}} {{json .Config.User}}'
done
)

把实际标签、平台、完整摘要和检查日期记在部署记录中,再填写 .env。这份模板本身不表示任意版本都已完成云服务器验收;首次启动后的 Git 读写、S3 请求、证书和备份恢复仍按后文检查。

镜像默认用户可以通过上面的命令查看,但本项目的运行身份由 Compose 中的 user 指定,OtterIO 使用 20002:20002。

部署时保存版本、摘要和验证结果。摘要用于固定镜像内容,发布清单用于记录镜像身份,两者本身不能代替签名验证或可复现构建证明。

7.1 从秘密文件加载凭据

第 6 节的 init-state.sh 已经创建两个凭据文件:

  • secrets/root_user:默认管理员名为 file-code-root。
  • secrets/root_password:使用 openssl rand -hex 32 生成随机密码。

重复执行初始化不会覆盖已有凭据。先检查文件和数据目录的归属、权限:

cd /app/file-code-stack

sudo stat -c '%u:%g %a %n' \
  secrets/root_user \
  secrets/root_password \
  /data/otterio

两个凭据文件都应为 20002:20002、0400,数据目录应为 20002:20002、0750。

默认管理员名可以直接使用。如果希望更换,在首次启动前执行下面的命令,将示例名称替换为自己的名称:

(
set -euo pipefail

printf '%s\n' 'your-admin-name' |
  sudo tee /app/file-code-stack/secrets/root_user >/dev/null

sudo chown 20002:20002 /app/file-code-stack/secrets/root_user
sudo chmod 0400 /app/file-code-stack/secrets/root_user
)

在安全终端查看凭据,并保存到密码管理器:

sudo cat /app/file-code-stack/secrets/root_user
sudo cat /app/file-code-stack/secrets/root_password

秘密文件不提交 Git,也不复制到聊天、日志或工单。服务启动后遇到问题,先检查日志、配置和文件权限,不通过重新生成密码尝试修复。需要修改管理员凭据时,单独安排变更,并更新使用该凭据的客户端。

仓库通过 .env 中的 OTTERIO_IMAGE 固定镜像,通过 /data/otterio 保存数据,通过 Compose secrets 挂载凭据,无需在 shell 中 export 管理员密码。

OtterIO 使用镜像自带的入口脚本加载用户名和密码。下面是仓库中已有的配置片段,用于说明读取方式,不需要另外创建一份 Compose 文件:

command: [server, --address, ':9000', --console-address, ':9001', /data]
environment:
  HOME: /tmp
  OTTERIO_REGION_NAME: us-east-1
  OTTERIO_ROOT_USER_FILE: /run/secrets/root_user
  OTTERIO_ROOT_PASSWORD_FILE: /run/secrets/root_password
  OTTERIO_ACCESS_KEY_FILE: ''
  OTTERIO_SECRET_KEY_FILE: ''
secrets: [root_user, root_password]

两个 _FILE 变量指向容器内的绝对路径,Compose 将宿主机上的凭据文件挂载到对应位置。保留镜像原有的 entrypoint,不绕过它的凭据检查。

这个版本要求显式配置非默认凭据。这里统一使用文件方式,不再为同一个设置提供非空环境变量,也不混用新旧两组凭据变量。文件必须可读、非空且为普通文件;镜像不会自动调整数据目录归属,启动前需要核对权限,具体要求见版本发布说明。

7.2 存储检查与后续升级

OtterIO 这个版本修正了编码对象路径的单次解码,正确保留字面百分号和编码分隔符;也修复了管理接口查询参数传递、流式请求取消与清理,以及 HTTP 服务按配置超时关闭的行为。上一版的容器凭据策略、SigV4 请求头校验和内部存储检查继续适用。

这里使用单节点部署,数据目录只供存储服务写入。其他程序通过 S3 API 上传和下载,不直接修改 /data/otterio 中的文件。

路径和元数据检查不能替代文件系统权限、备份及资源限制。后续升级前保留旧镜像摘要,完成一致性备份,再阅读目标版本的兼容说明。遇到元数据错误时先保留现场,确认原因后再从验证过的备份恢复,不通过关闭检查或启用默认凭据例外绕过问题。

8 配置 HTTPS 入口并启动服务

准备好域名、邮箱、访问来源和三个镜像摘要后,编辑第 6 节创建的 .env:

cd /app/file-code-stack
SUDO_EDITOR=vim sudoedit .env

配置生成器按字面量读取这个文件,不执行命令,也不展开其他变量。下面列出需要填写的参数;域名、邮箱、IP、镜像仓库和摘要占位值都要换成实际值:

TRAEFIK_IMAGE=TRAEFIK_REPOSITORY@sha256:VERIFIED_64_HEX_DIGEST
OTTERIO_IMAGE=ghcr.io/soulteary/otterio@sha256:VERIFIED_64_HEX_DIGEST
GITEA_IMAGE=GITEA_ROOTLESS_REPOSITORY@sha256:VERIFIED_64_HEX_DIGEST
IMAGE_DOMAIN=img.your-domain.tld
DOWNLOAD_DOMAIN=files.your-domain.tld
S3_DOMAIN=s3.your-domain.tld
GIT_DOMAIN=git.your-domain.tld
ACME_EMAIL=admin@your-domain.tld
TLS_MODE=http
UPLOAD_CIDRS=YOUR_UPLOAD_IP/32
ADMIN_CIDRS=YOUR_ADMIN_IP/32
GIT_BOOTSTRAP=true
COMPOSE_FILE=compose.yaml:traefik/generated/compose.yaml

UPLOAD_CIDRS 限制 S3 域名的访问来源,上传程序和其他 S3 客户端的出口地址都要包含在内。ADMIN_CIDRS 用于限制 Gitea 初始化阶段的访问。多个地址或网段用英文逗号分隔,单个 IPv4 使用 /32,单个 IPv6 使用 /128。

首次部署保留 GIT_BOOTSTRAP=true,完成 Gitea 安装和管理员配置后,再按后文步骤开放入口。COMPOSE_FILE 保持示例中的值,让 Compose 同时读取主配置和生成的覆盖文件。

8.1 选择证书验证方式

默认的 TLS_MODE=http 使用 ACME HTTP-01。首次部署时,四个域名都先直接解析到服务器,并确认公网 TCP 80 可以访问,具体要求可以参考 Traefik ACME 配置说明。

如果图片或下载域名已经指向 CDN,需要确认 CDN 能将 /.well-known/acme-challenge/ 请求正确转发到 Traefik。无法满足时,可以暂缓接入 CDN,或者改用 DNS-01。

使用阿里云 DNS-01 时,先按仓库的 AliDNS 凭据准备说明创建秘密文件,再将 .env 中的 TLS_MODE 改为 alidns。凭据使用独立 RAM 用户,只授予验证所需的权限,不使用云账号主密钥。

TLS_MODE=http 指的是证书验证方式,业务入口仍然使用 HTTPS。Traefik 在容器内监听 8080、8443,宿主机映射为 80、443;HTTP 跳转使用公网的 :443,不会将浏览器带到内部端口 :8443。

8.2 使用 Cloudflare DNS-01

如果域名的权威 DNS 使用 Cloudflare,可以通过 DNS-01 申请证书。Traefik 会调用 Cloudflare API 创建临时 TXT 记录,完成域名验证。这个过程不依赖 HTTP 验证入口,也不要求开启 Cloudflare 的橙云代理。

在 Cloudflare 的 API Tokens 页面创建独立 Token,授予以下权限:

  • Zone / DNS / Edit
  • Zone / Zone / Read

将 Zone Resources 限定到实际使用的域名。四个子域名属于同一个 Zone 时,只授权这个 Zone 即可。两项权限可以放在同一个 Token 中,不使用权限范围更大的 Global API Key,具体要求见 Cloudflare provider 说明。

第 6 节已经创建了 root 私有的 secrets 目录。下面以 root 权限打开 Vim,将 Token 保存到文件,同时关闭交换文件、编辑历史和备份文件,减少额外副本:

cd /app/file-code-stack

sudo env EDITOR=vim VISUAL=vim vim -n -i NONE \
  -c 'set nobackup nowritebackup' secrets/cloudflare_dns_api_token

sudo chown 20003:20003 secrets/cloudflare_dns_api_token
sudo chmod 0400 secrets/cloudflare_dns_api_token
sudo stat -c '%u:%g %a %n' secrets/cloudflare_dns_api_token

文件只写 Token 内容,不写变量名、引号或 export。检查结果应为 20003:20003、400。不在终端打印 Token,也不提交 Git。

接下来编辑部署参数:

SUDO_EDITOR=vim sudoedit /app/file-code-stack/.env

将证书模式改为:

TLS_MODE=cloudflare

其他参数按前文填写,尤其是 UPLOAD_CIDRS 和 ADMIN_CIDRS,不能保留示例地址。Token 保存在秘密文件中,不写入 .env。这些步骤也整理在仓库的 Cloudflare 凭据准备说明中。

配置生成器会设置 dnsChallenge.provider=cloudflare,将 Token 通过 Compose secret 挂载到 /run/secrets/cloudflare_dns_api_token,再通过 CF_DNS_API_TOKEN_FILE 指定读取路径。无需手动修改生成文件,准备好后继续执行下一节的生成和启动步骤。

首次部署时,S3 和 Git 域名先保持 DNS only,让请求直接到达服务器,便于验证来源 IP 限制。后续需要开启代理时,再检查真实客户端 IP 的传递和信任配置。图片及下载域名可以在服务验证完成后接入 CDN;使用 Cloudflare 代理时,将 SSL/TLS 模式设为 Full (strict),让 Cloudflare 验证源站证书,具体要求见 Full (strict) 配置说明。

Token 需要持续保留,供 Traefik 自动续期使用。证书和 ACME 账号保存在 /data/traefik/acme/acme.json,切换验证方式时不删除这个文件。DNS-01 不需要开放公网 HTTP 验证入口,但本项目仍保留 TCP 80,用于将普通 HTTP 请求跳转到 HTTPS。

8.3 使用 Aliyun DNS-01

如果域名的 DNS 使用阿里云云解析,可以通过 AliDNS 完成 DNS-01 验证。Traefik 会调用阿里云 API 创建临时 TXT 记录,完成域名验证后清理记录。这个过程不依赖公网 HTTP 验证入口,图片和下载域名已经接入 CDN 时也可以使用。

这里看的是域名的 DNS 解析服务商,而不是注册商或服务器所在的云平台。比如,域名在阿里云注册,但 DNS 已经迁移到 Cloudflare 时,应使用前面的 Cloudflare 方案。

在阿里云 RAM 控制台创建独立用户,为它创建 AccessKey,不使用云账号主密钥。创建自定义权限策略,将下面的 阿里云账号ID 和 example.com 换成实际值,再将策略授权给这个用户:

{
  "Version": "1",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "alidns:DescribeDomains"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "alidns:DescribeDomainRecords",
        "alidns:AddDomainRecord",
        "alidns:DeleteDomainRecord"
      ],
      "Resource": [
        "acs:alidns:*:阿里云账号ID:domain/example.com"
      ]
    }
  ]
}

DescribeDomains 用于查询域名列表,需要全局资源授权;解析记录的操作限定到实际使用的 DNS 域。四个服务域名都属于 example.com 时,授权这个 DNS 域即可,不分别填写 img.example.com、s3.example.com 等主机名。使用多个 DNS 域时,在 Resource 中逐个添加对应资源。独立托管的子域按实际 DNS 域填写,具体规则可以参考阿里云自定义权限策略说明。

第 6 节已经创建了 root 私有的 secrets 目录。下面以 root 权限打开 Vim,分别保存 AccessKey ID 和 AccessKey Secret,同时关闭交换文件、编辑历史和备份文件,减少额外副本:

cd /app/file-code-stack

sudo env EDITOR=vim VISUAL=vim vim -n -i NONE \
  -c 'set nobackup nowritebackup' secrets/alidns_access_key

sudo env EDITOR=vim VISUAL=vim vim -n -i NONE \
  -c 'set nobackup nowritebackup' secrets/alidns_secret_key

alidns_access_key 只写 AccessKey ID,alidns_secret_key 只写对应的 AccessKey Secret;不写变量名、引号或 export。分别保存退出后,设置归属和权限:

(
set -euo pipefail
cd /app/file-code-stack

sudo chown 20003:20003 \
  secrets/alidns_access_key secrets/alidns_secret_key
sudo chmod 0400 \
  secrets/alidns_access_key secrets/alidns_secret_key
sudo stat -c '%u:%g %a %n' \
  secrets/alidns_access_key secrets/alidns_secret_key
)

两个文件的检查结果都应为 20003:20003、400。不在终端打印密钥,也不提交 Git。

接下来编辑部署参数:

SUDO_EDITOR=vim sudoedit /app/file-code-stack/.env

将证书模式改为:

TLS_MODE=alidns

其他参数按前文填写,尤其是 UPLOAD_CIDRS 和 ADMIN_CIDRS,不能保留示例地址。AccessKey 保存在秘密文件中,不写入 .env。

配置生成器会设置 dnsChallenge.provider=alidns,将两个秘密文件通过 Compose secrets 挂载到 /run/secrets/alidns_access_key 和 /run/secrets/alidns_secret_key,再通过 ALICLOUD_ACCESS_KEY_FILE、ALICLOUD_SECRET_KEY_FILE 指定读取路径。变量和文件读取方式见 AliDNS provider 说明。无需手动修改生成文件,准备好后继续执行第 8.4 节的生成和启动步骤。

首次部署时,S3 和 Git 域名先直接解析到服务器,便于验证来源 IP 限制。图片及下载域名可以在服务验证完成后接入 CDN;DNS-01 不要求将业务域名临时改回源站,但服务访问仍需要正确的 DNS 和回源配置。

两个密钥文件需要持续保留,供 Traefik 自动续期使用。证书和 ACME 账号保存在 /data/traefik/acme/acme.json,切换验证方式时不删除这个文件。DNS-01 不需要开放公网 HTTP 验证入口,但本项目仍保留 TCP 80,用于将普通 HTTP 请求跳转到 HTTPS。

申请失败时,先查看 Traefik 日志。权限错误检查 RAM 策略和资源范围;找不到 DNS 域时检查权威 DNS、所属账号及域名列表查询权限;TXT 验证超时则检查记录是否写入正确的 DNS 域,以及公网解析是否已生效,不通过关闭验证绕过问题。

8.4 生成配置并启动

确认云防火墙已限制 SSH 管理入口,.env 中的管理 IP 和 S3 客户端来源也填写正确后,生成配置、执行预检查,再启动服务:

(
set -euo pipefail

cd /app/file-code-stack

sudo python3 scripts/render-config.py
sudo scripts/preflight.sh
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps
sudo docker compose logs --tail 100
)

这些步骤放在同一个命令块中,生成、预检查或镜像拉取失败时就停止,不继续启动。

生成器读取 traefik/templates 中的模板,在 traefik/generated 中生成静态配置、动态路由和 Compose 覆盖文件。文件采用 JSON 格式,也是合法的 YAML,可以直接供 Traefik 和 Compose 读取。

部署参数修改 .env,路由结构修改模板,不直接编辑生成文件。生成器会先验证输入,确认没有残留 ${...} 占位符,再逐个替换文件。它不会自动备份旧配置,后续变更前需要自行保留原参数和模板。

预检查会核对生成结果、凭据权限和合并后的 Compose 配置。修改参数后没有重新生成,检查就会报错。但检查通过只说明这些配置符合预期,不代表证书已经签发或后端服务已经正常运行。

如果日志提示域名中仍包含 ${...},说明配置里留下了未替换的占位符。先确认仓库代码已更新,再重新生成并执行预检查,不在生成文件中临时修改域名,避免下次生成时被覆盖。

启动后查看容器状态和日志,再从管理电脑访问 Git 域名,确认 HTTPS 证书有效。遇到证书错误时,检查 DNS、云防火墙、验证方式和 Traefik 日志,不关闭证书校验继续访问。

8.5 后续配置变更

Traefik 通过文件 provider 监听动态路由变化,可以自动加载更新。静态配置需要重启后生效,例如修改 ACME 邮箱或证书验证方式后,执行:

(
set -euo pipefail

cd /app/file-code-stack

sudo python3 scripts/render-config.py
sudo scripts/preflight.sh
sudo docker compose up -d
sudo docker compose restart traefik
)

up -d 会应用 Compose 配置变化,但挂载文件内容变化不一定触发容器重建,因此这里显式重启 Traefik。切换到 AliDNS 或 Cloudflare 模式时,先准备对应凭据,再执行上述步骤。

重新生成配置不会自行改变 GIT_BOOTSTRAP,它始终以 .env 中的设置为准。

当前路由没有配置请求重试或路径改写,减少对 S3 写入和签名校验的干扰。缓存策略由对象响应或 CDN 单独设置,不向所有响应统一追加缓存头,Traefik Dashboard 也保持关闭。

Traefik 访问日志只保留指定字段,不记录请求路径、查询参数和请求头,减少签名 URL、token 等信息泄露。启动后仍需检查实际日志;OtterIO、Gitea 和 CDN 的日志各自配置,也要分别核对。

9 创建存储桶和上传账号

先在自己的电脑上建立 SSH 隧道。服务器地址和密钥路径按实际情况填写:

SERVER_IP=203.0.113.10
ADMIN_KEY="$HOME/.ssh/file-code-stack_ed25519"

ssh -N \
  -o IdentitiesOnly=yes \
  -o ExitOnForwardFailure=yes \
  -i "$ADMIN_KEY" \
  -L 127.0.0.1:19001:127.0.0.1:19001 \
  -L 127.0.0.1:19000:127.0.0.1:19000 \
  "deploy@$SERVER_IP"

命令运行期间保留这个终端。19001 用于访问控制台,19000 用于通过本机工具测试 S3 API;本地监听地址明确绑定为 127.0.0.1,不向管理电脑所在网络开放。

在本机浏览器打开 http://127.0.0.1:19001,使用第 7 节保存的管理员凭据登录。

在控制台完成下面几步:

  1. 创建 images 桶,保持默认私有状态。
  2. 创建图片上传专用账号,将 policies/uploader.json 的完整 JSON 保存为它的权限策略;上传客户端只使用这个账号的访问键。
  3. 确认桶内只有允许公开的图片,再将 policies/public-images.json 的完整 JSON 保存为该桶的匿名读取策略。
  4. 使用专用账号上传一个测试对象,再分别验证匿名读取成功、匿名写入和列桶失败,以及该账号不能访问其他桶。

策略文件随前面的配置目录一并下载。操作时区分用户或账号的权限策略与桶的匿名策略,不把两个 JSON 填到同一处。控制台的菜单和管理能力随版本变化;如果所选版本缺少创建账号或设置策略的入口,先补齐经过该版本验证的管理工具,再继续开放服务。

账号策略和桶策略用途不同:前者限制上传账号可以执行的操作,后者决定匿名访问是否允许。其他桶按用途单独配置,不一起公开。

仓库的公开策略只允许 GetObject,不允许匿名列桶、上传或删除。对象也可以通过 S3 域名读取,但这个入口仍受来源 IP 限制。

如果控制台不能导入所需策略,先确认所选管理工具与当前 OtterIO 版本兼容。S3 兼容并不代表管理 API 也兼容,不直接套用其他项目的账号管理命令。

程序连接时,endpoint 设置为 https://s3.实际域名,region 使用 us-east-1,启用 path-style。每个应用使用自己的账号,不把管理员密钥交给插件、网页或其他应用。

图片上传策略只授权 images 桶内的 GetObject 和 PutObject,不开放删除或列桶。需要分片上传时,再根据客户端调用的 API 补充权限,并在测试桶验证。

图片入口只允许 /images/ 下的 GET、HEAD。例如,桶中的对象 key 为 demo/example.png,公开地址就是:

https://图片域名/images/demo/example.png

先使用便于检查的文件名和目录结构完成上传、读取验证,再测试特殊字符、URL 编码及条件请求。不要通过改写上传请求路径来调整公开链接,以免影响 S3 签名。

9.1 提供公开文件下载

下载文件使用 downloads 桶,将 policies/public-downloads.json 应用为桶策略,上传账号使用 policies/download-uploader.json。

下载上传账号与图片上传账号分开,只授权各自桶内的对象读写,不开放删除或列桶。需要分片上传时,同样先在测试桶验证所需权限。

例如,一个发布包的公开地址是:

https://files.实际域名/downloads/releases/demo/v1.0.0/demo-linux-amd64.tar.gz

下载路由只允许 /downloads/ 下的 GET、HEAD,上传仍通过 S3 域名并使用签名认证。对象是否允许匿名读取,最终还取决于桶策略。

发布包按版本目录或内容哈希保存,不覆盖已经发布的对象。上传策略中的 PutObject 本身允许覆盖已有 key,因此这里还需要由发布流程保证文件名不重复。

每次发布同时提供 SHA-256 校验文件,重要软件再提供签名。校验和用于检查内容是否一致,不能单独证明发布者身份。私有文件不要临时放进公开桶。

上传时设置正确的 Content-Type。需要下载保存的压缩包和二进制文件,可以设置 Content-Disposition: attachment;图片保持正常展示,不给所有对象统一追加下载头。元数据通过 SDK 或兼容工具设置,上传后检查实际响应。

下载入口需要验证 GET、HEAD、Range 和 ETag 条件请求,并确认写请求被拒绝。下面使用一个大于 1,024 字节的测试文件,检查响应头并读取前 1,024 字节:

FILE_URL='https://files.实际域名/downloads/releases/demo/v1.0.0/demo-linux-amd64.tar.gz'

curl -fsSI "$FILE_URL"

curl -fsS \
  -D /tmp/file-range.headers \
  -H 'Range: bytes=0-1023' \
  "$FILE_URL" \
  -o /tmp/file-range.bin

cat /tmp/file-range.headers
wc -c /tmp/file-range.bin

Range 请求应返回 206 Partial Content,Content-Range 中的范围应为 bytes 0-1023/文件总长度,保存的内容应为 1,024 字节。只看到 curl 成功退出不够,还要核对这些结果。

再复制响应中的完整 ETag 值,包括双引号,测试条件请求:

ETAG='"替换为实际ETag值"'

curl -sS -D - -o /dev/null \
  -H "If-None-Match: $ETAG" \
  "$FILE_URL"

对象未变化时应返回 304 Not Modified。不要把 ETag 一律当成文件的 MD5 校验值,文件完整性仍使用发布时提供的 SHA-256 校验文件检查。

接入 CDN 后,再通过 CDN 域名重复这些测试,确认范围请求、条件请求和响应元数据仍然符合预期。

9.2 给程序接入私有存储

程序数据使用独立桶或明确的前缀,并创建独立账号。默认不设置匿名访问策略,读、写、删除权限按业务需要分别授权,不共用图片上传账号。

私有文件可以通过签名 S3 请求下载,也可以由应用检查用户权限后生成短期预签名链接。

不过,预签名链接不会绕过 Traefik 的来源 IP 限制。当前 S3 入口只允许配置中的客户端来源,因此普通读者不能直接使用这个入口的预签名链接。如果需要向读者提供私有下载,另行设计下载入口,并检查授权和缓存行为,避免私有响应被公开缓存。

10 初始化 Gitea 并发布代码

本文使用的 Gitea 28.1.0修复了特殊字符分支名处理、并发 repack 时的 go-git 存储读取、新建访问令牌的复制,以及部分 API 和 Web 处理函数缺少检查的问题。它也修复了提交状态查询触发 SQLite 表达式深度限制的情况,与本文使用的 SQLite 部署相关。

该版本还包含 Actions、npm 软件包及其他数据库的修复;本文仍保持 Actions、LFS 和软件包仓库关闭,数据库继续使用 SQLite,不因版本更新额外启用功能。首次部署后继续验证 Token 认证、push、clone 和公开、私有仓库的访问权限。

Gitea rootless 镜像使用 /var/lib/gitea 和 /etc/gitea 保存数据与配置,默认 UID/GID 为 1000:1000。本项目通过 Compose 的 user 指定 20001:20001,挂载目录也使用相同归属。它与普通镜像的目录布局不同,不能只替换镜像名称就切换,具体可以参考 Gitea rootless 安装说明。

安装期间保留 GIT_BOOTSTRAP=true,让 Git 域名只允许配置中的管理出口访问。推荐直接打开已经启用 HTTPS 的 Git 域名完成安装。

需要通过隧道访问后端时,可以在自己的电脑上执行:

SERVER_IP=203.0.113.10
ADMIN_KEY="$HOME/.ssh/file-code-stack_ed25519"

ssh -N \
  -o IdentitiesOnly=yes \
  -o ExitOnForwardFailure=yes \
  -i "$ADMIN_KEY" \
  -L 127.0.0.1:13000:127.0.0.1:13000 \
  "deploy@$SERVER_IP"

然后打开 http://127.0.0.1:13000。如果浏览器跳转到配置中的 ROOT_URL,继续使用受管理 IP 限制的 HTTPS Git 域名。

安装页面中核对数据库类型为 SQLite,数据库路径为 /var/lib/gitea/data/gitea.db,公共 URL 为 https://git.实际域名/。创建管理员账号并设置强密码。

安装完成后,在 /data/gitea/config/app.ini 中确认 INSTALL_LOCK=true,检查公开注册已关闭、安装页面不能再次进入。查看配置时注意其中包含内部密钥,不将整份文件贴到日志、聊天或工单中。

管理员启用 2FA 或 Passkey,恢复码单独保存。不要在整个 Git 域名前再增加一层 BasicAuth,以免影响匿名 clone 和 Gitea 自身的认证。

确认初始化完成后,编辑 .env:

SUDO_EDITOR=vim sudoedit /app/file-code-stack/.env

将初始化开关改为:

GIT_BOOTSTRAP=false

重新生成、预检查并应用配置:

(
set -euo pipefail
cd /app/file-code-stack

sudo python3 scripts/render-config.py
sudo scripts/preflight.sh
sudo docker compose up -d
)

这个开关移除 Git 路由的初始化 IP 限制,不改变仓库的公开或私有属性。不要直接删除生成文件中的 middleware,否则下次生成会恢复。

当前配置关闭 Actions、Git 的 SSH 入口、LFS、Packages、用户自定义 Git hooks、webhooks 和本地路径导入。Gitea 自己维护的正常 hooks 保留,不手动删除,相关参数可以参考 Gitea 配置说明。

先在 Gitea 网页中创建与你准备推送的远端 URL 一致的私有仓库。如果本地已有 Git 历史,创建时不要勾选初始化 README、许可证或 .gitignore,避免生成另一套初始提交。模板关闭了 push 自动建仓,不能仅靠第一次 git push 创建远端仓库。

新仓库先保持私有,检查完整 Git 历史、附件、凭据和许可证后,再改为公开。检查历史时不能只看当前工作目录,已经删除的敏感文件也可能保留在旧提交中。

项目代码先在本机审查,再通过 HTTPS 推送,初期不使用服务器端的在线仓库导入功能。推送使用权限范围受限的访问 Token,保存到系统凭据管理器,不写入远端 URL。

模板关闭的是使用账号密码的 HTTP Basic 认证,Token 认证仍然可用,不影响网页密码登录;具体行为见 Gitea 认证配置说明。

在已有本地仓库中,添加远端并推送已经检查过的分支和标签:

git remote add code https://git.实际域名/你的账号/示例仓库.git

git push code main

# 将标签名替换为实际需要发布的标签。
git push code v1.0.0

不要将 Token 拼进 URL。客户端需要认证时,通过凭据管理器提供。

公开后,在没有 .netrc、URL 改写、认证请求头和已存凭据的独立环境中验证匿名 clone。换一个目录不会清除全局凭据配置。下面的命令关闭凭据管理器和交互式认证;不要在仍设置了其他认证方式的环境中把结果当成匿名验收:

GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/false \
  git -c credential.helper= clone \
  https://git.实际域名/你的账号/示例仓库.git

在同样的无凭据环境中访问私有仓库,应失败。

只推送需要发布的分支和标签,不使用 --mirror 上传未经检查的全部 refs。以后需要配置 GitHub 拉取镜像时,再设置迁移域名和地址限制,并按选定版本验证对私网、回环地址和云元数据地址的防护。Gitea 容器不保存云账号凭据。

Gitea 的浏览、clone 和 push 直接走 HTTPS。登录、API、私有仓库,以及 info/refs、git-upload-pack、git-receive-pack 不使用公共 CDN 缓存。固定版本的大文件可以放到 OtterIO 下载桶,确认公开后再接入 CDN。

11 接入 CDN 并检查缓存

图片域名和文件下载域名分别配置 CDN。源站指向 Traefik,回源 Host 与对应路由一致,使用 HTTPS 回源并验证源站证书。

业务域名已经解析到 CDN 时,源站填写服务器 IP 或独立源站域名,避免再次解析回 CDN 形成循环。使用 IP 回源时,还要确认 CDN 的回源 Host 和 TLS SNI 设置正确。

不会再覆盖、使用内容哈希命名的图片,可以在对象上设置:

Cache-Control: public,max-age=31536000,immutable

同时设置正确的 Content-Type。可能被覆盖的 URL 使用较短 TTL,更新后按需要刷新缓存,不在 Traefik 上给所有响应统一追加 immutable。

只缓存公开内容的 GET、HEAD 响应,404 等错误响应使用较短缓存或不缓存。接入后重复检查 Range、ETag 和 If-None-Match,确认 CDN 没有改变下载行为。

带签名参数的 URL 不与忽略查询参数的公共缓存规则混用。JPEG、PNG、WebP、AVIF 等已经压缩的图片通常不需要额外 gzip。缓存命中率按实际服务域名观察,多个域名的汇总数据不能代表某一个图片服务。

公开桶允许匿名读取,因此接入 CDN 不代表源站只能由 CDN 访问。如果需要限制为 CDN 回源,再配置可靠的回源鉴权,或维护服务商公布的回源 IP 范围。Referer 不作为身份认证,也不信任未经核对的 forwarded headers。

12 观察资源占用再调优

当前 Compose 为 Traefik、OtterIO、Gitea 分别设置 256m、768m、1280m 的内存上限,合计约 2.25 GiB,为 4 GB 主机留出系统和缓存空间。这是起步配置,2 GB 主机不能直接照用。

CPU 配额的合计可以超过物理核数,但不代表机器具备这些算力。并发 clone、上传和下载时,仍要观察实际资源竞争和响应时间。

Gitea 的 /tmp 暂时保留执行权限,避免影响 Git 和初始化。其他应用也先确认临时目录的用途,再决定是否设置 noexec。pids_limit 限制进程和线程任务数量,不等于网络连接或请求并发限制。

Compose 的日志轮转将每个容器的日志保留量控制在约 60 MiB,还要检查 journald、服务数据目录,以及 Docker/containerd 的镜像和快照占用。

sudo /app/file-code-stack/scripts/audit.sh
sudo docker system df

df -h /data /app/file-code-stack /var/lib/docker
df -i /data /app/file-code-stack

# 使用默认 containerd 存储目录时,另外检查它所在的文件系统。
if [ -d /var/lib/containerd ]; then
  df -h /var/lib/containerd
fi

audit.sh 用于查看容器运行身份、权限、挂载、端口和当前资源占用,不是完整的性能测试。

先监控磁盘用量、inode、OOMKilled 和容器重启次数。磁盘使用率可以从 70% 提醒、85% 告警起步,再根据增长速度调整,同时监测外部下载、Git clone、证书有效期和最近成功备份时间。

调优时结合 P95 延迟、CPU、内存和网络峰值观察。套餐标注的 200 Mbps 峰值带宽不等于持续保底带宽,不能只根据这个数值估算长期服务能力。

图片转换优先离线进行,并发先从 1–2 开始。Git clone 测试先从 2–4 个并发开始,再按结果调整,不直接提高所有服务的并发。

清理镜像和卷前先确认用途,不自动执行 image prune -a、volume prune,也不让 Watchtower 未经检查更新镜像。长期内存不足时,先检查实际负载和服务配额,再考虑增加主机内存;确实需要 swap 缓解短时峰值时,再评估性能和数据保护要求。

13 配置异地加密备份

备份使用 restic,目标选择另一账户的 COS/S3 或异地仓库,不把本机 OtterIO 的桶作为唯一副本。备份账号与源站管理员分开,只授权备份仓库所需的范围。

如果源主机保存着可以删除备份的凭据,主机被攻破后,远端备份也可能被删除。因此,还需要在目标端安排版本保留、经过验证的不可变策略,或独立离线副本。具体方案要与 restic 的写入、锁文件和清理操作兼容。

13.1 准备备份凭据

将备份配置保存到 /etc/file-code-stack-backup.env,归 root 所有,权限为 0600。实际密钥在受控位置准备,再安装到服务器,不写进公开文章和仓库:

sudo install -o root -g root -m 0600 \
  /受控临时路径/backup.env \
  /etc/file-code-stack-backup.env

# 确认安装成功后,删除临时明文副本。

配置内容如下,替换为实际端点、桶、访问键和 region:

RESTIC_REPOSITORY='s3:https://独立对象存储端点/备份桶/file-code-stack'
RESTIC_PASSWORD_FILE='/etc/file-code-stack-restic-password'
AWS_ACCESS_KEY_ID='备份专用访问键'
AWS_SECRET_ACCESS_KEY='备份专用秘密键'
AWS_DEFAULT_REGION='目标region'

这个文件会被 root shell 通过 source 加载,属于可执行的 shell 配置,与前面按字面量读取的项目 .env 不同。它必须由 root 持有,普通账号不能修改,只包含自己确认过的变量赋值。

首次创建备份密码时执行下面的命令。文件已经存在时停止,不覆盖原密码:

sudo bash <<'EOF'
set -euo pipefail
umask 077

password_file=/etc/file-code-stack-restic-password

if [ -e "$password_file" ] || [ -L "$password_file" ]; then
  printf '%s\n' '备份密码文件已存在,请核对,不重新生成。'
  exit 1
fi

openssl rand -hex 32 > "$password_file"
chown root:root "$password_file"
chmod 0600 "$password_file"
EOF

将备份密码另存到安全的密码管理器或离线介质。恢复备份需要这个密码,只有对象存储访问键还不够。不要把唯一一份密码留在源主机上。

13.2 初始化仓库并执行首次备份

下面只用于尚未初始化的新 restic 仓库:

sudo bash <<'EOF'
set -euo pipefail

set -a
source /etc/file-code-stack-backup.env
set +a

restic init
restic snapshots
EOF

已有仓库不再执行 restic init,先用对应凭据运行 restic snapshots,确认能够访问。

接下来执行项目的备份脚本:

sudo /app/file-code-stack/scripts/backup.sh

脚本会检查 /data 挂载、远端仓库访问,以及三个服务的运行状态。检查通过后,停止 OtterIO 和 Gitea,直接使用 restic 备份:

  • /app/file-code-stack:配置、脚本和秘密文件。
  • /data/otterio:对象及相关数据。
  • /data/gitea:数据库、仓库和 Gitea 配置。

restic 完成后,脚本启动两个后端并检查三个容器是否处于运行状态。备份失败时,退出处理也会尝试恢复后端;备份命令仍返回失败,不会把服务恢复成功当成备份成功。容器运行不等于业务可用,仍需检查 Git 和 S3 请求:

cd /app/file-code-stack
sudo docker compose ps
sudo docker compose logs --tail 100 otterio gitea

脚本在停服前写入 /var/lib/file-code-stack/backup-backends-stopped,后端恢复并通过运行状态检查后删除。这个标记不保存密钥,用于发现未完成的恢复;标记存在时,下一次普通备份会停止,避免直接掩盖中断状态。

退出处理无法应对断电、SIGKILL 或宿主机故障。两个后端使用 unless-stopped,备份时主动停止后,不能依赖机器重启自动恢复。出现中断标记时,先查看日志、核对 /data 的挂载来源和配置,再运行:

sudo /app/file-code-stack/scripts/backup.sh --recover

cd /app/file-code-stack
sudo docker compose ps
sudo docker compose logs --tail 100 otterio gitea

恢复模式会取得与备份相同的锁,执行预检查,启动后端并检查运行状态;失败时保留标记。它不重试备份,也不能证明上次备份成功。恢复后验证实际请求,检查远端快照,再重新安排备份。没有标记但服务仍不可用时,继续按日志排查,不以标记是否存在代替服务监控。

停服是为了避免备份期间继续写入 SQLite、Git 仓库和对象目录。停服时间包含 restic 扫描和上传所需的时间,没有先复制到本地暂存目录。

Traefik 继续运行,但未命中 CDN 缓存的请求可能出现后端不可用错误,所以应选择低峰时间。数据量增大、停服时间无法接受时,再改用经过验证的一致性快照方案。

13.3 单独备份证书状态

当前备份脚本不包含 /data/traefik/acme,因为运行中的 Traefik 可能更新 ACME 文件。

需要保留证书状态时,在维护窗口短暂停止 Traefik,将 acme.json 复制到 root 私有目录,然后恢复 Traefik,再对这份副本执行加密备份。复制和重启过程也需要安排失败处理,确保入口服务恢复。

acme.json 包含 ACME 账号和证书私钥,副本保持 root 所有、0600,不放进公开仓库。恢复到正式服务目录时,再按项目要求设置为 20003:20003、0600。

没有这份备份时,可以重新申请证书,但可能遇到 CA 速率限制。Cloudflare 或 AliDNS 的续期凭据位于项目的 secrets 目录,会随项目配置备份;恢复后仍要确认这些凭据有效。

13.4 配置定时任务

首次备份成功后,再安装定时任务:

(
set -euo pipefail

sudo install -o root -g root -m 0644 \
  /app/file-code-stack/scripts/file-code-stack-backup.service \
  /etc/systemd/system/file-code-stack-backup.service

sudo install -o root -g root -m 0644 \
  /app/file-code-stack/scripts/file-code-stack-backup.timer \
  /etc/systemd/system/file-code-stack-backup.timer

sudo systemctl daemon-reload
sudo systemctl enable --now file-code-stack-backup.timer

sudo systemctl list-timers file-code-stack-backup.timer
sudo journalctl -u file-code-stack-backup.service --no-pager
)

定时器按北京时间每天 04:10 调度,并增加最多 10 分钟的随机延迟。它设置了 Persistent=true,机器关机期间错过的任务可能在下次启动后补执行,需要考虑这次停服对业务的影响。

失败会记录到 journal,不会自动发送通知。接入自己的监控,检查任务执行结果、后端恢复状态和最近一次成功快照,不能只确认 timer 处于启用状态。

保留策略可以先采用最近 7 个日备份、4 个周备份、6 个备份月份。脚本不自动执行 forget 或 prune,完成恢复演练后,再单独安排清理。

定期运行 restic check,并安排完整或分批的数据读取检查。读取量、时间和对象存储费用按仓库大小评估。检查命令同样需要先加载备份环境,restic 的完整说明可以参考其官方文档。

14 恢复演练与升级

恢复先在独立目录或新主机上进行,不覆盖运行中的数据。从同一个快照恢复 /app/file-code-stack、/data/otterio 和 /data/gitea,核对文件归属、秘密文件权限、部署参数和镜像摘要。

备份服务器需要的对象存储凭据和 restic 密码,也要能从源主机之外取得。恢复演练应包括这一步,不能只在原主机已经配置好环境的情况下测试。

隔离环境使用独立域名、端口和数据目录,关闭外部邮件、webhook 和镜像任务,避免演练向真实用户发信或修改外部仓库。不要让测试实例直接使用生产域名申请证书,也不要将生产环境的 /data 挂进测试容器。

按照演练环境修改参数后,重新生成配置并执行预检查,不直接沿用备份中的生成结果。启动前核对 Gitea 初始化锁,避免恢复实例重新开放安装页面。

SQLite、Git 仓库和配置按同一个快照恢复,不能只复制 gitea.db。恢复后检查管理员登录、匿名 clone、私有仓库访问限制,以及对象上传和下载。必要时按选定版本的说明重建 Gitea 内部 hooks,操作方法参考 Gitea 备份与恢复说明。

可以先将备份间隔设为 24 小时、恢复时间目标设为 4 小时。实际能够恢复到哪个时间点、需要多长时间,要在演练中记录。备份失败或任务延迟后,实际的数据丢失范围也会扩大。

升级前保存旧参数和镜像摘要,完成一致性备份,在隔离环境验证新镜像,再修改 .env,重新生成配置、通过预检查后更新容器。

Gitea 如果已经执行数据库迁移,只换回旧镜像可能无法启动,需要同时恢复升级前的完整快照。OtterIO 如果涉及存储格式变化,也要按发布说明处理。旧镜像在升级验收完成前保留。

发现服务异常或凭据泄露时,先限制受影响的公网访问,保留日志和磁盘快照,再处理凭据轮换。如果宿主机 root 或 Docker 管理权限已经被攻破,应从干净主机恢复,同时重新处理备份凭据、对象存储管理员凭据、DNS API Token 和 ACME 私钥。

14.1 OtterIO 版本升级操作

OtterIO 升级前,先运行一致性备份脚本,保存旧 .env、Compose、模板和镜像摘要。

把备份恢复到隔离环境,检查凭据加载、已有对象读取,以及实际使用的上传方式。使用分片上传、对象复制或大文件的程序,也要覆盖这些场景。删除测试只对测试数据执行。

核对新版本的发布说明、镜像清单和摘要后,更新 .env 中的 OTTERIO_IMAGE;如果发布说明要求调整服务参数,同时修改对应配置。

确认 /data 已挂载,凭据文件的 UID 和权限正确,再执行:

(
set -euo pipefail

cd /app/file-code-stack

sudo python3 scripts/render-config.py
sudo scripts/preflight.sh
sudo docker compose pull otterio
sudo docker compose up -d --no-deps otterio

sudo docker compose ps otterio
sudo docker compose logs --tail 100 otterio
)

容器启动后,再通过实际客户端验证上传和下载。compose ps 显示运行不代表这些功能已经通过检查。

日志出现凭据或权限错误时,检查对应文件和参数,不通过改成 root 用户或关闭只读根文件系统解决。入口脚本加载的凭据可能进入进程环境,Compose secrets 不能防住宿主机 root;排错时不要打印完整进程环境。

升级检查失败时,先暂停写入,再恢复旧配置和镜像摘要。如果新版本已经改变数据格式,需要同时恢复升级前快照,不能让旧版本继续读取未经确认的新数据。

降级能力以相关版本的发布说明和实际验证结果为准。缺少必要安全修复的版本,不长期重新开放公网。

15 开放访问前检查这些项目

完成部署后,从实际客户端检查以下内容:

检查 通过标准
外部端口扫描 仅预期 80/443;22 仅可信来源;后端端口无法直连
容器权限 非 root、Privileged=false、CapDrop ALL、no-new-privileges
防护机制 seccomp 默认、AppArmor docker-default 实际启用
挂载 无 Docker socket、宿主机根、SSH/云管理员凭据
新版启动 官方入口加载两个 secret;没有凭据冲突/默认凭据错误
对象与流式上传 测试对象可读;专用测试账号补齐分片权限后,分片上传和超过 64 MiB 的对象读写成功
文件下载 HEAD/GET 正常,MIME/下载头正确,Range 与校验文件通过
私有存储 匿名读取失败,应用账号不能跨桶访问
图片 GET/HEAD 成功、Content-Type 正确、缓存按对象设置
图片 PUT/DELETE/列桶 拒绝
S3 上传 仅白名单+专用签名;跨桶访问拒绝
签名安全 预签名 PUT 添加未签名复制头被拒绝,对象未改变
Gitea 注册禁用、安装锁启用、匿名 clone 成功、私有库拒绝
Git 发布 审查整个历史,没有秘密和未授权代码
CDN 正确 HTTPS 回源,源站 Host 匹配,不循环回源
日志 不记录签名 URL/token;日志轮转有效
备份 远端加密快照完成、计时恢复成功;中断后能恢复后端,并确认备份结果
重启 数据/策略/证书/账号仍在;磁盘挂载失败不误启动
压测 并发图片读取+Git clone 不 OOM,无异常错误率

端口检查从另一台机器进行,先安装 nmap,再扫描实际服务器地址:

nmap -Pn \
  -p 22,80,443,3000,9000,9001,13000,19000,19001,2375,2376,8080,8082,8443 \
  服务器IP

上面的命令是针对常见端口的快速检查,不能证明其他 TCP 端口没有开放。上线前再执行全 TCP 端口检查;SYN 扫描需要管理员权限:

sudo nmap -Pn -sS -p- 服务器IP

管理来源与非管理来源分别检查 SSH 的可达性。使用 IPv6 时,对实际 IPv6 地址增加 -6 重复快速检查和全 TCP 检查,不只在服务器上扫描自身。这里的检查范围是 TCP;如果另外启用了 UDP 服务,也要按实际协议检查。

16 遇到问题先看日志

先查看容器和系统日志:

cd /app/file-code-stack

sudo docker compose ps
sudo docker compose logs --tail 100
sudo journalctl -u docker --since '30 minutes ago' --no-pager
sudo journalctl -k --since '30 minutes ago' --no-pager

按具体错误继续排查:

问题 优先检查
403 路由 IP 白名单、上传账号策略、签名 Host/时钟
SignatureDoesNotMatch 公共域名签名、路径/查询是否被改写、SDK path-style
502 后端是否启动、网络名、服务 URL、内存 OOM
HTTP-01 证书失败 A/AAAA、80 入站、CDN 验证路径转发、ACME 文件权限
Cloudflare DNS-01 失败 权威 DNS、Token 的 Zone 范围和 DNS/Edit、Zone/Read 权限、秘密文件读取、TXT 传播、API 日志
AliDNS DNS-01 失败 权威 DNS、RAM 授权、秘密文件读取、TXT 传播、API 日志
Permission denied 对应 UID、secret 0400、目录可写位置、AppArmor 日志
Gitea 启动失败 是否 rootless 变体、20001 UID、config/data 布局
Git push 认证失败 使用权限正确的 token,非账号密码;仓库已创建
公网后端端口意外可达 Docker 实际 PortBindings、云防火墙 IPv4/IPv6

替换配置时保留原有归属和权限,避免文件已经存在,容器却无法读取。

需要查看 docker inspect 时,只输出排查所需的字段,避免将完整环境和秘密信息复制到日志或工单。先根据具体错误处理,不把 privileged、unconfined 或 chmod 777 当作通用修复。

其他

本文对应的配置固定到前面下载步骤中的提交,仓库采用 Apache-2.0 许可证。部署参数示例和模板分别位于 .env.example、traefik/templates/;运行时的 .env、secrets 和生成配置不提交 Git。容器内的软件仍遵守各自的许可证。

完整 Compose 配置

完整配置以仓库中的 compose.yaml为准,生成步骤和配置字段见配置说明。

Traefik 挂载生成的配置目录,健康检查只访问容器内的 127.0.0.1:8082,不增加公网端口。它用于检查 Traefik 自身是否响应,不能替代后端、证书和实际请求验收;容器显示 unhealthy,也不会因此自动重启。

最后

服务启动后,再从实际使用的程序检查上传、下载和 clone,也把数据挂载检查和恢复流程走一遍。后面有了访问量,再根据资源占用调整配额和缓存。

这套文件与代码服务先记录到这里。遇到问题时,把系统版本、镜像摘要、部署方式和复现步骤一起留下,会更容易继续排查。

—EOF