Mueve una app de Next.js de Vercel a tu propio servidor
Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta
Tu app de Next.js corre en Vercel y quieres tenerla en un servidor que tú controles, con una factura mensual fija. El código apenas cambia. Lo que cambia es todo lo que Vercel hacía alrededor: construir en cada push, guardar tus variables de entorno, configurar algunas variables propias, correr cron jobs, y servir HTTPS. Esta guía traslada cada una de esas piezas a un solo servidor, luego cambia el DNS sin downtime, y mantiene el despliegue de Vercel como respaldo hasta que el nuevo haya demostrado que funciona.
Lo que necesitas
- Un servidor. Esta guía usa un droplet Basic de DigitalOcean,
s-1vcpu-2gb(1 vCPU, 2 GB de memoria, 50 GB SSD), a 12 USD al mes. Si tu build es pesado,s-2vcpu-4gbcuesta 24 USD al mes. Cualquier servidor Ubuntu 24.04 con IP pública funciona de la misma manera. - Acceso al DNS del dominio, dondequiera que esté alojado hoy (los nameservers de Vercel o tu registrador).
- El CLI de Vercel en tu propia máquina (
npm i -g vercel), con sesión iniciada en el equipo dueño del proyecto. - El repositorio desde el que despliega Vercel, con un lockfile incluido en el commit.
Los comandos asumen Next.js 16 (16.4.3 al momento de escribir esto) y Node.js 24, la línea LTS actual.
Paso 1: Enumera lo que Vercel hace por ti
Antes de tocar el servidor, revisa el proyecto en busca de las partes que dependen del propio Vercel:
grep -rn "VERCEL_" --include=*.ts --include=*.tsx --include=*.js --include=*.mjs . | grep -v node_modules
cat vercel.json 2>/dev/null
Normalmente encontrarás algunas de estas:
- Variables de entorno del sistema. Vercel configura
VERCEL_ENV,VERCEL_URL,VERCEL_PROJECT_PRODUCTION_URL,VERCEL_GIT_COMMIT_SHAy otras en cada despliegue. Ninguna existe en tu servidor. El Paso 3 reemplaza las que lee tu código. - Cron jobs definidos bajo
cronsenvercel.json. Vercel los dispara enviando una petición GET a la ruta que listaste, en un horario en UTC. El Paso 8 los traslada al cron del servidor. - Preview deployments para cada rama y pull request. Un solo servidor corre un solo entorno: las preguntas frecuentes cubren tus opciones.
- Paquetes exclusivos de Vercel como
@vercel/analyticso@vercel/functions. Revisa su documentación para saber qué hacen fuera de Vercel antes de depender de ellos.
Las funciones del framework en sí te acompañan. La guía de autoalojamiento de Next.js establece que la optimización de imágenes a través de next/image y Proxy (la convención de archivo antes llamada Middleware) funcionan ambas sin configuración adicional cuando ejecutas el servidor de Next.js.
Paso 2: Exporta tus variables de entorno
Desde el directorio del proyecto, vincúlalo y descarga las variables de producción a un archivo:
vercel link
vercel env pull .env.vercel --environment=production
Abre .env.vercel y revisa cada valor. Las variables guardadas con el tipo Secret de Vercel no se pueden leer de vuelta, así que el CLI escribe un placeholder para cada una. Obtén los valores reales de donde vinieron (tu proveedor de base de datos, Stripe, tu proveedor de autenticación) y complétalos. Mantén este archivo fuera de Git: el .gitignore por defecto de Next.js ya ignora los archivos .env*.
Deberías terminar con una lista completa de líneas NAME=value y sin placeholders.
Paso 3: Reemplaza las variables que Vercel configuraba por ti
Por cada variable VERCEL_ que lee tu código, agrega una variable propia y apunta el código hacia ella. El caso común es construir URLs absolutas:
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
Luego agrega NEXT_PUBLIC_SITE_URL=https://app.example.com a tu archivo de entorno. Haz lo mismo con las librerías de autenticación: Auth.js lee AUTH_URL (NextAuth v4 lee NEXTAUTH_URL), y en Vercel podía inferir la URL. En tu servidor, configúrala con tu dominio real.
Haz commit de los cambios de código. Deberías poder ejecutar grep -rn "VERCEL_" de nuevo y no ver nada que tu app todavía necesite.
Paso 4: Activa la salida standalone
Agrega output: "standalone" a next.config.ts, conservando tus otras opciones:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
Con esto, next build escribe un servidor autocontenido en .next/standalone, así que la imagen de producción no necesita tu node_modules completo.
Paso 5: Agrega un Dockerfile
Este Dockerfile sigue el ejemplo oficial with-docker del repositorio de Next.js: instala las dependencias con el gestor de paquetes al que pertenece tu lockfile, construye, y ejecuta solo la salida standalone como el usuario sin privilegios node.
FROM node:24-slim AS dependencies
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; \
elif [ -f yarn.lock ]; then corepack enable yarn && yarn install --frozen-lockfile --production=false; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install --frozen-lockfile; \
else echo "No lockfile found." && exit 1; \
fi
FROM node:24-slim AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NODE_ENV=production
RUN if [ -f package-lock.json ]; then npm run build; \
elif [ -f yarn.lock ]; then corepack enable yarn && yarn build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm build; \
fi
FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]
En Vercel, cada variable estaba presente durante el build. En Docker, solo está lo que tú pasas. Next.js incrusta las variables NEXT_PUBLIC_ en el bundle del navegador durante next build, así que cada una necesita un par ARG y ENV en la etapa builder, como NEXT_PUBLIC_SITE_URL arriba. Las variables solo de servidor no lo necesitan: la app las lee en tiempo de ejecución.
Agrega un .dockerignore para que los archivos locales y los secretos queden fuera del build:
.git
.next
node_modules
.env*
Constrúyela y córrela una vez en tu propia máquina con las variables exportadas, para detectar problemas antes de involucrar al servidor:
docker build --build-arg NEXT_PUBLIC_SITE_URL=http://localhost:3000 -t my-app .
docker run --rm -p 3000:3000 --env-file .env.vercel my-app
Deberías ver la app en http://localhost:3000, no un crash por una variable faltante.
Paso 6: Prepara el servidor
Crea el droplet (Ubuntu 24.04, Basic, 12 USD), conéctate por SSH e instala Docker con su script oficial:
curl -fsSL https://get.docker.com | sh
docker compose version
Deberías ver impresa una versión de Compose. Luego clona el repositorio y copia tu archivo de entorno hacia él, renombrado a .env:
git clone https://github.com/you/your-app.git /opt/your-app
scp .env.vercel root@your-server-ip:/opt/your-app/.env
Ejecuta el primer comando en el servidor y el segundo en tu propia máquina. En el servidor, asegúrate de que .env contenga NEXT_PUBLIC_SITE_URL con tu dominio real, y bloquea el archivo con chmod 600 /opt/your-app/.env.
Paso 7: Agrega Compose y Caddy, y luego inicia
La guía de autoalojamiento de Next.js recomienda un proxy inverso delante del servidor de Node. Caddy cumple ese rol y gestiona los certificados. Agrega compose.yaml al repositorio:
services:
app:
build:
context: .
args:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
restart: unless-stopped
env_file: .env
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- app
volumes:
caddy-data:
caddy-config:
Y un Caddyfile con tu dominio:
app.example.com {
reverse_proxy app:3000
}
Haz commit de ambos y git pull en el servidor. Por ahora, inicia solo la app. Caddy espera hasta el Paso 9, porque no puede obtener un certificado mientras el dominio todavía apunte a Vercel:
cd /opt/your-app
docker compose up -d --build app
El primer build tarda unos minutos. Luego comprueba que la app responda dentro de su contenedor:
docker compose exec app node -e "fetch('http://localhost:3000').then((r) => console.log(r.status))"
Deberías ver 200. Si en cambio ves un error, docker compose logs app muestra por qué la app no arrancó, lo cual normalmente es una variable faltante.
Paso 8: Traslada los cron jobs de Vercel al servidor
Por cada entrada bajo crons en vercel.json, agrega una línea de crontab que solicite la misma ruta. Los horarios de Vercel siempre están en UTC, y también lo está un servidor Ubuntu recién creado, así que las expresiones se copian sin cambios. Si tu ruta verifica CRON_SECRET (Vercel lo envía como Authorization: Bearer <value>), sigue verificándolo y envía el encabezado tú mismo.
Crea un pequeño script en el servidor:
cat > /opt/your-app/cron-request.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
set -a
source /opt/your-app/.env
set +a
curl -fsS -H "Authorization: Bearer ${CRON_SECRET}" "https://app.example.com$1"
EOF
chmod 700 /opt/your-app/cron-request.sh
Luego crontab -e y agrega una línea por cada job, usando la ruta y el horario de vercel.json:
0 5 * * * /opt/your-app/cron-request.sh /api/cron >> /var/log/your-app-cron.log 2>&1
Pon un # delante de cada línea por ahora, y quítalo una vez que el DNS se haya trasladado en el Paso 9, para que ningún job corra en ambas plataformas a la vez. Una vez que esté en vivo, tail /var/log/your-app-cron.log después de la hora programada debería mostrar la respuesta de tu ruta.
Paso 9: Cambia el DNS
Baja el TTL del registro DNS del dominio con un día de anticipación si puedes. Luego cambia el registro para que apunte a tu servidor: un registro A con la dirección IPv4 del servidor, reemplazando el registro de Vercel. Si el dominio usa los nameservers de Vercel, haz el cambio en la configuración de DNS de Vercel, o traslada primero el dominio a otro proveedor de DNS.
Espera hasta que la nueva dirección sea lo que el mundo ve:
dig +short app.example.com
Cuando eso imprima la IP de tu servidor, inicia Caddy y observa cómo obtiene el certificado:
docker compose up -d caddy
docker compose logs -f caddy
Deberías ver una línea que indique que el certificado se obtuvo correctamente. Luego https://app.example.com carga desde tu servidor con un certificado válido. Descomenta las líneas de crontab y desactiva los cron jobs en la configuración del proyecto en Vercel.
Mantén el proyecto de Vercel durante unos días. Los visitantes cuyo DNS no se haya actualizado todavía llegan a él, y si algo sale mal puedes volver a apuntar el registro.
Paso 10: Despliega actualizaciones y conserva copias de seguridad
Ahora una versión es:
cd /opt/your-app
git pull
docker compose up -d --build app
Caddy sigue corriendo y conserva sus certificados: la app queda no disponible durante los pocos segundos que tarda en arrancar el nuevo contenedor. Vercel también deja de construir tus pushes en cuanto desconectas la integración con Git, así que hazlo cuando confíes en la nueva configuración.
El servidor en sí no guarda nada que no puedas reconstruir salvo .env, así que guarda una copia en un gestor de contraseñas. Tu base de datos se queda donde esté hoy y conserva sus propias copias de seguridad: puedes migrarla por separado más adelante.
El atajo: hazlo con Railyard
Los pasos 4 al 10 son lo que Railyard hace por ti. Conecta el mismo repositorio, y construye y despliega la app en un servidor de tu propia cuenta en la nube, con HTTPS y un redespliegue en cada push. No hay Dockerfile, archivo Compose ni Caddyfile que escribir.
- Agrega un servidor: en el panel de control de DigitalOcean, abre API, genera un token de acceso personal con acceso de lectura y escritura, y crea el droplet desde Servers → Add a server en Railyard. O conecta un servidor que ya tengas (Ubuntu 22.04 o 24.04,
sudosin contraseña, puertos 22, 80 y 443 abiertos). - Despliega el repositorio, o abre
https://app.railyard.run/deploy?repo=https://github.com/you/your-app.gitcon tu propia URL. - Pega las variables del Paso 2 en Variables → Bulk import.
Railyard elige el gestor de paquetes según tu lockfile, ejecuta tu script de build e inicia la app con npm start, escuchando en $PORT. Postgres o Redis agregados desde la página de la app tienen su cadena de conexión inyectada como variable. Recrea cada cron job de vercel.json como una entrada en la pestaña Cron jobs de la app, que ejecuta un comando en la imagen de la app en un horario (en UTC) y conserva un historial de cada ejecución. Para solicitar una ruta, usa el fetch integrado de Node con la variable RAILYARD_URL de la app:
node -e "fetch(process.env.RAILYARD_URL + '/api/cron', { headers: { authorization: 'Bearer ' + process.env.CRON_SECRET } }).then((r) => process.exit(r.ok ? 0 : 1))"
``` Las previews de pull request se pueden activar para la app una vez que GitHub esté conectado: consulta [Entornos y previews](/es/docs/environments-and-previews).
El plan Free cubre un servidor más un servidor de base de datos y tres apps, sin necesidad de tarjeta; Pro cuesta 19 USD al mes por equipo y Team cuesta 49 USD al mes por equipo. Empieza con [Despliega desde un repositorio](/es/docs/deploy-from-a-repository), o consulta [Despliega Next.js en DigitalOcean](/es/deploy/nextjs-to-digitalocean) para el lado del proveedor. ¿También estás moviendo apps desde Heroku, Render, Fly o Railway? [Importar desde otra plataforma](/es/docs/import-from-another-platform) las lee con un token de API.
## Solución de problemas
**El build falla por una variable faltante, o un valor `NEXT_PUBLIC_` está vacío en el navegador.** No estaba presente durante `next build`. Agrega un par `ARG` y `ENV` para ella en el Dockerfile, pásala bajo `build.args` en `compose.yaml`, y reconstruye. Reiniciar el contenedor no cambia los valores que se incrustaron en el momento del build.
**Los logins redirigen a una dirección `vercel.app`.** Tu URL de autenticación todavía guarda un valor de Vercel de la exportación. Configura `AUTH_URL` o `NEXTAUTH_URL` con tu dominio en `.env` y ejecuta `docker compose up -d app`.
**Caddy registra errores de certificado durante horas.** El DNS todavía no apunta al servidor, o un firewall bloquea los puertos 80 y 443. `dig +short app.example.com` debería devolver la IP de tu servidor. Caddy reintenta por su cuenta en cuanto eso ocurre.
**El código que lee geolocalización o encabezados de petición de Vercel no recibe nada.** Encabezados como `x-vercel-ip-country` los agrega la red de Vercel y no existen detrás de Caddy. Elimina esa ruta de código o usa un servicio de geolocalización propio.
**El build se mata en el servidor de 2 GB.** Se quedó sin memoria. Agrega un archivo swap de 2 GB (`fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile`), cambia al tamaño de 24 USD, o construye la imagen en otro lugar y descárgala desde un registro.
## Preguntas frecuentes
**¿El código de mi app necesita cambiar?** Normalmente solo donde lee variables `VERCEL_` o usa paquetes específicos de Vercel. Routing, Server Components, Server Actions, Image Optimization y Proxy funcionan todos en el servidor standalone.
**¿Y los preview deployments?** Un servidor sirve un solo entorno. Un segundo servidor, más pequeño, para staging, construido desde otra rama, cubre la mayoría de las necesidades. En Railyard, las previews de pull request son un ajuste de la app.
**¿ISR sigue funcionando?** Sí. Next.js guarda su caché en el disco local de cada instancia de servidor por defecto, lo cual funciona para una sola instancia. La caché empieza vacía después de cada despliegue, porque el contenedor se reemplaza.
**¿Puedo seguir usando servicios que compré a través de Vercel?** Los servicios que te dan una cadena de conexión o un token de API, como una base de datos de Marketplace, siguen funcionando desde cualquier servidor mientras copies las credenciales. Revisa la documentación propia de cada uno para conocer los límites fuera de Vercel.
La mudanza es sobre todo trabajo administrativo: variables, cron jobs y DNS. Una vez que esos tres están listos, una versión en tu propio servidor es un `git pull` y un comando. Por qué eligen Railyard para esto
- Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
- Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
- Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
- Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
- Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.