Tutoriales técnicos
De la compilación manual en un VPS a GitHub Actions + Docker
Registro del proceso completo de migrar un proyecto real de Next.js desde la compilación manual en un VPS de poca memoria hasta el despliegue automático con Docker + GitHub Actions. Adecuado para desarrolladores independientes con un VPS 1C2G, incluye el Dockerfile, deploy.yml y soluciones a los problemas más comunes.

Este artículo no es un tutorial de Docker ni una introducción a Next.js.
Está escrito para desarrolladores independientes como yo:
-
Que usan un VPS barato (1C2G / 2C2G / 2C4G)
-
Con poca memoria y CPU débil
-
En la misma máquina suelen correr además Nginx, Redis, bases de datos y otros servicios
En estas condiciones, seguir ejecutando la compilación del proyecto Next.js en el VPS a menudo no es un "problema de optimización", sino un error de elección de arquitectura. En estos escenarios, trasladar el proceso de compilación a CI es, en cambio, la solución de menor coste y mayor estabilidad.
Este artículo recoge un análisis retrospectivo completo de un proyecto real que migró de "checkout + compilación manual en el VPS" a GitHub Actions + Docker + despliegue automático.
Nota de contexto:
Sigo usando el Nginx del host para el proxy inverso y TLS (Nginx aún no está en contenedores).
El puerto del contenedor de la aplicación solo se enlaza a
127.0.0.1, y el Nginx del host lo reenvía, lo que garantiza la seguridad y simplifica la superficie de exposición de red.
Al principio era como mucha gente:
-
En un VPS barato corrían a la vez 4~5 servicios de Next.js
-
En el VPS
git pull -
npm install -
npm run build -
pm2 restart/next start
Hasta que el proyecto empezó a complicarse.
En un VPS con 2G / 4G de memoria, next build a menudo:
-
Se queda colgado a mitad de la compilación
-
Lo mata el OOM Killer
-
Se corta la conexión SSH
-
Incluso se pierde la máquina entera
Para que "apenas funcione", tenía que:
-
Detener temporalmente Redis, Nginx y otros servicios
-
Añadir swap
-
Ejecutar con cuidado solo un proyecto a la vez
Muy inestable y muy agotador.
Otro problema más sutil:
-
Versión de Node, npm, lockfile
-
Dependencias del sistema (openssl, glibc, fuentes, chromium…)
Todo esto está fuertemente ligado al VPS.
El mismo código: compila en local, pero no necesariamente en el servidor.
Cada vez que surge un problema, hay que entrar por SSH y "adivinar el entorno".
Esta sección está especialmente dirigida a: desarrolladores independientes que usan VPS baratos (1C2G / 2C2G / 2C4G), con poca memoria y CPU débil.
Si te descubres parando servicios, añadiendo swap y rezando para no sufrir OOM con frecuencia solo por
next build, no es que tu nivel técnico sea insuficiente, sino que ese trabajo no debería hacerse en el VPS.
El objetivo central es una sola frase:
El servidor solo se encarga de ejecutar contenedores, ya no de compilar código.
El proceso tras la migración:
-
push a
main -
GitHub Actions:
-
checkout del código
-
npm ci+next build -
build de la imagen Docker
-
push al registro de imágenes
-
-
VPS:
-
docker compose pull -
docker compose up -d -
el servicio se reinicia automáticamente
-
Beneficios directos:
-
El VPS ya no sufrirá OOM por la compilación
-
Entorno de compilación reproducible (Docker)
-
Operaciones de despliegue automatizadas (Actions)
-
Posibilidad de revertir a una imagen anterior si hay problemas
En una frase:
Deja lo "inestable" a CI y lo "estable" al servidor.
La estructura que uso actualmente es:
-
Nginx en el host (no en contenedor):
-
Gestiona HTTPS / certificados
-
Proxy inverso a
127.0.0.1:3000(u otro puerto local)
-
-
Docker Compose:
-
Ejecuta el contenedor de Next.js (opcional: Redis, colas, etc.)
-
Puerto enlazado solo a
127.0.0.1, para evitar exponer el servicio directamente a Internet
-
Ventajas de hacerlo así:
-
Nginx se sigue gestionando con el método de servicio del sistema que ya conoces
-
El despliegue y la reversión de la aplicación están totalmente basados en imágenes
-
Superficie de exposición pública mínima
Si quieres una imagen Docker más pequeña y un runtime que no dependa del node_modules completo, se recomienda encarecidamente activar el modo standalone.
Añade en next.config.js o next.config.mjs:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig
-
El tamaño de la imagen Docker se reduce notablemente
-
La etapa runner no necesita el
node_modulescompleto -
Se adapta mejor al modelo de build en CI + ejecución en VPS
Por eso el Dockerfile posterior puede copiar solo:
.next/standalone
.next/static
public
Principios clave:
-
La etapa build hace todo el trabajo pesado
-
La etapa runtime solo ejecuta los artefactos
# 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"]
Punto clave: al usar
output: 'standalone', la etapa runner solo necesita copiarstandalone + static + public.
Antes de usar este deploy.yml, hay tres puntos que debes preparar con antelación, de lo contrario GitHub Actions no podrá conectarse correctamente a tu VPS ni completar el build. A continuación se explican por separado estas tres cosas para que puedas seguirlas directamente.
El deploy.yml siguiente es la versión que uso de forma estable tras la práctica:
-
Al hacer push a
mainse construye automáticamente la imagen y se sube a Docker Hub -
SSH al VPS, genera
docker-compose.ymly descarga la última imagen para reiniciar -
El puerto del contenedor se enlaza solo a
127.0.0.1
Debes configurar en el repositorio de GitHub en 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, añado un paso de preparación que se pasa muy fácilmente por alto pero que sin duda bloqueará a los principiantes: cómo configurar GitHub Secrets (SERVER_HOST / SERVER_SSH_KEY).
SERVER_HOST es tu IP pública o dominio del VPS, por ejemplo:
1.2.3.4
o:
vps.example.com
En el repositorio de GitHub, ve a:
Settings → Secrets and variables → Actions → New repository secret
-
Name:
SERVER_HOST -
Value: la IP o el dominio de tu VPS
Ejecuta en tu propio ordenador (no en el VPS):
ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/github_actions
Se generarán dos archivos:
~/.ssh/github_actions # 私钥(给 GitHub)
~/.ssh/github_actions.pub # 公钥(给 VPS)
A continuación, añade la clave pública al VPS:
cat ~/.ssh/github_actions.pub | ssh root@YOUR_VPS_IP "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
Luego, copia el contenido de la clave privada en GitHub Secrets:
cat ~/.ssh/github_actions
-
Name:
SERVER_SSH_KEY -
Value: el contenido completo de la clave privada mostrado arriba (incluye
BEGIN/END)
Recomendación: usa esta clave solo para el despliegue de CI, no la mezcles con la clave que usas normalmente para iniciar sesión en el VPS.
En esta parte intento usar el formato de "antes / después" o "bloques de código mínimos" para que puedas copiarlos y usarlos directamente.
Síntoma:
container_name: ${SERVICE_NAME}
Pero .env ya tiene SERVICE_NAME, y sin embargo al arrancar está vacío.
Causa:
-
Campos como
container_name / image / ports -
Solo leen variables de la fase de análisis de Compose (shell o
.enven el mismo directorio) -
env_file:solo se inyecta en el runtime del contenedor
Enfoque recomendado:
-
Define explícitamente las variables de nivel Compose en el script SSH de
deploy.yml -
Solo pon las variables de negocio en
.env
Antes (conexión directa al host):
redis://127.0.0.1:6379
Después (red interna de Docker Compose):
redis://redis:6379
O unificar mediante variables de entorno:
REDIS_HOST=redis
REDIS_PORT=6379
`redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`
Conocimiento clave:
127.0.0.1dentro de un contenedor solo apunta al propio contenedor actual, no al host ni a otros contenedores.
127.0.0.1 no es el host, ni otros contenedores.**
Síntoma:
-
Funciona en local
-
next buildfalla en la etapa de Docker build
La causa suele ser falta de código generado.
Ubicación correcta (Dockerfile / etapa builder):
# 在 npm run build 之前
RUN npx prisma generate
RUN npm run build
Principio: todos los "comandos generativos" deben entrar en el proceso de build de Docker.
Antes (forma predeterminada):
puppeteer.launch()
Después (especificar la ruta explícitamente):
puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
})
Además, especifica en el Dockerfile:
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_SKIP_DOWNLOAD=true
Causa:
-
La etapa de build de Next.js ejecuta parte del código de los componentes
-
El entorno de CI / Docker build carece de la public env
GitHub Actions (pasar build-arg):
build-args: |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
Dockerfile (inyectar en la etapa builder):
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
Debe colocarse antes de:
RUN npm run build
Problema común:
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/ || exit 1"]
En algunos frameworks o servidores personalizados, / no es un endpoint de salud accesible.
Enfoque recomendado:
- Expón un endpoint de salud fijo en la aplicación, por ejemplo:
/health
- Contrólalo de forma unificada mediante variables de entorno en
docker-compose.yml:
environment:
HOSTNAME: http://127.0.0.1:3000
healthcheck:
test: ["CMD-SHELL", "curl -fsS $HOSTNAME/health || exit 1"]
Ventajas de hacerlo así:
-
La dirección de la comprobación de salud es configurable
-
Varios entornos (puerto / prefijo) no requieren cambiar la estructura de compose
-
Más cercano al estado real de disponibilidad del negocio
-
Una cuenta gratuita de Docker Hub normalmente solo tiene 1 cuota de repositorio privado.
-
Si tienes varios proyectos privados, se recomienda migrar a GHCR (GitHub Container Registry):
-
Imágenes privadas más flexibles
-
Integración nativa con GitHub Actions
-
(Más adelante escribiré un artículo aparte: plantilla completa de reescritura para migrar de Docker Hub a GHCR.)
De "compilar manualmente en el VPS" a "despliegue automático con Docker + CI", el mayor cambio no es técnico, sino de mentalidad:
Ya no trato el servidor como un "entorno de desarrollo", sino como un nodo de infraestructura que funciona de forma estable.
Una vez que el build sale del VPS:
-
Los fallos de compilación ya no afectan a producción
-
El VPS ya no se cae aleatoriamente por OOM
-
El despliegue pasa de "operación manual" a "proceso reproducible"
Si estás trabajando en un proyecto Next.js, especialmente en un VPS de poca memoria, te recomiendo trasladar el build a CI cuanto antes.