
很多开发者刚开始接触 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 一样管理容器安装简单一键部署很多开源应用CoolifyVercel / 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 记录里新增记录类型主机记录记录值Ademo1.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/DockerfileFROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install --registryhttps://registry.npmjs.org COPY . . EXPOSE 3000 CMD [npm, start]这段 Dockerfile 的逻辑是使用 Node 20 Alpine 镜像作为基础环境把依赖安装结果和源码一起打包进镜像。如果出于网络原因无法直接访问 npm 默认源可以按团队规范指定内部镜像源否则保持默认配置即可。5.3 编写 docker-compose.yml在项目根目录创建docker-compose.ymlservices: 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 端口上并自动申请和续期 Lets Encrypt 证书。你不需要像传统 Nginx 那样手动上传证书文件也不需要写一堆ssl_certificate配置。如果你暂时没有域名只想用 IP 测试可以这样写 Caddyfilehttp://1.2.3.4 { reverse_proxy app:3000 }但需要注意Lets 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服务器登录用户例如 deployerSSH_PRIVATE_KEY服务器中 deployer 用户对应的私钥APP_DIR服务器项目目录例如 /opt/selfhosted-demo为安全起见私钥应该是单独为 CI 生成的密钥对并且最好限制该密钥只能访问这个项目目录。6.2 编写 GitHub Actions 部署文件在项目根目录创建.github/workflows/deploy.ymlname: Deploy to self-hosted server on: push: branches: - main jobs: deploy: name: Deploy runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkoutv4 - name: Deploy via SSH uses: appleboy/ssh-actionv1.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_PASSWORDchange_me MYSQL_ROOT_PASSWORDchange_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 deployeryour_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确认账号、密码、