很多开发者刚开始接触 Vercel、Railway 这类平台时,往往会被它们的自动化程度惊艳到:项目推到 Git 仓库,平台自动完成构建、发布、HTTPS 证书签发,甚至还能一键创建数据库和定时任务。这种体验确实流畅,基本把“部署”这件事从命令行操作变成了网页点击。不过,一旦项目从“练手应用”走向“长期运行的业务系统”,我们迟早会开始算一笔账:配额、成本、日志留存时长、网络区域限制、协作权限粒度,每一个因素都可能成为瓶颈。
于是,“own-your-infra”这个思路这两年越来越流行。它不是让你回到手搓服务器的古老时代,而是把 Vercel / Railway 这类平台的“核心体验”搬到自己的服务器上:Docker 容器化应用、反向代理自动签发 HTTPS、Git 提交后自动部署、数据库持久化。这种方案既保留了类似 PaaS 的工作流,又能让团队完全掌控运行环境、备份策略和成本结构。
这篇文章就围绕这个目标展开。我会先梳理 Vercel / Railway 与自托管方案的分工和区别,再从环境初始化讲起,用一套完整示例完成 Node.js 应用 + MariaDB + Caddy 自动 HTTPS 的部署,最后补充 Git 自动发布、生产环境最佳实践和常见排错。看完以后,你可以自己从一台空服务器开始,搭建出一个可以日常使用的私有部署平台。
1. 自托管方案的定位:不是替换,而是掌控
1.1 Vercel / Railway 到底帮你做了什么
要理解自托管方案,先要清楚托管平台的价值在哪里。
Vercel 最初聚焦前端部署,核心流程是:Git 仓库接入 -> 自动识别框架 -> 云端构建静态资源 -> 发布到全球边缘节点。它把构建、CDN、域名、 HTTPS、预览地址全部封装好。对于 Next.js、Vue、React 这类前端工程,开发者几乎不需要关心服务器和 Nginx。
Railway 则更像一个云原生应用平台。它允许你直接运行 Node.js、Python、Go 等后端服务,也可以启动 PostgreSQL、Redis 这类中间件,还内置模板市场、日志面板、环境变量管理。你把它理解成一个对开发者更友好的容器部署服务:提供 Docker 运行环境,替你做端口管理、域名映射和基础监控。
这两类平台的共同点是“减少重复运维”。你不需要自己装 Nginx、不需要处理证书续期、不需要准备对象存储,平台默认帮你做掉一部分。
1.2 什么时候会产生自托管需求
托管平台好用,不代表所有场景都合适。下面几个情况很常见:
- 成本不可控。尤其当应用持续在线、访问量增长后,平台按调用次数、请求时长或独享实例计费,账单会比想象中涨得快。
- 配额限制。免费额度适合小项目,但一旦涉及文件存储、后台任务数量、带宽,很快会遇到限制。
- 环境依赖特殊。部分项目需要特定内核参数、特殊网络配置或本地硬件资源,托管平台很难满足。
- 数据合规要求。有些业务数据需要存放在指定机房,或者需要自己掌握完整的备份和删除流程。
- 团队需要做多环境隔离。例如开发环境、测试环境、演示环境全部放在同一个服务器集群中,开源 PaaS 比在云厂商逐个购买环境更灵活。
所以自托管通常并不是因为 Vercel / Railway“不好”,而是因为团队或项目进入了一个需要更多可控性的阶段。自己搭建的部署平台,能换来的是更低的长期边际成本、更自由的运行环境,以及更完整的运维数据。
2. 自托管 PaaS 的基本原理
2.1 PaaS 由哪些组件构成
所谓 PaaS,是 Platform as a Service,也就是“平台即服务”。自建部署平台的本质,就是把下面这些组件组合在一台或多台服务器上:
- 构建引擎:负责把源代码变成可运行产物或容器镜像。
- 运行环境:常见的底层是 Docker 或 containerd,用容器隔离不同应用。
- 服务编排:决定容器如何启动、如何重启、多个服务之间如何通信。
- 反向代理:统一接收 80 / 443 端口的外部流量,再转发到不同容器。
- HTTPS 证书管理:自动申请、续期 SSL/TLS 证书。
- 存储卷:为数据库或文件上传提供持久化目录。
- 控制台与 API:提供 Web 界面或命令行入口,完成创建项目、设置环境变量、查看日志等操作。
Vercel 和 Railway 只是把以上能力做成了 SaaS,你不需要自己安装。自托管则是把它们还原到一台 Linux 服务器上。
2.2 一次部署的完整链路
无论使用什么工具,一次现代化部署的过程几乎都是固定的:
- 开发者将代码推送到 Git 仓库。
- 构建系统拉取代码,执行依赖安装和构建命令。
- 构建产物被放入 Docker 镜像,或直接以静态文件发布。
- 运行环境启动一个新容器,并挂载环境变量和持久化卷。
- 反向代理收到请求后,按域名转发给对应容器。
- 平台为域名申请 HTTPS 证书,并在证书快过期时自动续期。
这个链路可以用命令和配置文件全部实现,不需要依赖某个特定商业平台。只要把 Docker、Caddy / Nginx、Git Hook 或 CI 工具串起来,就得到了一个属于自己的轻量 PaaS。
2.3 Docker Compose 为什么成为自托管的核心
Docker Compose 是目前自托管项目里出现频率最高的文件格式。它通过一个 YAML 文件描述整个应用栈:前端容器、后端容器、数据库容器、反向代理容器。只要执行一条命令,就能创建并启动全部服务。
以后想迁移服务器,也不用重新配置环境,直接把项目目录和 Docker Compose 文件拷贝过去即可。这种可复制性,是自托管方案相对于在网页控制台手动点击的重要优势。
3. 常见的开源替代方案盘点
如果你不想从零写一套控制台,可以直接安装开源 PaaS 工具。它们通常自带 Web UI、用户管理、项目模板,已经比较接近 Vercel / Railway 的体验。
| 方案 | 定位 | 适合场景 | 建议 |
|---|---|---|---|
| Dokku | 最轻量的开源 PaaS | 单人维护、服务器资源有限的场景 | 类 Heroku 思路,部署命令简单 |
| CapRover | 自带 Web UI 的 PaaS | 希望像操作 App Store 一样管理容器 | 安装简单,一键部署很多开源应用 |
| Coolify | Vercel / Netlify 风格的自托管平台 | 前端和全栈应用混合部署,喜欢现代化界面 | 支持静态站点、Docker Compose 等部署类型 |
| Dokploy | 界面现代、偏 Docker Compose 方案 | 团队希望用 Compose 管理全栈项目 | 开源免费,也可用于 VPS 上的站群部署 |
| 手动 Docker Compose + Caddy | 最“自己掌控”的方式 | 想彻底理解每一层原理 | 无额外控制台,但有最大灵活性 |
如果你只需要把一个 Node 项目跑起来,Dokku 和手动 Compose 都够用。如果你想给团队提供一个相对友好的控制台,Coolify、Dokploy 这一类更接近 Railway 的使用感受。
不过要注意,自托管 PaaS 也需要运维成本。控制台本身也是软件,升级、备份、权限管理都需要维护。选择时先问自己:是需要一个“能用的平台”,还是需要一套“能完全掌控的架构”。
为了把原理讲扎实,这篇文章后面的示例不依赖任何现成 PaaS 控制台,而是用最直接的方式——Docker Compose + Caddy——从空服务器开始完整部署。理解这套流程后,再去看 Dokploy / Coolify 的源码或文档,会轻松很多。
4. 服务器初始化与基础环境搭建
4.1 服务器选型建议
自托管部署平台需要一台长期运行的 Linux 服务器。实际生产中,更推荐 2 核 4G 或更高配置;如果只做练习,1 核 1G 到 1 核 2G 也能跑通基础应用。操作系统优先选择 Ubuntu 20.04+ 或 Debian 11+,因为软件源更新及时,Docker 兼容性好。
如果你是学习用途,云服务商的新用户规则、按量付费实例、家用 NAS 或闲置小主机都可以用来练习。但正式业务建议至少满足以下条件:
- 独立的公网 IP。
- 可以解析到该 IP 的域名。
- 开放 22、80、443 端口,其他端口尽量不对外开放。
- 50GB 以上磁盘空间,为 Docker 镜像和数据库预留容量。
4.2 初始化服务器与创建普通用户
为了安全,不建议直接用 root 账号长期操作,而应该创建一个具备 sudo 权限的普通用户。
# 添加用户 adduser deployer # 添加到 sudo 组 usermod -aG sudo deployer # 切换到新用户 su - deployer生产环境如果使用 SSH 登录,建议进一步配置公钥认证,并禁止密码登录。这一步需要谨慎操作,先确认新用户的公钥已经能正常登录,再关闭 root 密码登录,否则容易把自己锁在服务器外。
4.3 安装 Docker Engine 与 Docker Compose 插件
Docker 官方提供了适用于常见发行版的安装脚本。下面的命令适合学习环境使用;如果生产环境有严格软件供应链要求,建议按官方仓库手动添加安装源。
# 更新系统软件源 sudo apt update sudo apt upgrade -y # 使用 Docker 官方安装脚本 curl -fsSL https://get.docker.com -o get-docker.sh sudo sh get-docker.sh # 设置 Docker 开机自启 sudo systemctl enable docker sudo systemctl start docker之后验证是否安装成功:
docker version docker compose version如果你看到 client 和 server 两段版本信息,并且docker compose version能正常输出,就说明 Docker 安装完成。新版 Docker Engine 通常自带 Compose v2 插件,不需要单独安装docker-compose。
4.4 配置域名解析
自托管的 HTTPS 证书签发依赖域名解析。在云厂商的 DNS 控制台,为你的服务器 IP 添加一条 A 记录。
例如我想把应用部署在demo.my-domain.com,服务器公网 IP 是1.2.3.4,就在 DNS 记录里新增:
| 记录类型 | 主机记录 | 记录值 |
|---|---|---|
| A | demo | 1.2.3.4 |
如果想为后续多个子应用预留地址,可以配置一个泛解析记录,例如把*.apps.my-domain.com全部解析到同一台服务器。这样之后创建新应用时,不需要每次单独添加 DNS 记录。
当然,如果服务器厂商和域名接入方之间有特殊的接入要求,需要先按厂商指引确认域名合法接入,否则 80 和 443 端口可能无法正常提供服务。这里的技术重点是:证书签发请求会尝试通过域名访问你的服务器,所以域名必须真实解析到这台服务器,CDN 代理或防火墙拦截都会导致证书申请失败。
5. 实战:从零部署一个 Node.js + MariaDB 应用
下面进入正文核心。我以一个带数据库连接的 Node.js 应用为例,演示完整的自托管部署链路。
应用目标很简单:提供一个 HTTP 服务,页面能访问 MariaDB 数据库并执行一条查询,证明应用和数据库的网络通信正常。
5.1 创建项目目录结构
在服务器上创建一个工作目录,例如/opt/selfhosted-demo。
sudo mkdir -p /opt/selfhosted-demo sudo chown -R $USER:$USER /opt/selfhosted-demo cd /opt/selfhosted-demo项目结构如下:
selfhosted-demo/ ├── docker-compose.yml ├── Caddyfile └── app/ ├── Dockerfile ├── package.json └── index.js其中:
docker-compose.yml描述应用、数据库、反向代理三个服务。Caddyfile配置域名转发和 HTTPS。app目录放 Node.js 应用代码。
5.2 编写 Node.js 应用
在app/package.json中声明依赖:
{ "name": "selfhosted-demo", "version": "1.0.0", "type": "module", "scripts": { "start": "node index.js" }, "dependencies": { "express": "^4.19.2", "mysql2": "^3.10.0" } }在app/index.js中写一个简单服务:
import express from "express"; import mysql from "mysql2/promise"; const app = express(); const port = process.env.PORT || 3000; const pool = mysql.createPool({ host: process.env.DB_HOST || "db", user: process.env.DB_USER || "demo", password: process.env.DB_PASSWORD || "", database: process.env.DB_NAME || "demo", waitForConnections: true, connectionLimit: 5, }); app.get("/", async (req, res) => { try { await pool.query("SELECT 1"); res.send("Hello Self-hosted! Database connection OK."); } catch (err) { res.status(500).send(`Database connection failed: ${err.message}`); } }); app.listen(port, () => { console.log(`App listening on port ${port}`); });这里不暴露任何管理页面,只提供一个健康检查型路由。真实项目中可以继续扩展接口和业务逻辑。
接着编写app/Dockerfile:
FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install --registry=https://registry.npmjs.org COPY . . EXPOSE 3000 CMD ["npm", "start"]这段 Dockerfile 的逻辑是:使用 Node 20 Alpine 镜像作为基础环境,把依赖安装结果和源码一起打包进镜像。如果出于网络原因无法直接访问 npm 默认源,可以按团队规范指定内部镜像源;否则保持默认配置即可。
5.3 编写 docker-compose.yml
在项目根目录创建docker-compose.yml:
services: app: build: ./app expose: - "3000" environment: DB_HOST: db DB_USER: demo DB_PASSWORD: demo_pass DB_NAME: demo PORT: 3000 depends_on: db: condition: service_healthy restart: always db: image: mariadb:11 environment: MYSQL_ROOT_PASSWORD: root_pass MYSQL_DATABASE: demo MYSQL_USER: demo MYSQL_PASSWORD: demo_pass volumes: - db-data:/var/lib/mysql healthcheck: test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$$MYSQL_USER -p$$MYSQL_PASSWORD --silent"] interval: 10s timeout: 5s retries: 10 restart: always caddy: image: caddy:2 ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config restart: always volumes: db-data: caddy_data: caddy_config:几个关键点需要说明:
app服务不直接映射宿主机端口,而是通过expose暴露给 Docker 内部网络。真正对外接收请求的是 Caddy。depends_on配合service_healthy,可以让应用容器等待数据库就绪后启动。- MariaDB 的数据保存在
db-data卷中。以后执行docker compose down不会删除卷,数据仍然保留。 - Caddy 的
caddy_data卷用于存储自动申请的证书,避免容器重建后重新申请。
5.4 编写 Caddy 反向代理配置
Caddyfile内容如下:
demo.my-domain.com { reverse_proxy app:3000 }把demo.my-domain.com替换成你已经解析到当前服务器的真实域名。
Caddy 会自动判断当前容器是否在 80 / 443 端口上,并自动申请和续期 Let's Encrypt 证书。你不需要像传统 Nginx 那样手动上传证书文件,也不需要写一堆ssl_certificate配置。
如果你暂时没有域名,只想用 IP 测试,可以这样写 Caddyfile:
http://1.2.3.4 { reverse_proxy app:3000 }但需要注意,Let's Encrypt 无法为纯 IP 申请公共可信证书,Caddy 在纯 IP 场景下实际使用的是 HTTP 明文流量。要体验完整的 HTTPS 流程,必须有真实域名。
5.5 启动整个应用栈
回到项目根目录:
cd /opt/selfhosted-demo docker compose up -d --build首次执行时,Docker 会拉取 Node、MariaDB、Caddy 镜像,并构建 Node 应用镜像。构建和拉取时间取决于网络条件。
启动完成后,运行:
docker compose ps预期能看到三个服务都处于running状态。如果数据库启动速度较慢,应用容器可能会短暂重启,但restart: always会保证它恢复运行。
然后验证应用是否健康:
curl http://127.0.0.1:80因为 Caddy 已经监听宿主机 80 端口,并把流量转发到app:3000,所以本机请求返回的应该是应用页面内容。如果使用域名访问,直接在浏览器打开https://demo.my-domain.com,就能得到响应。
5.6 验证数据库连接
访问页面后,如果输出:
Hello Self-hosted! Database connection OK.说明整个链路已经打通:冷请求从 Caddy 进入,转发到 Node 容器,Node 再通过 Docker 内网连接 MariaDB,并成功执行查询。
这一步可能遇到数据库连接失败。多数原因是数据库容器还没有完全启动,或者环境变量里的密码和docker-compose.yml不一致。可以查看日志:
docker compose logs -f app docker compose logs -f db用日志确认程序真正报错点。
6. 让部署变成一个命令:接入 Git 自动构建
手动登录服务器执行docker compose up -d --build只是第一步。要让这个方案接近 Vercel / Railway 的持续部署体验,还需要把“服务器操作”放到 CI/CD 流程中。
一个常见的做法是:本地或团队将代码推送到 GitHub 的main分支,然后由 GitHub Actions 通过 SSH 登录服务器,在服务器上拉取最新代码并重建 Docker 容器。
6.1 准备 GitHub Secrets
在项目的 GitHub 仓库页面,进入Settings -> Secrets and variables -> Actions,添加下面几个变量:
| Secret 名称 | 说明 |
|---|---|
| SERVER_HOST | 服务器公网 IP 或域名 |
| SERVER_USER | 服务器登录用户,例如 deployer |
| SSH_PRIVATE_KEY | 服务器中 deployer 用户对应的私钥 |
| APP_DIR | 服务器项目目录,例如 /opt/selfhosted-demo |
为安全起见,私钥应该是单独为 CI 生成的密钥对,并且最好限制该密钥只能访问这个项目目录。
6.2 编写 GitHub Actions 部署文件
在项目根目录创建.github/workflows/deploy.yml:
name: Deploy to self-hosted server on: push: branches: - main jobs: deploy: name: Deploy runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Deploy via SSH uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd ${{ secrets.APP_DIR }} git pull origin main docker compose up -d --build之后,每次向main分支推送代码,GitHub Actions 都会自动连接服务器并执行部署。你不需要在本地手动运行 Docker 命令。
如果项目代码仓库托管在 GitLab 或 Gitea,也可以使用对应的 CI 功能,触发 SSH 脚本的原理一致。
6.3 关于“自动部署”的安全边界
CI 通过 SSH 直接执行命令,拥有非常大的权限。请务必注意以下几点:
- 不要在服务器上使用 root 账号执行这些命令,给 CI 使用独立用户。
- 不要把所有服务器端口都开放到公网,SSH 尽量只允许可信 IP 访问。
- 不要把
SSH_PRIVATE_KEY硬写在代码仓库中,只保存在私有仓库的 Secrets 中。 - 正式环境每次部署前,应在测试环境跑通数据库迁移和应用测试。CI 可以加上测试 Job,通过后再触发部署 Job,形成环境保护机制。
7. 生产环境的工程化建议
部署完成后,项目能正常访问并不代表可以高枕无忧。一个真正面向生产的自托管平台,还需要考虑下面这些维度。
7.1 数据备份策略
数据库是自托管方案中最需要保护的部分。虽然 Docker 卷提供了持久化,但服务器磁盘损坏、误删除容器卷等操作仍会造成数据丢失。
建议至少在服务器本地定期备份数据库,并把备份文件同步到另外的存储位置。例如可以编写一个定时脚本:
#!/bin/bash docker compose exec -T db mysqldump -udemo -pdemo_pass demo > /opt/backups/demo_$(date +%F).sql配合 cron 任务,每天凌晨自动执行备份。注意脚本里的密码不要直接硬写在代码里,可以放到.env文件,并通过 Docker Compose 的env_file注入。
7.2 环境变量和密钥管理
不要把数据库密码写死在docker-compose.yml里提交到 Git 仓库。正确的做法是用.env文件或在 CI Secrets 中管理:
DB_PASSWORD=change_me MYSQL_ROOT_PASSWORD=change_me然后在docker-compose.yml中通过${DB_PASSWORD}引用。.env文件需要加入.gitignore,避免误提交。
7.3 日志与监控
查看容器日志是最基本的排错方式:
docker compose logs -f app由于容器重启后日志不会自动写到持久文件,建议生产环境启用 Docker 的日志驱动,或配置日志采集工具。若无额外日志系统,至少应该在服务器上做日志轮转,避免单个容器日志无限制增长。
7.4 镜像更新与 Docker 版本升级
自托管并不意味着服务永远不变。你需要定期升级镜像补丁和 Docker 版本。升级前最好先在测试服务器执行:
docker compose pull docker compose up -d升级 Docker 本身前,建议关注官方发布说明,并在非业务高峰期操作。如果对 Docker 命令不熟悉,先在测试环境验证,不要直接在生产服务器上执行大版本升级。
7.5 网络安全最小化
前面说过,只需要对外暴露 80 和 443 端口。数据库端口3306、应用调试端口等都不应该映射到宿主机公网。如果为了本地调试需要访问数据库,优先使用 SSH 隧道,而不是直接把端口暴露在公网。
以 SSH 隧道访问数据库为例,在本地开发机执行:
ssh -L 3307:127.0.0.1:3306 deployer@your_server_ip然后本地用127.0.0.1:3307连接数据库。这样不会把数据库端口暴露给公网。
8. 常见问题与排查清单
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 浏览器访问域名显示无法连接 | DNS 未生效或安全组没放行 80 / 443 | 使用dig demo.my-domain.com检查解析结果,并确认云控制台放行端口 |
| Caddy 容器反复重启 | 80 / 443 端口被占用 | 执行sudo ss -lntp查看端口占用情况,关闭冲突服务 |
| HTTPS 证书一直申请失败 | 域名解析到了 CDN 或代理节点,而不是源服务器 | 暂时关闭 CDN 代理,让证书签发请求直接到达服务器 |
| 应用页面提示数据库连接失败 | 数据库环境变量不匹配或数据库未就绪 | 查看docker compose logs db和docker compose logs app,确认账号、密码、 |