Tutoriais técnicos
Da compilação manual no VPS ao GitHub Actions + Docker
Registro do processo completo de migração de um projeto Next.js real da compilação manual em um VPS de pouca memória para a implantação automática com Docker + GitHub Actions. Indicado para desenvolvedores independentes com VPS 1C2G, inclui o Dockerfile, o deploy.yml e soluções para os problemas mais comuns.

Este artigo não é um tutorial de Docker nem uma introdução ao Next.js.
Ele é escrito para desenvolvedores independentes como eu:
-
Que usam um VPS barato (1C2G / 2C2G / 2C4G)
-
Com pouca memória e CPU fraca
-
Na mesma máquina costumam rodar também Nginx, Redis, bancos de dados e outros serviços
Nessas condições, continuar executando a compilação do projeto Next.js no VPS muitas vezes não é um "problema de otimização", mas um erro de escolha de arquitetura. Nesses cenários, mover o processo de build para a CI é, na verdade, a solução de menor custo e maior estabilidade.
Este artigo registra um retrospecto completo de um projeto real que migrou de "checkout + compilação manual no VPS" para GitHub Actions + Docker + implantação automática.
Nota de contexto:
Continuo usando o Nginx da máquina host para proxy reverso e TLS (o Nginx ainda não está em contêiner).
A porta do contêiner da aplicação é vinculada apenas a
127.0.0.1, encaminhada pelo Nginx do host, o que garante segurança e simplifica a superfície de exposição de rede.
No começo eu era como muita gente:
-
No VPS barato rodavam ao mesmo tempo 4~5 serviços Next.js
-
No VPS
git pull -
npm install -
npm run build -
pm2 restart/next start
Até que o projeto começou a ficar complexo.
Em um VPS com 2G / 4G de memória, o next build frequentemente:
-
Trava no meio da compilação
-
É morto pelo OOM Killer
-
Derruba a conexão SSH
-
Chega até a derrubar a máquina inteira
Para fazê-lo "rodar de qualquer jeito", eu precisava:
-
Parar temporariamente o Redis, o Nginx e outros serviços
-
Adicionar swap
-
Rodar com cuidado apenas um projeto por vez
Muito instável e muito desgastante.
Outro problema mais sutil:
-
Versão do Node, npm, lockfile
-
Dependências do sistema (openssl, glibc, fontes, chromium…)
Tudo isso está fortemente vinculado ao VPS.
O mesmo código: compila localmente, mas não necessariamente no servidor.
Toda vez que surge um problema, é preciso entrar por SSH e "adivinhar o ambiente".
Esta seção é especialmente para: desenvolvedores independentes que usam VPS baratos (1C2G / 2C2G / 2C4G), com pouca memória e CPU fraca.
Se você se pega parando serviços, adicionando swap e rezando para não sofrer OOM com frequência só por causa do
next build, não é que sua habilidade técnica seja insuficiente, mas sim que esse trabalho não deveria ser feito no VPS.
O objetivo central é uma única frase:
O servidor só é responsável por rodar contêineres, não mais por compilar código.
O processo após a migração:
-
push para
main -
GitHub Actions:
-
checkout do código
-
npm ci+next build -
build da imagem Docker
-
push para o registro de imagens
-
-
VPS:
-
docker compose pull -
docker compose up -d -
o serviço reinicia automaticamente
-
Benefícios diretos:
-
O VPS nunca mais sofrerá OOM por causa da compilação
-
Ambiente de compilação reproduzível (Docker)
-
Operações de implantação automatizadas (Actions)
-
Possibilidade de reverter para uma imagem anterior em caso de problema
Em uma frase:
Deixe o "instável" para a CI e o "estável" para o servidor.
A estrutura que uso atualmente é:
-
Nginx no host (não conteinerizado):
-
Cuida do HTTPS / certificados
-
Proxy reverso para
127.0.0.1:3000(ou outra porta local)
-
-
Docker Compose:
-
Executa o contêiner Next.js (opcional: Redis, filas etc.)
-
Porta vinculada apenas a
127.0.0.1, evitando expor o serviço diretamente à internet
-
Vantagens de fazer assim:
-
O Nginx continua sendo gerenciado do jeito de serviço do sistema que você já conhece
-
A implantação e a reversão da aplicação são totalmente baseadas em imagens
-
Superfície de exposição pública mínima
Se você quer uma imagem Docker menor e um runtime que não dependa do node_modules completo, é fortemente recomendado ativar o modo standalone.
Adicione em next.config.js ou next.config.mjs:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig
-
O tamanho da imagem Docker diminui visivelmente
-
O estágio runner não precisa do
node_modulescompleto -
Adapta-se melhor ao modelo de build na CI + execução no VPS
É por isso que o Dockerfile a seguir pode copiar apenas:
.next/standalone
.next/static
public
Princípios centrais:
-
O estágio build faz todo o trabalho pesado
-
O estágio runtime apenas executa os artefatos
# syntax=docker/dockerfile:1.6
############################
# 1) deps:安装依赖
############################
FROM node:22-slim AS deps
WORKDIR /app
# 证书(HTTPS 拉依赖/请求第三方)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# 先拷贝依赖清单以最大化缓存
COPY package.json package-lock.json* ./
RUN npm ci
############################
# 2) builder:编译 Next.js
############################
FROM node:22-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 关闭 Next 遥测
ENV NEXT_TELEMETRY_DISABLED=1
# -------------------------
# (可选) 构建期 Public Env(例如 Clerk 这类在 build 阶段会校验的配置)
# 需要在 GitHub Actions 中通过 build-args 传入
# -------------------------
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
# -------------------------
# (可选) 构建前生成命令:Prisma / OpenAPI / GraphQL Codegen 等
# 你的项目如果需要,取消注释即可
# -------------------------
# RUN npx prisma generate
# RUN npx openapi-typescript ./openapi.json -o ./src/types/openapi.d.ts
# RUN npx graphql-codegen
# 构建阶段编译
RUN npm run build
############################
# 3) runner:仅运行产物
############################
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV NEXT_TELEMETRY_DISABLED=1
# 运行时仅保留必要工具
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# -------------------------
# (可选) Puppeteer:如果你的项目需要在容器里跑 Chromium
# 方案:安装系统 Chromium + 指定 executablePath,并禁用 Puppeteer 自己下载
# 你的项目如果需要,取消注释即可
# -------------------------
# RUN apt-get update && apt-get install -y --no-install-recommends \
# chromium \
# fonts-noto-cjk \
# fontconfig \
# && rm -rf /var/lib/apt/lists/*
# ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# ENV PUPPETEER_SKIP_DOWNLOAD=true
# 只拷贝 standalone 运行所需文件
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/standalone ./
EXPOSE 3000
CMD ["node", "server.js"]
Ponto-chave: ao usar
output: 'standalone', o estágio runner só precisa copiarstandalone + static + public.
Antes de usar este deploy.yml, há três pontos que precisam ser preparados com antecedência, caso contrário o GitHub Actions não conseguirá se conectar corretamente ao seu VPS nem concluir o build. A seguir, essas três coisas são explicadas separadamente para que você possa segui-las diretamente.
O deploy.yml abaixo é a versão que uso de forma estável após a prática:
-
Push para
mainconstrói automaticamente a imagem e a envia para o Docker Hub -
SSH para o VPS, gera
docker-compose.ymle baixa a imagem mais recente para reiniciar -
Porta do contêiner vinculada apenas a
127.0.0.1
Você precisa configurar no repositório do GitHub em Settings → Secrets and variables → Actions:
DOCKER_USERNAME/DOCKER_PASSWORD
SERVER_HOST
SERVER_SSH_KEY
name: Deploy Next.js (Docker Compose)
on:
push:
branches: ["main"]
env:
IMAGE_NAME: adfuture/aimazing.site
SERVICE_NAME: aimazing-site
HOST_PORT: 3001
DEPLOY_PATH: /data/aimazing-site-docker
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max
# (可选) build 阶段需要的 public env(例如 Clerk publishableKey)
# 需要先在 GitHub Secrets 中配置:NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
# build-args: |
# NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
- name: Deploy to VPS
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: root
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
set -e
DEPLOY_PATH="${{ env.DEPLOY_PATH }}"
IMAGE_NAME="${{ env.IMAGE_NAME }}"
SERVICE_NAME="${{ env.SERVICE_NAME }}"
HOST_PORT="${{ env.HOST_PORT }}"
mkdir -p "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
# 生成 docker-compose.yml(顶格输出,避免缩进导致 YAML 解析异常)
cat > docker-compose.yml <<EOF
services:
app:
image: ${IMAGE_NAME}:latest
container_name: ${SERVICE_NAME}
restart: unless-stopped
ports:
- "127.0.0.1:${HOST_PORT}:3000"
env_file:
- .env
environment:
# 运行在宿主机 Nginx 后面,仅用于容器内部探活
HOSTNAME: 127.0.0.1
PORT: 3000
# (可选) Redis:如果你把 redis 也放进 compose,用服务名访问
# REDIS_HOST: redis
# REDIS_PORT: "6379"
# (可选) Redis:如果启用 redis 服务,取消注释 depends_on
# depends_on:
# - redis
healthcheck:
# 直接使用固定的本地回环地址进行探活
test: ["CMD-SHELL", "curl -fsS https://127.0.0.1:3000/health >/dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# (可选) Redis:需要的话直接取消注释即可
# redis:
# image: redis:7-alpine
# container_name: ${SERVICE_NAME}-redis
# restart: unless-stopped
# command: ["redis-server", "--appendonly", "yes"]
# volumes:
# - redis_data:/data
# healthcheck:
# test: ["CMD", "redis-cli", "ping"]
# interval: 10s
# timeout: 3s
# retries: 10
# volumes:
# redis_data:
EOF
# 确保 .env 存在(你在 VPS 上手动维护真实变量)
test -f .env || touch .env
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
Antes de continuar, acrescento um passo de preparação que passa muito facilmente despercebido, mas que com certeza vai travar os iniciantes: como configurar o GitHub Secrets (SERVER_HOST / SERVER_SSH_KEY).
SERVER_HOST é o seu IP público ou domínio do VPS, por exemplo:
1.2.3.4
ou:
vps.example.com
No repositório do GitHub, vá em:
Settings → Secrets and variables → Actions → New repository secret
-
Name:
SERVER_HOST -
Value: o IP ou domínio do seu VPS
Execute no seu próprio computador (não no VPS):
ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/github_actions
Serão gerados dois arquivos:
~/.ssh/github_actions # 私钥(给 GitHub)
~/.ssh/github_actions.pub # 公钥(给 VPS)
Em seguida, adicione a chave pública ao VPS:
cat ~/.ssh/github_actions.pub | ssh root@YOUR_VPS_IP "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
Depois, copie o conteúdo da chave privada para o GitHub Secrets:
cat ~/.ssh/github_actions
-
Name:
SERVER_SSH_KEY -
Value: o conteúdo completo da chave privada exibido acima (incluindo
BEGIN/END)
Recomendação: use esta chave apenas para a implantação da CI, não a misture com a chave que você usa normalmente para acessar o VPS.
Nesta parte, procuro usar o formato de "antes / depois" ou "blocos de código mínimos" para que você possa copiar e usar diretamente.
Sintoma:
container_name: ${SERVICE_NAME}
Mas o .env já tem SERVICE_NAME, e mesmo assim na inicialização está vazio.
Causa:
-
Campos como
container_name / image / ports -
Leem apenas variáveis da fase de análise do Compose (shell ou
.envno mesmo diretório) -
O
env_file:é injetado apenas no runtime do contêiner
Abordagem recomendada:
-
Defina explicitamente as variáveis de nível Compose no script SSH do
deploy.yml -
Coloque apenas as variáveis de negócio no
.env
Antes (conexão direta ao host):
redis://127.0.0.1:6379
Depois (rede interna do Docker Compose):
redis://redis:6379
Ou unificar por variáveis de ambiente:
REDIS_HOST=redis
REDIS_PORT=6379
`redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`
Conhecimento central: o
127.0.0.1dentro de um contêiner aponta apenas para o próprio contêiner atual, não para o host nem para outros contêineres.
127.0.0.1 não é o host, nem outros contêineres.**
Sintoma:
-
Roda localmente
-
O
next buildfalha no estágio de Docker build
A causa costuma ser falta de código gerado.
Local correto (Dockerfile / estágio builder):
# 在 npm run build 之前
RUN npx prisma generate
RUN npm run build
Princípio: todos os "comandos generativos" devem entrar no processo de build do Docker.
Antes (forma padrão):
puppeteer.launch()
Depois (especificar o caminho explicitamente):
puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
})
Além disso, especifique no Dockerfile:
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_SKIP_DOWNLOAD=true
Causa:
-
O estágio de build do Next.js executa parte do código dos componentes
-
O ambiente de CI / Docker build não tem a public env
GitHub Actions (passar build-arg):
build-args: |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
Dockerfile (injetar no estágio builder):
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
Deve ser colocado antes de:
RUN npm run build
Problema comum:
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/ || exit 1"]
Em alguns frameworks ou servidores personalizados, o / não é um endpoint de saúde acessível.
Abordagem recomendada:
- Exponha um endpoint de saúde fixo na aplicação, por exemplo:
/health
- Controle de forma unificada por variáveis de ambiente no
docker-compose.yml:
environment:
HOSTNAME: http://127.0.0.1:3000
healthcheck:
test: ["CMD-SHELL", "curl -fsS $HOSTNAME/health || exit 1"]
Vantagens de fazer assim:
-
O endereço da verificação de saúde é configurável
-
Vários ambientes (porta / prefixo) não exigem alterar a estrutura do compose
-
Mais próximo do estado real de disponibilidade do negócio
-
Uma conta gratuita do Docker Hub normalmente tem apenas 1 cota de repositório privado.
-
Se você tem vários projetos privados, recomenda-se migrar para o GHCR (GitHub Container Registry):
-
Imagens privadas mais flexíveis
-
Integração nativa com o GitHub Actions
-
(Mais adiante vou escrever um artigo separado: modelo completo de reescrita para migrar do Docker Hub para o GHCR.)
De "compilar manualmente no VPS" para "implantação automática com Docker + CI", a maior mudança não é técnica, mas de mentalidade:
Não trato mais o servidor como um "ambiente de desenvolvimento", mas como um nó de infraestrutura que funciona de forma estável.
Assim que o build sai do VPS:
-
Falhas de compilação não afetam mais a produção
-
O VPS não cai mais aleatoriamente por OOM
-
A implantação passa de "operação manual" para "processo reproduzível"
Se você está trabalhando em um projeto Next.js, especialmente em um VPS de pouca memória, recomendo mover o build para a CI o quanto antes.