摆摊AI 产品实战手册

自建服务器部署

一台云主机 + Docker Compose + nginx 反代 + Let's Encrypt 证书,从零到 HTTPS 可访问。含摆摊自己的完整配置和踩过的坑。

托管平台省事,但有些情况你必须自己管服务器:需要长期运行的后台任务、特殊的运行时、自建数据库,或者单纯是账单算下来更便宜。

这一页给的是一条走通过的最小路径:一台云主机,Docker Compose 起应用和数据库,nginx 做反向代理,Let's Encrypt 签证书。 摆摊自己就跑在这套上面,下面的配置是实际在用的,不是示例。

代价先说清楚:系统更新、备份、监控、故障恢复全部由你负责。托管平台帮你做的那些事,现在是你的工作。

快速版

  1. 买一台最低配云主机(1-2 核 2G 起步够用),选离用户近的区域。
  2. 装 Docker 和 Docker Compose。
  3. 应用打成镜像,用 Compose 起应用 + 数据库,应用端口只监听 127.0.0.1。
  4. nginx 反代到应用端口,certbot 签证书。
  5. 数据库端口绑 127.0.0.1,永远不要暴露到公网。
  6. 上线前用生产产物完整冒烟一遍——dev 通过不代表部署形态通过。

为什么是这套组合

组件作用换掉的代价
Docker Compose应用和数据库一条命令起停,环境一致裸装 Node + Postgres,换机器时要重来一遍
nginx 反向代理统一入口、HTTPS 终止、多站点共存应用直接暴露端口,证书和多域名都难办
Let's Encrypt + certbot免费证书,自动续期手工管证书,忘记续期就整站挂

这套的好处是每一层都能单独替换,而且都是十年以上的成熟东西,出问题搜得到答案。

一、镜像怎么构建

关键点是多阶段构建:依赖层、构建层、运行层分开,最终镜像只带运行时需要的东西。

摆摊的 Dockerfile 结构(Next.js standalone 产物):

# ---- 依赖层 ----
FROM node:24-alpine AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --ignore-scripts

# ---- 构建层 ----
FROM node:24-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
RUN pnpm build

# ---- 运行层 ----
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

四个容易踩的点:

ENV HOSTNAME=0.0.0.0。 不加这一行,服务只监听容器内的 localhost,Docker 端口映射进来是空的。这个错的症状是"容器在跑,但端口连不上"。

构建期变量和运行期变量不是一回事。 打进前端产物的公开变量(站点地址、公开 key)必须在构建时通过 ARG 传入——它们被烤进静态文件里了。改了这类变量必须重新构建,重启容器没用。

构建期不要连数据库。 构建时给一个占位的连接串让模块初始化不报错就行。构建机连生产库是个坏习惯,而且 CI 里通常连不上。

用非 root 用户跑。 adduser 那两行不是形式主义,容器逃逸时这是一道实际的墙。

二、Compose 编排

services:
  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: myapp
    ports:
      # 只绑本机,绝不暴露到公网
      - '127.0.0.1:${DB_PORT:-5432}:5432'
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 3s
      retries: 10

  app:
    build:
      context: .
      args:
        NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
    restart: unless-stopped
    ports:
      - '${APP_PORT:-3000}:3000'
    environment:
      DATABASE_URL: postgres://postgres:${DB_PASSWORD}@db:5432/myapp
      # 其余密钥从同目录 .env 读
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:

ports: - '127.0.0.1:5432:5432' 里的 127.0.0.1: 前缀是这一整页最重要的九个字符。写成 - '5432:5432' 的话,你的数据库直接暴露在公网上,而且 Docker 会绕过 ufw 之类的防火墙规则——你以为端口是关的,实际是开的。暴露的 Postgres 通常几小时内就会被扫到。

三个值得注意的配置:

  • restart: unless-stopped——服务器重启后容器自动回来。少了这个,你会在某次意外重启后发现站挂了一整天。
  • healthcheck + depends_on: condition: service_healthy——应用会等数据库真的能接受连接,而不是等容器"启动了"。没有这个,应用启动比数据库快时会崩一次再重启。
  • 数据卷 pgdata——数据在卷里,不在容器里。容器可以随便删重建,卷不能。

三、nginx 反代 + HTTPS

nginx 配置的最小可用版本:

server {
    listen 80;
    server_name example.com;
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        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;
    }
}

然后签证书,certbot 会自动改写上面的配置加上 443 和跳转:

certbot --nginx -d example.com --non-interactive --agree-tos --redirect

那四个 proxy_set_header 不是可选的。 少了 X-Forwarded-Proto,应用会认为自己在跑 HTTP,于是生成 http:// 的回调地址和 cookie 设置——症状是登录成功后立刻掉登录,或者 OAuth 回调跳到 http 然后失败。

签证书前先确认 DNS 已经生效,否则 certbot 的验证会失败:

dig +short example.com @8.8.8.8   # 必须返回你的服务器 IP

www 子域没解析就不要一起签。 certbot 是整批验证,其中一个域名验不过,整次签发失败,你会以为是主域名的问题。

四、把接入写成脚本

手工敲这些命令的问题不是麻烦,是换域名时你会漏掉一步。摆摊把整个接入过程写成了一个幂等脚本(scripts/setup-nginx.sh),重复执行只会覆盖配置并让 certbot 复用已有证书。

脚本里做了三件手工容易忘的事:

  1. 先校验 DNS,没生效直接退出,不去浪费一次 certbot 请求。
  2. www 子域按解析结果决定是否纳入签发。
  3. 换域名时把旧域名改成 301,而不是删掉配置——旧域名的证书必须保留,否则 HTTPS 的老链接会握手失败。用户点开一个旧链接看到的是证书错误页,比 404 更糟。

第 3 点是实际迁移时才会发现的:摆摊从 apischolar.com 换到 baitan.dev 的时候,旧域名的 nginx 配置改成了保留证书 + return 301,而不是删除。

五、上线前必须用生产产物冒烟

这一节是整页里最容易被跳过、代价最大的一节。

dev 通过不代表部署形态通过。 摆摊踩过的具体例子:i18n 的语言前缀原本用 middleware 的 rewrite 实现,dev 下完全正常,打成 standalone 产物之后退化成 308 自跳转死循环——整站打不开。最后改成在 next.config.mjs 的 redirects() / rewrites() 里处理,因为这两个由路由核心处理,dev 和 standalone 行为一致。

这类问题的共同特征是:代码没错,是运行形态不同。 类型检查、单元测试、dev 手点,一个都发现不了。

所以上线前必须:

# 用生产命令构建并跑起来,不是 pnpm dev
docker compose up -d --build
# 然后从公网域名(不是 localhost)走一遍:
#   首页 / 内页刷新 / 登录 / 受保护页面 / 支付回调

从公网域名测很重要。localhost 测不出反向代理、协议头、cookie domain 这一类问题。

六、部署更新的流程

每次更新固定四步,顺序不要变:

# 1. 先备份(尤其是有数据库变更时)
docker compose exec -T db pg_dump -U postgres myapp | gzip > ~/backups/db-$(date +%F).sql.gz

# 2. 同步代码 / 拉取新版本
#    服务器目录不一定是 git 仓库,rsync 同步也很常见

# 3. 重新构建并启动
docker compose up -d --build app

# 4. 验证,而不是假设它好了
curl -s -o /dev/null -w '%{http_code}' https://example.com
docker compose logs --tail=50 app

第 4 步的 curl 拿状态码比打开浏览器可靠——浏览器有缓存,会给你一个已经挂了的站的旧页面。

常见卡点

卡在哪可能原因试试这样
容器在跑但端口连不上服务只监听容器内 localhostDockerfile 里加 ENV HOSTNAME=0.0.0.0
登录成功后立刻掉登录缺 X-Forwarded-Proto,应用以为自己是 HTTP补齐四个 proxy_set_header
改了站点地址但页面没变公开变量是构建期烤进产物的重新 --build,不是重启
certbot 报验证失败DNS 没生效,或 www 没解析却一起签了先 dig 确认,www 未解析就别带上
数据库被扫、被删库端口暴露到公网ports 加 127.0.0.1: 前缀,Docker 会绕过 ufw
服务器重启后站没了容器没配自动重启每个服务加 restart: unless-stopped
应用启动时报连不上数据库应用比数据库先就绪healthcheck + condition: service_healthy
dev 正常线上路由乱跳运行形态差异用生产产物从公网域名冒烟,见上面第五节

验收标准

  • docker compose up -d 能从零把应用和数据库起起来
  • 数据库端口只绑 127.0.0.1,公网扫不到
  • HTTPS 正常,证书自动续期已确认(certbot renew --dry-run)
  • 应用容器和数据库容器都配了 restart: unless-stopped
  • 数据在卷里,删掉重建容器不丢数据
  • 用生产产物从公网域名走过完整主流程
  • 更新流程写下来了,包含备份和验证两步
  • 服务器重启一次,站点自己回来了(真的重启验证过)

本章动作

在服务器上执行 sudo reboot,然后什么都不做,等两分钟。

如果站点自己回来了,你的部署是可靠的。如果没有,你现在知道了一个本来会在半年后某个凌晨发现的问题。

顺手再跑一次 certbot renew --dry-run——证书过期是自建部署最常见的整站故障,而它 100% 可以提前避免。

下一步

  1. 数据库迁移、备份与恢复——备份要能恢复才算备份
  2. 监控、日志与故障处理——出问题时怎么知道、怎么查
  3. 上线前验收——交给用户之前的完整检查

来源

  • Dockerfile、Compose 编排、nginx 配置与 i18n 运行形态差异,均来自摆摊自身部署实测,2026-08。
  • Let's Encrypt 证书有效期与续期机制以 letsencrypt.org 官方文档为准。

On this page