本文记录了在 VPS 服务器上部署各种服务的详细步骤,包括服务器初始化、Docker 环境配置、以及多个自托管服务的部署过程。

我的 VPS 使用的是 AlmaLinux 9 操作系统,所以以下操作都是基于 AlmaLinux 系统。

服务器设置

以下为初次登录 VPS 后的基础配置。AlmaLinux 9 默认开启 SELinux,使用 Nginx 反向代理时需在安装 Nginx 后执行后文中的 setsebool 命令。

设置主机名(可选)

hostnamectl set-hostname vps

配置 AlmaLinux 9 源(可选):先清理默认多份 .repo,再创建一份精简源文件(仅 BaseOS + AppStream)。

第一步:清理默认 yum 源文件

mkdir -p /etc/yum.repos.d/bak
mv /etc/yum.repos.d/*.repo /etc/yum.repos.d/bak/

第二步:新建精简 repo 文件

按机房位置选用下面一段,将 REPO_BASE 替换为对应域名后,整段复制执行(默认官方源、美国 AWS、国内阿里云三选一):

  • 默认官方源(适合美国等,Fastly CDN 加速):REPO_BASE=https://repo.almalinux.org
  • 美国 AWS:REPO_BASE=https://bos.aws.repo.almalinux.org(丹佛用 den 替换 bos
  • 国内阿里云:REPO_BASE=https://mirrors.aliyun.com
REPO_BASE=https://repo.almalinux.org

cat > /etc/yum.repos.d/almalinux.repo << EOF
[baseos]
name=AlmaLinux 9 - BaseOS
baseurl=$REPO_BASE/almalinux/9/BaseOS/\$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-AlmaLinux-9

[appstream]
name=AlmaLinux 9 - AppStream
baseurl=$REPO_BASE/almalinux/9/AppStream/\$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-AlmaLinux-9
EOF

清理缓存并重建缓存:

dnf clean all
dnf makecache

更新系统:

dnf update -y

安装常用软件:

dnf install git vim jq chrony -y

设置时钟同步(AlmaLinux 9 默认使用 chrony):

systemctl enable chronyd
systemctl start chronyd
chronyc makestep

设置时区为上海时区:

# 查看当前时区
timedatectl

# 设置时区为上海(Asia/Shanghai)
timedatectl set-timezone Asia/Shanghai

# 验证时区设置
timedatectl
date

配置 git:

git config --global user.name zhijunio
git config --global user.email zhijun.lab@gmail.com

[可选] 使普通用户拥有 root 级 sudo 权限

将用户加入 wheel 组即可获得等同 root 的 sudo 权限(AlmaLinux 9 默认配置为 %wheel ALL=(ALL) ALL):

# 新建用户(若已有用户可只执行 usermod 与 passwd)
useradd -m -s /bin/bash zhijunio
usermod -aG wheel zhijunio
passwd zhijunio

# 验证:切换为该用户后执行,应看到 (ALL) ALL 且 sudo whoami 输出 root
su - zhijunio
sudo -l
sudo whoami   # 应输出 root

之后可用 su - zhijunio 或 SSH 登录该用户,建议配置好 SSH 密钥后再考虑禁用 root 密码登录。

[可选] 设置系统 Swap 交换分区

因为 vps 服务器的运行内存很小,所以这里先设置下 Swap 空间为 4G。

sudo fallocate -l 4G /swapfile && \
sudo dd if=/dev/zero of=/swapfile bs=1024 count=4194304 && \
sudo chmod 600 /swapfile && \
sudo mkswap /swapfile && \
sudo swapon /swapfile && \
echo "/swapfile swap swap defaults 0 0" | sudo tee -a /etc/fstab && \
sudo swapon --show && \
sudo free -h

[可选] 安全加固

在确认已有可用的 SSH 密钥登录或普通用户可 sudo 之前,不要禁用密码登录或 root 登录,以免被锁在机器外。

  • SSH 密钥生成与上传(本地执行)

本机生成密钥对,并将公钥上传到 VPS,以便后续使用密钥登录。

# 生成 Ed25519 密钥对(推荐,比 RSA 更短更安全)
ssh-keygen -t ed25519 -C "your_email@example.com" -f ~/.ssh/id_ed25519_vps

# 提示输入 passphrase 可留空,或设置后每次使用密钥时需输入

上传公钥到 VPS(任选一种方式):

# 方式一:ssh-copy-id(需当前能密码登录 VPS)
ssh-copy-id -i ~/.ssh/id_ed25519_vps.pub root@your_vps_ip
# 若登录用户为普通用户:ssh-copy-id -i ~/.ssh/id_ed25519_vps.pub user@your_vps_ip
# 方式二:手动追加(将本机公钥内容追加到 VPS 的 authorized_keys)
# 本机查看公钥:cat ~/.ssh/id_ed25519_vps.pub
# 在 VPS 上执行:mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "粘贴公钥内容" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys

上传后务必先测试密钥登录是否成功(新开一个终端窗口,密码登录不要关),再在服务端禁用密码登录:

ssh -i ~/.ssh/id_ed25519_vps root@your_vps_ip
# 若使用非默认密钥路径,可在 ~/.ssh/config 中配置 Host 与 IdentityFile
  • SSH 仅允许密钥登录:确认密钥登录无误后,在服务端用命令修改 /etc/ssh/sshd_config 并重启:
# 备份并设置:禁用密码登录、启用公钥登录
sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
sudo sed -i "s/^#*PasswordAuthentication.*/PasswordAuthentication no/; s/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/" /etc/ssh/sshd_config
grep -q "^PasswordAuthentication " /etc/ssh/sshd_config || echo "PasswordAuthentication no" | sudo tee -a /etc/ssh/sshd_config
grep -q "^PubkeyAuthentication " /etc/ssh/sshd_config || echo "PubkeyAuthentication yes" | sudo tee -a /etc/ssh/sshd_config
sudo systemctl restart sshd
  • 禁用 root 密码登录(仅允许 root 密钥或改用普通用户):确认密钥或普通用户可用后,用命令二选一:
# 仅允许 root 使用密钥登录(推荐)
sudo sed -i "s/^#*PermitRootLogin.*/PermitRootLogin prohibit-password/" /etc/ssh/sshd_config
grep -q "^PermitRootLogin " /etc/ssh/sshd_config || echo "PermitRootLogin prohibit-password" | sudo tee -a /etc/ssh/sshd_config
sudo systemctl restart sshd
# 禁止 root 登录,仅用普通用户 + sudo
sudo sed -i "s/^#*PermitRootLogin.*/PermitRootLogin no/" /etc/ssh/sshd_config
grep -q "^PermitRootLogin " /etc/ssh/sshd_config || echo "PermitRootLogin no" | sudo tee -a /etc/ssh/sshd_config
sudo systemctl restart sshd
  • 安装 fail2ban 防暴力破解
dnf install epel-release -y
dnf install fail2ban fail2ban-firewalld -y
systemctl enable fail2ban --now
# 默认会保护 sshd,可查看状态:fail2ban-client status sshd

安装 Nginx

AlmaLinux 9 默认仓库不含 Nginx 或版本较旧,需要额外添加 Nginx 官方 yum 源,再用 dnf install nginx 安装(本文采用包管理安装,不从源码编译)。

# 添加 Nginx 官方 yum 源(适用于 RHEL 9 / AlmaLinux 9)
cat > /etc/yum.repos.d/nginx.repo << "EOF"
[nginx-stable]
name=nginx stable repo
baseurl=https://nginx.org/packages/rhel/9/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true
EOF

# 若系统已装 AlmaLinux 自带的 nginx-core,先卸载以免与官方 nginx 包冲突
dnf remove -y nginx-core nginx-filesystem nginx-mod-* 2>/dev/null || true

dnf install nginx -y

systemctl enable nginx
systemctl start nginx

打开防火墙,开放 443 端口:

dnf install firewalld -y

sudo firewall-cmd --permanent --add-port=443/tcp
sudo firewall-cmd --reload

使用反向代理需要打开 SELinux 网络访问权限:

setsebool -P httpd_can_network_connect on

安装并生成证书

域名托管在 CloudFlare,使用 acme.sh 通过 DNS API 自动申请和续期 SSL 证书。参考 acme.sh DNS API 文档

准备工作

  1. 在 CloudFlare 获取 API Token(推荐)或 Global API Key;使用 Token 时建议同时设置 Account ID
  2. 创建 SSL 证书存储目录

以下命令使用 root 用户执行:

# 邮箱统一使用变量(Git、acme.sh 注册与 CloudFlare 共用)
export GIT_EMAIL=zhijun.lab@gmail.com

# 使用 API Token(推荐)+ Account ID
export CF_Token="your_api_token"
export CF_Account_ID="your_account_id"
# 或使用 Global API Key:export CF_Key=xxx(邮箱会使用上面的 GIT_EMAIL)

curl https://get.acme.sh | sh -s email=$GIT_EMAI

# 申请证书(支持通配符域名)
~/.acme.sh/acme.sh --issue --server letsencrypt --dns dns_cf -d zhijun.io -d "*.zhijun.io"

# 复制证书到 Nginx 目录
mkdir -p /etc/nginx/ssl
cp ~/.acme.sh/zhijun.io_ecc/{zhijun.io.cer,zhijun.io.key,fullchain.cer,ca.cer} /etc/nginx/ssl/

# 安装证书并设置自动续期
~/.acme.sh/acme.sh --installcert -d zhijun.io -d *.zhijun.io \
  --cert-file /etc/nginx/ssl/zhijun.io.cer \
  --key-file /etc/nginx/ssl/zhijun.io.key \
  --fullchain-file /etc/nginx/ssl/fullchain.cer \
  --ca-file /etc/nginx/ssl/ca.cer \
  --reloadcmd "nginx -s reload"

注意:证书会自动续期,续期命令已添加到 crontab 中。

Docker 安装和配置

一键安装docker和docker compose:

bash <(curl -sSL https://linuxmirrors.cn/docker.sh)

启动 Docker 服务:

systemctl enable docker
systemctl start docker

# 验证安装
docker --version
docker ps

查看docker配置文件,默认会给我们配置刚才选择的镜像加速地址

cat /etc/docker/daemon.json

设置 iptables 允许流量转发:

iptables -P FORWARD ACCEPT

参考 Best Practice: Use a Docker network,创建一个自定义的网络:

docker network create vps

查看 docker 网络:

docker network ls
NETWORK ID     NAME            DRIVER    SCOPE
68f4aeaa57bd   bridge          bridge    local
6a96b9d8617e   vps          bridge    local
4a8679e35f4d   host            host      local
ba21bef23b04   none            null      local

注意:bridge、host、none 是内部预先创建的网络。

是否需要暴露端口:需要。Nginx 装在宿主机上,通过 proxy_pass http://127.0.0.1:端口 反代到容器,因此各服务的 compose 里要写 ports 映射到宿主机。为减少误暴露,建议只绑定本机:写成 127.0.0.1:3003:3000 而不是 3003:3000,这样外网无法直连该端口;防火墙只放行 80/443 即可,其余端口仅本机访问。

Docker 服务部署

Postgres

PostgreSQL 是一个功能强大的开源关系型数据库管理系统,作为其他服务的数据存储后端。

创建 postgres.yaml 文件:

services:
   postgres:
      image: postgres:18
      restart: always
      ports:
        - "5435:5432"
      environment:
        POSTGRES_DB: postgres
        POSTGRES_USER: postgres
        POSTGRES_PASSWORD: vps@027!
        TZ: Asia/Shanghai
      healthcheck:
        test:
          ["CMD", "pg_isready", "-U", "postgres", "-d", "postgres"]
        interval: 5s
        timeout: 10s
        retries: 5
      networks:
        - vps

networks:
   vps:
      external: true

启动服务:

docker compose -f postgres.yaml up -d

# 查看日志
docker logs -f postgres

# 验证数据库连接
docker exec -it postgres psql -U postgres -d postgres

如果开启了防火墙,开放 5432 端口:

sudo firewall-cmd --permanent --add-port=5432/tcp
sudo firewall-cmd --reload

Umami

Umami 是一个简单、快速、注重隐私的网站分析工具,可以作为 Google Analytics 的开源替代品。

  1. 在 postgres 容器创建 umami 数据库:
docker exec postgres psql -U postgres -c "CREATE DATABASE umami OWNER postgres;"
  1. 通过 docker compose 安装,创建 umami.yaml
services:
  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: umami
    ports:
      - "3003:3000"
    environment:
      DATABASE_URL: postgresql://postgres:vps@027!@postgres:5432/umami
      DATABASE_TYPE: postgresql
      HASH_SALT: vps@2025
      TRACKER_SCRIPT_NAME: random-string.js
      TZ: "Asia/Shanghai"
      GENERIC_TIMEZONE: "Asia/Shanghai"
    networks:
      - vps
    restart: always

networks:
   vps:
    external: true

参考 https://eallion.com/umami/,Umami 的默认跟踪代码是被大多数的广告插件屏蔽的,被屏蔽了你就统计不到访客信息了。如果需要反屏蔽,需要在 docker compose.yml 文件中添加环境变量:TRACKER_SCRIPT_NAME,如:

    environment:
      TRACKER_SCRIPT_NAME: random-string.js

然后获取到的跟踪代码的 src 会变成:

srcipt.js => random-string.js

启动:

docker compose -f umami.yaml up -d
docker logs -f umami
  1. 设置自定义域名:umami.zhijun.io

  2. 配置 nginx 配置文件 /etc/nginx/conf.d/umami.conf

server {
    listen 80;
    listen [::]:80;
    server_name umami.zhijun.io;

    return 301 https://$host$request_uri;
}

server {
    listen          443 ssl;
    server_name     umami.zhijun.io;

    ssl_certificate      /etc/nginx/ssl/fullchain.cer;
    ssl_certificate_key  /etc/nginx/ssl/zhijun.io.key;

    ssl_session_cache    shared:SSL:1m;
    ssl_session_timeout  5m;

    ssl_ciphers  HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers  on;

    location / {
        proxy_pass http://127.0.0.1:3003;
    
        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;
    
        add_header X-Cache $upstream_cache_status;
        add_header Cache-Control no-cache;
        expires 12h;
    }
}
  1. 重新加载 Nginx 配置并访问服务:
nginx -t
nginx -s reload

访问 https://umami.zhijun.io/,默认用户名和密码为 admin/umami。登录之后,请立即修改密码,并添加要统计的网站。

Memos

Memos 是一个开源的、自托管的备忘录和知识管理系统,支持 Markdown、标签、搜索等功能。

  1. 在 postgres 容器创建 memos 数据库:
docker exec postgres psql -U postgres -c "CREATE DATABASE memos OWNER postgres;"
  1. 通过 docker compose 安装,创建 memos.yaml
services:
  memos:
    image: neosmemo/memos:0.18.2
    container_name: memos
    environment:
      - MEMOS_DRIVER=postgres
      - MEMOS_DSN=postgres://postgres:vps@027!@postgres:5432/memos?sslmode=disable
    volumes:
      - ~/.memos/:/var/opt/memos
    ports:
      - "5230:5230"
    networks:
      - vps

networks:
  vps:
    external: true

版本说明

  • 0.21.0 版本:支持 Mermaid、分享功能
  • 0.22.0 版本:去掉了分享功能
  • 0.24.0 版本:支持 Shortcuts
  • 0.24.1 版本:改变了布局
  1. 启动服务:
docker compose -f memos.yaml up -d
docker logs -f memos
  1. 配置 nginx 配置文件 /etc/nginx/conf.d/memos.conf
server {
    listen 80;
    listen [::]:80;
    server_name memos.zhijun.io;

    return 301 https://$host$request_uri;
}

server {
    listen          443 ssl;
    server_name     memos.zhijun.io;

    ssl_certificate      /etc/nginx/ssl/fullchain.cer;
    ssl_certificate_key  /etc/nginx/ssl/zhijun.io.key;

    ssl_session_cache    shared:SSL:1m;
    ssl_session_timeout  5m;

    ssl_ciphers  HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers  on;

    location / {
        proxy_pass http://127.0.0.1:5230;
        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;
    
    }
}

重新加载 Nginx 配置并访问服务:

nginx -s reload

访问 https://memos.zhijun.io/ ,默认用户名和密码为 admin/memos。登录之后,请立即修改密码。

N8n

N8n 是一个开源的工作流自动化工具,可以通过可视化界面创建自动化工作流,连接各种服务和 API。

  1. 在 postgres 容器创建 n8n 数据库:
docker exec postgres psql -U postgres -c "CREATE DATABASE n8n OWNER postgres;"
  1. 通过 docker compose 安装,创建 n8n.yaml
services:
  n8n:
    image: n8nio/n8n
    container_name: n8n
    restart: always
    environment:
      - NODE_ENV=production
      - DB_TYPE=postgresdb
      - DB_POSTGRESDB_HOST=postgres
      - DB_POSTGRESDB_PORT=5432
      - DB_POSTGRESDB_DATABASE=n8n
      - DB_POSTGRESDB_USER=postgres
      - DB_POSTGRESDB_PASSWORD=vps@027!
      - TZ="Asia/Shanghai"
      - GENERIC_TIMEZONE="Asia/Shanghai"
      - N8N_DEFAULT_LOCALE=zh-CN 

      # 是否启用诊断和个性化推荐
      - N8N_DIAGNOSTICS_ENABLED=false
      - N8N_PERSONALIZATION_ENABLED=false
      # 是否在启动时重新安装缺失的包
      - N8N_REINSTALL_MISSING_PACKAGES=true

      - WEBHOOK_URL=https://n8n.zhijun.io/
      - N8N_HOST=n8n.zhijun.io
      - N8N_ENCRYPTION_KEY=2cc5b5f9e31b43ff3817c1d04e5fa735
    ports:
      - "5678:5678"
    networks:
      - vps

networks:
  vps:
    external: true
  1. 启动服务:
docker compose -f n8n.yaml up -d
docker logs -f n8n
  1. 配置 nginx 配置文件 /etc/nginx/conf.d/n8n.conf
server {
    listen 80;
    listen [::]:80;
    server_name n8n.zhijun.io;

    return 301 https://$host$request_uri;
}

server {
    listen          443 ssl;
    server_name     n8n.zhijun.io;

    ssl_certificate      /etc/nginx/ssl/fullchain.cer;
    ssl_certificate_key  /etc/nginx/ssl/zhijun.io.key;

    ssl_session_cache    shared:SSL:1m;
    ssl_session_timeout  5m;

    ssl_ciphers  HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers  on;

    location / {
      proxy_pass http://127.0.0.1:5678;
      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_http_version 1.1;
      proxy_set_header Connection "upgrade";
      proxy_set_header Upgrade $http_upgrade;     
      proxy_set_header Origin "https://n8n.zhijun.io";
      access_log /var/log/nginx/n8n.log combined buffer=128k flush=5s;
    }
}

故障排查:如果出现 Origin header does NOT match the expected origin. (Origin: "n8n.zhijun.io", Expected: "127.0.0.1:5678") 错误,需要在 nginx 配置里添加 proxy_set_header Origin "https://n8n.zhijun.io";

  1. 重新加载 Nginx 配置并访问服务:
nginx -t
nginx -s reload
  1. 创建工作流(Workflows):

参考这篇文章 http://stiles.cc/archives/237/ ,目前我配置了以下 workflows,实现了 github、douban、rss、memos 同步到 Telegram。

workflows 参考:

Linkding

一款自托管的书签管理器,设计简洁、快速且易于设置。

创建数据库

docker exec postgres psql -U postgres -c "CREATE DATABASE linkding OWNER postgres;"

创建 docker-compose 文件 linkding.yaml

services:
  linkding:
    #image: woohoodai/linkding-cn:latest
    image: sissbruecker/linkding
    container_name: linkding
    restart: unless-stopped
    environment:
      - LD_SUPERUSER_NAME=admin
      - LD_SUPERUSER_PASSWORD=E2KWxxxx
      - LD_DB_ENGINE=postgres
      - LD_DB_DATABASE=linkding
      - LD_DB_HOST=postgres
      - LD_DB_USER=postgres
      - LD_DB_PASSWORD=vps@027!
    ports:
      - "9090:9090"
    networks:
      - vps

networks:
  vps:
    external: true

启动服务:

docker compose -f linkding.yaml up -d
docker logs -f linkding

配置 nginx 配置文件 /etc/nginx/conf.d/linkding.conf

server {
    listen 80;
    listen [::]:80;
    server_name linkding.zhijun.io;

    return 301 https://$host$request_uri;
}

server {
    listen          443 ssl;
    server_name     linkding.zhijun.io;

    ssl_certificate      /etc/nginx/ssl/fullchain.cer;
    ssl_certificate_key  /etc/nginx/ssl/zhijun.io.key;

    ssl_session_cache    shared:SSL:1m;
    ssl_session_timeout  5m;

    ssl_ciphers  HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers  on;

    location / {
      proxy_pass http://127.0.0.1:9090;
      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_http_version 1.1;
      proxy_set_header Connection "upgrade";
      proxy_set_header Upgrade $http_upgrade;     

      access_log /var/log/nginx/linkding.log combined buffer=128k flush=5s;
    }
}

重新加载 Nginx 配置并访问服务:

nginx -t
nginx -s reload

登录之后进行设置 Auto Tagging:

youtube.com youtube video
github.com github
bilibili.com bilibili video

完整的备份:

docker exec -it linkding python manage.py full_backup /etc/linkding/data/backup.zip
docker cp linkding:/etc/linkding/data/backup.zip backup.zip

使用:

  • 查询上周书签列表,返回 markdown 地址
start=$(date -v-1w -v-mon +%Y-%m-%dT00:00:00Z)
end=$(date -v-mon +%Y-%m-%dT00:00:00Z)

curl -sS -H "Authorization: Token ${LINKDING_TOKEN}" \
  "https://linkding.zhijun.io/api/bookmarks/?limit=100" \
  | jq -r --arg start "$start" --arg end "$end" '
      .results[]?
      | select(.date_added >= $start and .date_added < $end)
      | "- [" + (.title // "无标题") + "](" + .url + ")"
    '

Artalk

在 postgres 容器创建 artalk 数据库:

docker exec postgres psql -U postgres -c "CREATE DATABASE artalk OWNER postgres;"

通过 docker compose 安装,创建 artalk.yaml

services:
  artalk:
    container_name: artalk
    image: artalk/artalk-go
    restart: unless-stopped
    ports:
      - "23366:23366"
    environment:
      - TZ=Asia/Shanghai
      - ATK_LOCALE=zh-CN
      - ATK_SITE_DEFAULT=ZhiJun Blog
      - ATK_SITE_URL=https://blog.zhijun.io
      - ATK_TRUSTED_DOMAINS=https://blog.zhijun.io http://localhost:1313
      - ATK_DB_TYPE=pgsql
      - ATK_DB_HOST=postgres
      - ATK_DB_PORT=5432
      - ATK_DB_NAME=artalk
      - ATK_DB_USER=postgres
      - ATK_DB_PASSWORD=vps@027!
      - ATK_ADMIN_NOTIFY_BARK_ENABLED=true
      - ATK_ADMIN_NOTIFY_BARK_SERVER=https://bark.zhijun.io/AXWM5ZKnKa9Lp3rijBsVDm
      - ATK_ADMIN_NOTIFY_TELEGRAM_ENABLED=true
      - ATK_ADMIN_NOTIFY_TELEGRAM_API_TOKEN=8286091453:AAE2pucQXfliz_QGac1zVZEFIrrxVOLe938
      - ATK_ADMIN_NOTIFY_TELEGRAM_RECEIVERS=[-1001632154815]
    networks:
      - vps

networks:
  vps:
    external: true

启动服务:

docker compose -f artalk.yaml up -d
docker logs -f artalk

配置 nginx 配置文件 /etc/nginx/conf.d/artalk.conf

server {
    listen 80;
    listen [::]:80;
    server_name artalk.zhijun.io;

    return 301 https://$host$request_uri;
}

server {
    listen          443 ssl;
    server_name     artalk.zhijun.io;

    ssl_certificate      /etc/nginx/ssl/fullchain.cer;
    ssl_certificate_key  /etc/nginx/ssl/zhijun.io.key;

    ssl_session_cache    shared:SSL:1m;
    ssl_session_timeout  5m;

    ssl_ciphers  HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers  on;

    location / {
      proxy_pass http://127.0.0.1:23366;

      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;
    }
}

重新加载 Nginx 配置:

nginx -t
nginx -s reload

执行命令创建管理员账户:

docker exec -it artalk artalk admin

浏览器输入 http://artalk.zhijun.io 进入 Artalk 后台登录界面。

Wakapi

在 postgres 容器创建 wakapi 数据库:

docker exec postgres psql -U postgres -c "CREATE DATABASE wakapi OWNER postgres;"

通过 docker compose 安装,创建 wakapi.yaml

services:
  wakapi:
    image: ghcr.io/muety/wakapi
    container_name: wakapi
    restart: always
    ports:
      - "3020:3000"
    environment:
      - WAKAPI_PASSWORD_SALT=zhijunio
      - WAKAPI_DB_TYPE=postgres
      - WAKAPI_DB_NAME=wakapi
      - WAKAPI_DB_PORT=5432
      - WAKAPI_DB_HOST=postgres
      - WAKAPI_DB_USER=postgres
      - WAKAPI_DB_PASSWORD=vps@027!
    volumes:
      - wakapi_data:/data
    networks:
      - vps

volumes:
  wakapi_data:

networks:
  vps:
    external: true

启动服务:

docker compose -f wakapi.yaml up -d
docker logs -f wakapi

配置 nginx 配置文件 /etc/nginx/conf.d/wakapi.conf

server {
    listen 80;
    listen [::]:80;
    server_name wakapi.zhijun.io;

    return 301 https://$host$request_uri;
}

server {
    listen          443 ssl;
    server_name     wakapi.zhijun.io;

    ssl_certificate      /etc/nginx/ssl/fullchain.cer;
    ssl_certificate_key  /etc/nginx/ssl/zhijun.io.key;

    ssl_session_cache    shared:SSL:1m;
    ssl_session_timeout  5m;

    ssl_ciphers  HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers  on;

    location / {
      proxy_pass http://127.0.0.1:3020;

      proxy_set_header Host $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 REMOTE-HOST $remote_addr;
      proxy_buffering off;
    }
}

重新加载 Nginx 配置:

nginx -t
nginx -s reload

Uptime

Uptime Kuma 是一个易于使用的自托管监控工具,可以监控网站和服务的可用性。

项目地址:https://github.com/louislam/uptime-kuma

使用 docker compose 部署,创建 uptime.yaml

services:
  uptime:
    image: louislam/uptime-kuma:2
    container_name: uptime
    volumes:
      - ~/.uptime-kuma://app/data
    ports:
      - "3001:3001" 
    restart: always
    networks:
      - vps

networks:
  vps:
    external: true

启动服务:

docker compose -f uptime.yaml up -d
docker logs -f uptime

配置 nginx 配置文件 /etc/nginx/conf.d/uptime.conf

server {
    listen 80;
    listen [::]:80;
    server_name uptime.zhijun.io;

    return 301 https://$host$request_uri;
}

server {
    listen          443 ssl;
    server_name     uptime.zhijun.io;

    ssl_certificate      /etc/nginx/ssl/fullchain.cer;
    ssl_certificate_key  /etc/nginx/ssl/zhijun.io.key;

    ssl_session_cache    shared:SSL:1m;
    ssl_session_timeout  5m;

    ssl_ciphers  HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers  on;

    location / {
        proxy_pass http://127.0.0.1:3001;
        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_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
    }
}

重新加载 Nginx 配置并访问服务:

nginx -t
nginx -s reload

访问 https://uptime.zhijun.io/ ,首次访问需要设置管理员账号。

升级服务

docker compose -f uptime.yaml down
docker pull louislam/uptime-kuma:2
docker compose -f uptime.yaml up -d

Bark

Bark 是一个 iOS 应用,允许你向 iPhone 推送自定义通知。通过自建服务器,可以实现消息推送的完全控制。

项目地址:https://github.com/Finb/Bark

  1. 服务端使用 Docker 安装:
docker run -dt --name bark -p 127.0.0.1:3020:8080 -v ~/.bark/data://data finab/bark-server
  1. 配置 nginx 配置文件 /etc/nginx/conf.d/bark.conf
server {
    listen 80;
    listen [::]:80;
    server_name bark.zhijun.io;

    return 301 https://$host$request_uri;
}

server {
    listen          443 ssl;
    server_name     bark.zhijun.io;

    ssl_certificate      /etc/nginx/ssl/fullchain.cer;
    ssl_certificate_key  /etc/nginx/ssl/zhijun.io.key;

    ssl_session_cache    shared:SSL:1m;
    ssl_session_timeout  5m;

    ssl_ciphers  HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers  on;

    location / {
      proxy_pass http://127.0.0.1:3020;
    
     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;
    }
}
  1. 重新加载 Nginx 配置:
nginx -t
nginx -s reload
  1. 在 iOS 上安装 Bark 应用,添加私有服务器:`http://bark.zhijun.io

  2. 测试推送功能:

# 替换 <device_key> 为你的设备密钥
curl -X "POST" "https://bark.zhijun.io/<device_key>" \
     -H "Content-Type: application/json; charset=utf-8" \
     -d '{
  "body": "这是一个测试",
  "title": "Test Title",
  "badge": 1,
  "category": "myNotificationCategory",
  "icon": "https://www.usememos.com/logo-rounded.png",
  "group": "test"
}'

Docker 服务运维

容器运维

通过 docker 重启容器:

docker restart $(docker ps -q)

#或者重启所有容器(包括已停止的):
docker restart $(docker ps -aq)

通过 docker compose 文件重启:

docker compose -f rsshub.yaml down
docker compose -f n8n.yaml down
docker compose -f linkding.yaml down
docker compose -f umami.yaml down
docker compose -f uptime.yaml down
docker compose -f artalk.yaml down
docker compose -f memos.yaml down

docker compose -f rsshub.yaml up -d
docker compose -f n8n.yaml up -d
docker compose -f linkding.yaml up -d
docker compose -f umami.yaml up -d
docker compose -f uptime.yaml up -d
docker compose -f artalk.yaml up -d
docker compose -f memos.yaml up -d

查看内容使用情况:

$ docker stats --no-stream --format "table {{.Name}}\t{{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
NAME                 CONTAINER      CPU %     MEM USAGE / LIMIT     MEM %
rsshub               97ca49881678   0.00%     213.4MiB / 512MiB     41.68%
redis          39164aeb6228   0.31%     35.52MiB / 128MiB     27.75%
postgres             f46745208c1d   0.00%     45.6MiB / 1GiB        4.45%
n8n                  338fbdac295d   0.37%     197.5MiB / 512MiB     38.57%
linkding             a0e10cc27e18   0.14%     109.1MiB / 256MiB     42.60%
umami                80261017d1af   0.02%     9.277MiB / 256MiB     3.62%
uptime-kuma          a4fa6a45a8d6   0.00%     32.17MiB / 256MiB     12.57%
artalk               f9a10453002b   0.07%     11.23MiB / 256MiB     4.39%
memos                363b0964fad8   0.00%     13.02MiB / 4.055GiB   0.31%

数据库备份

先下载 rclone

curl -OfsS https://downloads.rclone.org/rclone-current-linux-amd64.zip 
unzip -oq rclone-current-linux-amd64.zip 
sudo mv rclone-*-linux-amd64/rclone /usr/local/bin/ 
sudo chmod +x /usr/local/bin/rclone

设置配置文件:

mkdir -p ~/.config/rclone

cat > ~/.config/rclone/rclone.conf << "EOF"
[r2]
type = s3
provider = Cloudflare
access_key_id = 4201cbab7617760d89d1837a93415f1d
secret_access_key = xxxxxxx
endpoint = https://2e1980a0ef43f57b920ea28cdf9d38d1.r2.cloudflarestorage.com
acl = private
no_check_bucket = true
EOF

备份脚本:/opt/vps-backup/scripts/backup_database.sh

#!/bin/bash
set -euo pipefail

CONTAINER_NAME="postgres"
BACKUP_BASE="/data/backup"
BACKUP_DIR="$BACKUP_BASE/postgres"
DATE=$(date +%Y%m%d_%H00)   # 如 20260303_1601
DATABASES=(memos umami linkding artalk wakapi n8n)

mkdir -p "$BACKUP_DIR"

SQL_FILES=()
for DB_NAME in "${DATABASES[@]}"; do
  BACKUP_FILE="${DB_NAME}_${DATE}.sql"
  docker exec "$CONTAINER_NAME" pg_dump -U postgres "$DB_NAME" > "$BACKUP_DIR/$BACKUP_FILE"
  SQL_FILES+=("$BACKUP_FILE")
  echo "DB $DB_NAME backup success: $BACKUP_FILE"
done

# 将本次所有 .sql 打成一个 tar.gz(仍用 gzip 压缩)
ARCHIVE="${CONTAINER_NAME}_${DATE}.tar.gz"
tar -czf "$BACKUP_DIR/$ARCHIVE" -C "$BACKUP_DIR" "${SQL_FILES[@]}"
echo "Archive: $ARCHIVE"

# 已打包后可删本次 .sql,避免占双倍空间;若要在 git 里保留明文 sql 请注释下面两行
for f in "${SQL_FILES[@]}"; do
  rm -f "$BACKUP_DIR/$f"
done

# 删除超过 1 天的 tar.gz(按修改时间)
find "$BACKUP_DIR" -maxdepth 1 -type f -name "${CONTAINER_NAME}_*.tar.gz" -mtime +1 -delete

rclone sync $BACKUP_DIR r2:backup/postgres -P

N8n 备份脚本:/opt/vps-backup/scripts/backup_n8n.sh

#!/bin/bash
set -euo pipefail

BACKUP_BASE="/data/backup"
BACKUP_DIR="$BACKUP_BASE/n8n"
WORKFLOWS_DIR="$BACKUP_DIR/workflows"
DATE=$(date +%Y%m%d_%H00)   # 如 20260303_1600

mkdir -p $BACKUP_DIR/workflows

# 导出工作流到容器临时目录,再拷出并按名称重命名
WORKFLOWS_TMP=$(mktemp -d)
trap "rm -rf "$WORKFLOWS_TMP"" EXIT
docker exec -u node n8n n8n export:workflow --backup --output=./backup_workflows
docker cp n8n:/home/node/backup_workflows/. "$WORKFLOWS_TMP"
for f in "$WORKFLOWS_TMP"/*; do
  [ -f "$f" ] || continue
  name=$(jq -r ".name" "$f" 2>/dev/null)
  echo "$f $name"
  [ -n "$name" ] && [ "$name" != "null" ] && command mv -f "$f" "$BACKUP_DIR/workflows/${name}.json"
done

# 导出凭证到当日目录
docker exec -u node n8n n8n export:credentials --all --output=./credentials.json
docker cp n8n:/home/node/credentials.json "$BACKUP_DIR/credentials.json"

rclone sync $BACKUP_DIR r2:backup/n8n -P

清理表数据

在确认无需保留数据的前提下,在目标库中执行:

  • 清空单张表(保留表结构,重置自增等):

    docker exec "$CONTAINER_NAME" psql -U postgres -d "$DB_NAME" -c "TRUNCATE TABLE 表名;"
  • 清空单表并连同依赖该表外键的表一起清空

    docker exec "$CONTAINER_NAME" psql -U postgres -d "$DB_NAME" -c "TRUNCATE TABLE 表名 CASCADE;"
  • 清空当前库下 public schema 中所有表(慎用):

    docker exec "$CONTAINER_NAME" psql -U postgres -d "$DB_NAME" -c "
    DO \$\$
    DECLARE r RECORD;
    BEGIN
      FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = "public")
      LOOP
        EXECUTE "TRUNCATE TABLE " || quote_ident(r.tablename) || " CASCADE;";
      END LOOP;
    END \$\$;
    "

执行前建议先备份或确认库名、表名无误。

在容器内用 psql 查指定库中每个表的行数(以下为估算值,来自 pg_stat_user_tables,大表足够参考):

CONTAINER_NAME="postgres"
DB_NAME="n8n"   # 改成要查的库名

docker exec "$CONTAINER_NAME" psql -U postgres -d "$DB_NAME" -c "
SELECT schemaname AS schema,
       relname AS table_name,
       n_live_tup AS row_estimate
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;
"

若要精确行数(大表较慢),可先生成再执行:

docker exec "$CONTAINER_NAME" psql -U postgres -d "$DB_NAME" -t -A -c "
SELECT "SELECT count(*) AS \"" || relname || "\" FROM " || quote_ident(schemaname) || "." || quote_ident(relname) || ";"
FROM pg_stat_user_tables;
"

把输出在 psql -d "$DB_NAME" 里执行即可。

数据库恢复

按备份时间戳恢复(备份目录 postgres/,文件命名:<库名>_<YYYYMMDDHH>.sql)。将 BACKUP_POSTFIX 改为要恢复的那次备份时间(如 2026030316),在备份仓库根或指定目录下执行。

先停止服务:

docker compose -f n8n.yaml down
docker compose -f linkding.yaml down
docker compose -f umami.yaml down
docker compose -f artalk.yaml down
docker compose -f memos.yaml down

再恢复数据库:

#!/usr/bin/env bash
set -euo pipefail

CONTAINER_NAME="postgres"
BACKUP_DIR="/opt/vps-backup/postgres"
BACKUP_POSTFIX="2026031809"   # 改为实际备份时间
DATABASES=(memos umami linkding artalk)

# 判断数据库是否存在(存在返回 1,不存在返回 0)
db_exists() {
  local db="$1"
  docker exec "$CONTAINER_NAME" psql -U postgres -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = "$db";" 2>/dev/null | grep -q 1
}

for DB_NAME in "${DATABASES[@]}"; do
  F="${DB_NAME}_${BACKUP_POSTFIX}.sql"
  if [ ! -f "$BACKUP_DIR/$F" ]; then
    echo "跳过(文件不存在): $F"
    continue
  fi

  docker cp "$BACKUP_DIR/$F" "$CONTAINER_NAME:/tmp/$F"

  if db_exists "$DB_NAME"; then
    docker compose -f /opt/vps-backup/dockerfile/$DB_NAME.yaml down
    # 数据库已存在:禁止新连接、断开已有连接、删库、重建
    docker exec "$CONTAINER_NAME" psql -U postgres -d postgres -c "ALTER DATABASE $DB_NAME CONNECT LIMIT 0;" 2>/dev/null || true
    docker exec "$CONTAINER_NAME" psql -U postgres -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = "$DB_NAME" AND pid <> pg_backend_pid();" 2>/dev/null || true
    sleep 1
    docker exec "$CONTAINER_NAME" psql -U postgres -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;"
    docker exec "$CONTAINER_NAME" psql -U postgres -d postgres -c "CREATE DATABASE $DB_NAME OWNER postgres;"
  else
    # 数据库不存在:直接新建
    docker exec "$CONTAINER_NAME" psql -U postgres -d postgres -c "CREATE DATABASE $DB_NAME OWNER postgres;"
  fi

  docker exec "$CONTAINER_NAME" psql -U postgres -d "$DB_NAME" -f "/tmp/$F"
  docker exec "$CONTAINER_NAME" rm -f "/tmp/$F"
  echo "已恢复: $DB_NAME"
done

注意:n8n 数据量太大,新建数据库,然后通过重新导入 workflow 文件来恢复。

docker exec postgres psql -U postgres -c "CREATE DATABASE n8n OWNER postgres;"

然后启动 n8n 服务:

docker compose -f n8n.yaml up -d

恢复n8n工作流

BACKUP_DIR="/opt/vps-backup/n8n"

docker cp "$BACKUP_DIR/workflows" n8n:/home/node/restore_workflows
docker exec -u node n8n n8n import:workflow --separate --input=/home/node/restore_workflows
docker exec -u node n8n rm -rf /home/node/restore_workflows

恢复n8n凭证

docker cp "$BACKUP_DIR/$DATE/credentials.json" n8n:/home/node/
docker exec -u node n8n n8n import:credentials --input=/home/node/credentials.json
docker exec -u node n8n rm -f /home/node/credentials.json

说明:

若当前库中已有同 ID 的工作流或凭证,导入时会覆盖。恢复后若 n8n 已在运行,工作流/凭证变更会生效,必要时可重启容器。

**发布n8n工作流:**登录网站,依次点击发布按钮。

定时任务

使用 crontab 设置定时任务,实现自动化运维。可手动 crontab -e 添加,或用下面命令一次性写入(不重复添加):

# 写入 crontab(若已有同类任务会重复,建议先 crontab -l 查看后选用其一方式)
( crontab -l 2>/dev/null | grep -v "acme.sh --cron" | grep -v "backup_database.sh" | grep -v "backup_n8n.sh"; \
  echo "58 1 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null"; \
  echo "0 */3 * * * sh /opt/vps-backup/scripts/backup_database.sh"; \
  echo "0 2 * * * sh /opt/vps-backup/scripts/backup_n8n.sh"; \
) | crontab -

# 查看当前任务
crontab -l

说明:SSL 证书续期(每天 1:58)、数据库备份(每 3 小时)、N8n 工作流备份(每天 2:00)。时钟同步由 chronyd 常驻,无需 crontab。

附录

AlmaLinux 9 一键初始化脚本

将下文脚本保存为 almalinux9-setup.sh,按需修改顶部变量或通过环境变量传入,以 root 执行 bash almalinux9-setup.sh。涵盖系统设置、安全加固、Nginx、SSL(acme.sh)、Docker;可选步骤均由变量控制。安全加固(SSH_KEY_ONLY 等)请确认密钥或普通用户可用后再开启;SSL 申请需 CloudFlare 凭证与 DOMAIN,未配置则只安装 acme.sh 与证书目录。

#!/usr/bin/env bash
# AlmaLinux 9 一键初始化:系统设置 + Nginx + SSL(acme.sh)+ Docker
# 用法:修改变量或环境变量后执行 bash almalinux9-setup.sh(需 root)

set -e

# ---------- 系统设置 ----------
HOSTNAME="${HOSTNAME:-vps}"
MIRROR="${MIRROR:-}"
GIT_USER="${GIT_USER:-zhijunio}"
GIT_EMAIL="${GIT_EMAIL:-zhijun.lab@gmail.com}"
CREATE_USER="${CREATE_USER:-}"
SWAP_GB="${SWAP_GB:-0}"
SSH_KEY_ONLY="${SSH_KEY_ONLY:-no}"
PERMIT_ROOT="${PERMIT_ROOT:-prohibit-password}"
INSTALL_FAIL2BAN="${INSTALL_FAIL2BAN:-no}"
# ---------- Nginx / SSL / Docker ----------
INSTALL_NGINX="${INSTALL_NGINX:-yes}"
INSTALL_SSL="${INSTALL_SSL:-yes}"
INSTALL_DOCKER="${INSTALL_DOCKER:-yes}"
DOMAIN="${DOMAIN:-}"
CF_Token="${CF_Token:-}"
CF_Account_ID="${CF_Account_ID:-}"
CF_Key="${CF_Key:-}"
CF_Email="${CF_Email:-$GIT_EMAIL}"   # 不设则与 GIT_EMAIL 一致

[[ $(id -u) -eq 0 ]] || { echo "请使用 root 运行"; exit 1; }

echo "[1/11] 设置主机名 ..."
[[ -z "$HOSTNAME" ]] || hostnamectl set-hostname "$HOSTNAME"

echo "[2/11] 配置 dnf 源(清理默认并创建精简 repo) ..."
case "$MIRROR" in
  "")       REPO_BASE="https://repo.almalinux.org" ;;
  aliyun)   REPO_BASE="https://mirrors.aliyun.com" ;;
  aws-bos)  REPO_BASE="https://bos.aws.repo.almalinux.org" ;;
  aws-den)  REPO_BASE="https://den.aws.repo.almalinux.org" ;;
  *)        echo "未知 MIRROR: $MIRROR"; exit 1 ;;
esac
mkdir -p /etc/yum.repos.d/bak
mv /etc/yum.repos.d/*.repo /etc/yum.repos.d/bak/ 2>/dev/null || true
cat > /etc/yum.repos.d/almalinux.repo << REPOEOF
[baseos]
name=AlmaLinux 9 - BaseOS
baseurl=$REPO_BASE/almalinux/9/BaseOS/\$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-AlmaLinux-9

[appstream]
name=AlmaLinux 9 - AppStream
baseurl=$REPO_BASE/almalinux/9/AppStream/\$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-AlmaLinux-9
REPOEOF
dnf clean all && dnf makecache

echo "[3/11] 更新系统并安装常用软件 ..."
dnf update -y 
dnf install -y git vim jq chrony

echo "[4/11] 时钟同步与时区 ..."
systemctl enable chronyd && systemctl start chronyd
chronyc -a makestep || true
timedatectl set-timezone Asia/Shanghai

echo "[5/11] Git 全局配置 ..."
git config --global user.name "$GIT_USER"
git config --global user.email "$GIT_EMAIL"

echo "[6/11] 可选:创建 sudo 用户 ..."
if [[ -n "$CREATE_USER" ]]; then
  if ! id "$CREATE_USER" &>/dev/null; then
    useradd -m -s /bin/bash "$CREATE_USER"
    usermod -aG wheel "$CREATE_USER"
    echo "请为 $CREATE_USER 设置密码:"
    passwd "$CREATE_USER"
  else
    usermod -aG wheel "$CREATE_USER"
    echo "用户 $CREATE_USER 已存在,已加入 wheel。"
  fi
fi

echo "[7/11] 可选:Swap ..."
if [[ "$SWAP_GB" -gt 0 ]] && [[ ! -f /swapfile ]]; then
  fallocate -l "${SWAP_GB}G" /swapfile
  chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
  echo "/swapfile swap swap defaults 0 0" >> /etc/fstab
  swapon --show
fi

echo "[8/11] 可选:SSH 加固与 fail2ban ..."
if [[ "$SSH_KEY_ONLY" == "yes" || "$PERMIT_ROOT" != "yes" ]]; then
  cp -a /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
  if [[ "$SSH_KEY_ONLY" == "yes" ]]; then
    sed -i "s/^#*PasswordAuthentication.*/PasswordAuthentication no/; s/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/" /etc/ssh/sshd_config
    grep -q "^PasswordAuthentication " /etc/ssh/sshd_config || echo "PasswordAuthentication no" >> /etc/ssh/sshd_config
    grep -q "^PubkeyAuthentication " /etc/ssh/sshd_config || echo "PubkeyAuthentication yes" >> /etc/ssh/sshd_config
  fi
  if [[ "$PERMIT_ROOT" != "yes" ]]; then
    sed -i "s/^#*PermitRootLogin.*/PermitRootLogin $PERMIT_ROOT/" /etc/ssh/sshd_config
    grep -q "^PermitRootLogin " /etc/ssh/sshd_config || echo "PermitRootLogin $PERMIT_ROOT" >> /etc/ssh/sshd_config
  fi
  systemctl restart sshd
fi
if [[ "$INSTALL_FAIL2BAN" == "yes" ]]; then
  dnf install -y epel-release
  dnf install -y fail2ban fail2ban-firewalld
  systemctl enable fail2ban --now
fi

echo "[9/11] Nginx ..."
if [[ "$INSTALL_NGINX" == "yes" ]]; then
  if [[ ! -f /etc/yum.repos.d/nginx.repo ]]; then
    cat > /etc/yum.repos.d/nginx.repo << "NGINXREPO"
[nginx-stable]
name=nginx stable repo
baseurl=https://nginx.org/packages/rhel/9/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true
NGINXREPO
  fi
  dnf remove -y nginx-core nginx-filesystem nginx-mod-* 2>/dev/null || true
  dnf install -y nginx
  systemctl enable nginx && systemctl start nginx
  dnf install -y firewalld 2>/dev/null || true
  firewall-cmd --zone=public --permanent --add-service=http 2>/dev/null || true
  firewall-cmd --zone=public --permanent --add-service=https 2>/dev/null || true
  firewall-cmd --reload 2>/dev/null || true
  setsebool -P httpd_can_network_connect on 2>/dev/null || true
  echo "Nginx 安装完成。"
fi

echo "[10/11] SSL(acme.sh)..."
if [[ "$INSTALL_SSL" == "yes" ]]; then
  mkdir -p /etc/nginx/ssl
  if [[ ! -x /root/.acme.sh/acme.sh ]]; then
    curl -sSL https://get.acme.sh | sh -s "email=$GIT_EMAIL"
  fi
  export CF_Token CF_Account_ID CF_Key CF_Email
  if [[ -n "$DOMAIN" ]] && { [[ -n "$CF_Token" ]] || [[ -n "$CF_Key" ]]; }; then
    echo "申请证书:$DOMAIN 与 *.$DOMAIN ..."
    /root/.acme.sh/acme.sh --issue --server letsencrypt --dns dns_cf -d "$DOMAIN" -d "*.$DOMAIN" --ecc
    cp /root/.acme.sh/"${DOMAIN}"_ecc/{"$DOMAIN".cer,"$DOMAIN".key,fullchain.cer,ca.cer} /etc/nginx/ssl/
    /root/.acme.sh/acme.sh --installcert -d "$DOMAIN" -d "*.$DOMAIN" --ecc \
      --cert-file /etc/nginx/ssl/"$DOMAIN".cer \
      --key-file /etc/nginx/ssl/"$DOMAIN".key \
      --fullchain-file /etc/nginx/ssl/fullchain.cer \
      --ca-file /etc/nginx/ssl/ca.cer \
      --reloadcmd "nginx -s reload"
    echo "证书已安装并配置自动续期。"
  else
    echo "未设置 DOMAIN 或 CloudFlare 凭证,仅完成 acme.sh 与 /etc/nginx/ssl。"
  fi
fi

echo "[11/11] Docker ..."
if [[ "$INSTALL_DOCKER" == "yes" ]]; then
  if ! command -v docker &>/dev/null; then
    curl -fsSL https://get.docker.com | sh
  fi
  systemctl enable docker && systemctl start docker
  iptables -P FORWARD ACCEPT 2>/dev/null || true
  docker network create vps 2>/dev/null || true
  echo "Docker 安装完成,已创建网络 vps(若不存在)。"
fi

echo "全部完成。"

变量说明

变量说明示例
HOSTNAME主机名,空则不设置vps
MIRROR软件源:空=默认,aliyunaws-bosaws-denaliyun
GIT_USER / GIT_EMAILGit 全局配置;邮箱同时用于 acme.sh 与 CloudFlare(CF_Email 未设时)-
CREATE_USER创建并加入 wheel 的用户名,空则不创建zhijunio
SWAP_GBSwap 大小(GB),0 不配置2
SSH_KEY_ONLYyes=仅密钥登录,请先确认密钥可用no
PERMIT_ROOTprohibit-password / no / yesprohibit-password
INSTALL_FAIL2BANyes 安装 fail2banno
INSTALL_NGINXyes 安装 Nginx + 防火墙 + SELinuxyes
INSTALL_SSLyes 安装 acme.sh;有 DOMAIN+CF 凭证则申请证书yes
INSTALL_DOCKERyes 安装 Docker + 网络 vpsyes
DOMAIN主域名(通配符 *.$DOMAIN),空则不申请证书zhijun.io
CF_TokenCloudFlare API Token(推荐)-
CF_Account_IDCloudFlare Account ID,与 CF_Token 配合使用-
CF_Key或使用 Global API Key(邮箱同 GIT_EMAIL,亦可设 CF_Email 覆盖)-

示例

cd /opt
git clone git@github.com:zhijunio/vps-backup.git
cd vps-backup/scripts

# 仅系统设置 + Nginx + Docker,不申请证书
INSTALL_SSL=no bash almalinux9-setup.sh


# 系统设置 + 全部(含证书,需 CloudFlare 凭证)
MIRROR=aws-bos CREATE_USER=zhijunio DOMAIN=zhijun.io CF_Token=xxx CF_Account_ID=xxx bash almalinux9-setup.sh

Docker 使用官方 get.docker.com;如需国内镜像或交互选源,可手动执行 bash <(curl -sSL https://linuxmirrors.cn/docker.sh) 后再运行本脚本(跳过 Docker 安装:INSTALL_DOCKER=no

服务部署脚本

前置:已将各服务的 postgres.yamlumami.yamlmemos.yamln8n.yamllinkding.yamlartalk.yaml 及(可选)uptime.yaml 放在同一目录,且 Nginx 配置已准备好(如放在该目录下 nginx/)。在该目录下执行:

set -e
DIR="${DIR:-$(pwd)}"   #  compose 文件所在目录,默认当前目录
cd "$DIR"

docker network create vps 2>/dev/null || true
docker compose -f postgres.yaml up -d

# 等 Postgres 就绪后创建数据库(非交互)
sleep 5
for db in umami memos n8n linkding artalk; do
  docker exec postgres psql -U postgres -c "CREATE DATABASE $db OWNER postgres;" 2>/dev/null || true
done

docker compose -f umami.yaml up -d
docker compose -f memos.yaml up -d
docker compose -f n8n.yaml up -d
docker compose -f linkding.yaml up -d
docker compose -f artalk.yaml up -d
# 可选:docker compose -f uptime.yaml up -d

[ -d nginx ] && cp nginx/*.conf /etc/nginx/conf.d/ && nginx -t && nginx -s reload

定时任务与证书续期、备份等仍按上文「定时任务」一节单独配置即可。

参考文章