
如果你正在把一个“能跑的原型”推向正式项目大概率会经历这样一个阶段前端随手推给 Vercel后端和数据库放上 Railway十分钟后所有服务都亮起绿色指标。这个过程太顺了顺到会让人忽略一个问题——你的服务确实在运行但你对“它如何被运行、被部署、被计费”的控制力正在被一点点打包交给别人。最近部署社区里有一个常被概括为 “The own-your-infra answer to Vercel and Railway” 的讨论方向它并不是简单地喊“不要用云平台”而是回到一个更本质的问题当 Vercel 和 Railway 把部署体验做成“基础设施即服务”时团队究竟失去了什么以及如果要找回这些失去的东西应该用什么架构、什么工具、什么流程来替代这篇文章我会先从平台价值和痛点的对比入手再给出三类可以落地的自建路径最后用一套完整可运行的 Next.js FastAPI PostgreSQL 项目演示在一台普通云主机上如何通过 Docker Compose、Caddy 和通用 Git 流程搭出接近 Vercel 和 Railway 的开发体验但把域名、数据、证书、密钥和备份都留在自己手里。先给一个明确判断own-your-infra 的真正答案不是“不用平台”而是用一套足够工程化的部署抽象把平台默认替你完成的那部分决策重新接管过来。这件事有价值的前提是你愿意同时接管它带来的运维责任。1. 什么场景下你会开始寻找 Vercel 和 Railway 之外的答案Vercel 和 Railway 的体验设计目标非常一致让部署从“一件需要谨慎对待的事”变成“一件像喝水一样简单的事”。这对前端项目、AI 生成原型、短期 Hackathon 来说几乎是完美的。代码推上去自动构建、自动 HTTPS、自动分配域名连失败时的错误信息都替你翻译成了人话。但当项目从“演示”走向“真实业务”时问题会逐渐从体验层面转移到权利层面。你开始在意的不再是部署是否顺畅而是以下几个问题第一是平台锁定。你的前端如果用了某个平台特有的 Serverless 函数、中间件或边缘缓存能力换一个运行环境时需要重写的不只是配置可能是部分业务代码。所谓“原样迁移”只适用于一开始就刻意规避平台特性的项目。第二是成本曲线。按量计费平台在流量低的时候确实便宜但一旦访问量呈非线性增长账单通常会跟着流量一起非线性上涨。如果你连“这个月为什么花了这么多”都很难立刻说清楚说明成本模型对你是不透明的。第三是数据边界和合规。业务数据放在哪里、数据库备份由谁负责、日志保留多久、密钥放在谁的密钥管理系统里这些在平台上一键可用的能力恰恰是你在审计、合规或故障复盘时最需要回答的问题。第四是排障链路。Vercel 和 Railway 上发生的故障经常依赖平台自身的控制台才能定位。你无法 SSH 到运行节点无法自己抓包无法自由安装诊断工具。每一次疑难问题都变成“等平台侧反馈”这对需要快速响应的团队是一种隐性成本。所以你会看到真正开始寻找 own-your-infra 方案的人通常不是刚入门的新手而是已经踩过平台边界、被成本或排障效率困扰过的开发者。他们要的不是“比 Vercel 更简单的按钮”而是“可控的复杂度”。2. 核心差异平台帮你做三件事自建时也要接手三件事要理解这个问题的本质可以把一次部署拆成三层构建层、运行层和接入层。Vercel 和 Railway 做的事情是把这三层全部收进平台内部。你在界面上看到的“Deploy”按钮实际上包含了源代码拉取、依赖安装、镜像或产物构建、容器调度、负载均衡、证书签发、域名解析、日志采集、监控报警这一整串动作。平台做得越好你对这一串动作的感知就越低。自建方案则需要你自己决定每一层用什么工具。构建层用什么构建镜像、在本地构建还是服务器上构建运行层用 Docker 容器还是裸进程用 systemd 管理还是 Kubernetes接入层用什么反向代理、如何处理 HTTPS 证书、如何让/api请求正确转发到后端。这些在平台上都是默认项在自建方案里全是选择题。这并不一定意味着更复杂而是意味着你换来了几个具体回报第一部署逻辑变成代码。平台界面里的点击操作在自建方案里可以被写进 Dockerfile、Compose 文件和 CI 配置中。部署流程可以被 Review、被版本化、被回滚。第二数据主权回到业务侧。数据库跑在自己的存储卷里备份策略可以由业务决定密钥存放在自己的环境变量或密钥管理系统中而不是散落在多个平台项目里。第三成本曲线更接近线性。一台云主机或几台云主机的固定成本不会因为单次流量尖峰突然产生数量级变化。你需要付出的是容量规划和监控成本。第四架构边界更干净。为了让应用能部署在任何一台 Linux 主机上你会自然地把前端构建、后端 API、数据库、反向代理拆成独立服务。这种拆分本身就是一种更健康的应用形态。如果只看表面很容易误以为自建方案是在“重新发明 Vercel”。更准确的说法是平台把默认决策藏起来了自建把它们还给了你。你要判断的不是“能不能自建”而是“这些问题我是否有精力长期回答”。3. 三类可以落地的 own-your-infra 路径“自建”听起来是一个很大的话题实际落地时通常有三条主流路线它们的共同点是自己持有服务器和域名区别只在于对部署流程的抽象程度。3.1 开源 PaaS 平台路线典型代表是 Dokku、CapRover、Coolify。这类项目提供类似 Heroku 或 Railway 的模型你在服务器上安装一个控制面板之后通过 Git 推送、网页点击或 API 创建应用平台自动处理容器构建、域名、反向代理和 HTTPS。Dokku 是最老练的一种。它的体量轻基于 Docker通过插件机制支持 PostgreSQL、Redis 等常见依赖操作方式贴近命令行适合喜欢“一个 SSH 就能搞定一切”的开发者。缺点是界面朴素很多操作需要记命令。CapRover 基于 Docker 和 Node.js提供了完整的 Web 控制台支持一键部署常见应用、直观的域名绑定、自动 HTTPS。它的定位是“比 Dokku 更容易上手的开源 PaaS”。Coolify 是近两年被讨论得比较多的新选择。它同样面向单机部署设计上更接近现代 PaaS支持从 Git 仓库拉取代码、自动构建、数据库管理、服务监控UI 体验更贴近现代云平台。很多人把它当作“自托管平台”的首选。这一类路线的优点是你不需要从零写部署脚本平台把容器、证书、反向代理这些通用问题解决了一部分。缺点是你仍然需要维护这个 PaaS 软件本身升级、备份、排障都是新任务。3.2 Docker Compose 直接编排路线不引入任何控制面板只用 Docker Compose 把应用、数据库、反向代理编排起来。这适合正式团队或服务数量可控的项目也是本文后半部分要演示的路线。它的优势是透明。每条配置都在文件里没有隐藏的中间层。出现问题可以直接看容器日志也可以直接进入容器排查。缺点是所有事情都要自己写需要你对 Docker、网络、存储卷和反向代理有基本理解。3.3 Kubernetes 与内部开发平台路线当服务数量达到几十个、团队规模也足够大时大家往往会走向 Kubernetes再基于它构建内部开发者平台。这条路线成本最高但扩展性和多环境管理能力最强。它已经超出了“个人替代 Vercel/Railway”的范畴更适合平台工程团队。对于大多数中小团队和个人开发者我的建议是在路线一和路线二之间选择。如果不想维护太多脚本就选一个开源 PaaS如果希望部署逻辑百分之百掌握在自己手中就选 Docker Compose。4. 最小可运行方案一台云主机上部署 Next.js 与 FastAPI为了让上面的概念落地接下来用一个完整示例展示“不依赖 Vercel 和 Railway”的前后端部署。示例包含一个 Next.js 前端、一个 FastAPI 后端、一个 PostgreSQL 数据库以及 Caddy 作为反向代理和自动 HTTPS 入口。这个结构的本质是不再依赖平台帮你做服务编排而是用 Docker Compose 在任意 Linux 云主机上启动同一套服务。架构简图如下用户请求 | v Caddy(80/443自动 HTTPS) |-- /api/* - backend:8000 |-- other/ - frontend:3000 | v PostgreSQL4.1 前置条件与目录约定建议准备一台 Linux 云主机操作系统可使用 Debian 或 Ubuntu内存建议 2GB 以上。需要有一个已经解析到该主机 IP 的域名本文示例中使用web.example.com请替换为你自己的域名。主机上需要安装 Docker 和 Docker Compose 插件。Debian/Ubuntu 下可直接用系统包安装sudo apt update sudo apt install -y docker.io docker-compose-plugin sudo systemctl enable --now docker安装完成后确认 Docker 可用docker --version docker compose version项目目录结构如下deploy-demo/ ├── frontend/ # Next.js 应用 │ ├── Dockerfile │ ├── next.config.mjs │ └── package.json ├── backend/ # FastAPI 应用 │ ├── Dockerfile │ ├── requirements.txt │ └── app/main.py ├── Caddyfile ├── docker-compose.yml └── .env4.2 Next.js 前端镜像Next.js 项目先开启 standalone 输出模式这样 Docker 镜像可以只复制运行所需的最小文件体积比直接复制整个 node_modules 小得多。文件frontend/next.config.mjs/** type {import(next).NextConfig} */ const nextConfig { output: standalone, }; export default nextConfig;文件frontend/DockerfileFROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm install --no-audit --no-fund COPY . . # NEXT_PUBLIC_ 开头的变量会在构建时被打进浏览器产物请按需传入 ARG NEXT_PUBLIC_API_BASE ENV NEXT_PUBLIC_API_BASE$NEXT_PUBLIC_API_BASE RUN npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENVproduction RUN addgroup -g 1001 -S nodejs adduser -S nextjs -u 1001 USER nextjs COPY --frombuild --chownnextjs:nodejs /app/.next/standalone ./ COPY --frombuild --chownnextjs:nodejs /app/.next/static ./.next/static # 如果项目没有 public 目录可以删除下一行 COPY --frombuild --chownnextjs:nodejs /app/public ./public EXPOSE 3000 CMD [node, server.js]关键点在于跑生产环境的是非 root 用户容器内没有不必要的包管理器整体镜像更精简。前端访问 API 的统一入口就是浏览器相对路径/api通过 Caddy 再转发到后端这样既能减少 CORS 配置也能让前端代码不关心后端实际地址。4.3 FastAPI 后端镜像文件backend/requirements.txtfastapi uvicorn[standard]实际项目请以小版本需求为准这里只演示镜像构建的最小依赖。文件backend/app/main.pyfrom fastapi import FastAPI app FastAPI(titledemo-api) app.get(/api/health) def health(): return {status: ok, service: demo-api}后端路由统一挂在/api前缀下这样 Caddy 转发时不需要重写路径语义也更清晰。文件backend/DockerfileFROM python:3.12-slim WORKDIR /code COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY app ./app EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]4.4 docker-compose.yml 编排在项目根目录准备.env文件存放数据库密码等敏感信息。这个文件要加入.gitignore绝不要提交到代码仓库。文件.envPOSTGRES_USERapp_user POSTGRES_PASSWORDreplace-with-a-strong-password POSTGRES_DBapp_db文件docker-compose.ymlservices: frontend: build: context: ./frontend args: NEXT_PUBLIC_API_BASE: restart: unless-stopped networks: - app-net backend: build: ./backend env_file: - .env environment: DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}db:5432/${POSTGRES_DB} depends_on: db: condition: service_healthy restart: unless-stopped networks: - app-net db: image: postgres:16-alpine env_file: - .env volumes: - pg_data:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}] interval: 5s timeout: 3s retries: 20 restart: unless-stopped networks: - app-net caddy: image: caddy:2-alpine ports: - 80:80 - 443:443 volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config restart: unless-stopped networks: - app-net volumes: pg_data: caddy_data: caddy_config: networks: app-net: driver: bridge有几个细节值得解释frontend 没有映射端口到宿主机因为它只需要在 docker 内部网络中供 Caddy 访问。对外统一走 Caddy 的 80/443。PostgreSQL 数据存放在命名卷pg_data中容器损坏或升级不会直接清空数据。backend 通过DATABASE_URL环境变量连接数据库服务名db在 compose 网络中可直接被解析。db 服务配置了 healthcheckbackend 启动前会等待数据库就绪避免首次启动时连接报错。4.5 Caddy 反向代理与自动 HTTPS文件Caddyfileweb.example.com { handle /api/* { reverse_proxy backend:8000