· 5 min de lectura

Desplegar Next.js y Strapi en tu propio servidor con Docker y Nginx

Paso a paso para publicar un sitio Next.js con Strapi y PostgreSQL en un solo servidor: Docker Compose, Nginx, HTTPS, actualizaciones sin despliegue y copias de seguridad.

Este blog corre en un solo servidor: el sitio en Next.js, el panel editorial en Strapi y la base de datos en PostgreSQL, cada uno en su contenedor Docker y con Nginx al frente. Publicar un artículo no requiere desplegar nada, y todo el stack cuesta lo mismo que el servidor. Aquí explico cómo está armado, paso a paso, para que puedas replicarlo.

## La arquitectura

- **Next.js** genera las páginas y las guarda en caché. Cuando publico en el CMS, un webhook invalida solo las páginas afectadas.

- **Strapi** es el panel donde escribo: borradores, vista previa, publicación programada y biblioteca de medios.

- **PostgreSQL** guarda el contenido; las imágenes van a un volumen Docker.

- **Nginx** recibe el tráfico HTTPS y lo reparte: `www` hacia Next.js y `cms` hacia Strapi. Los contenedores solo escuchan en `127.0.0.1`.

## Requisitos

- Un servidor Linux con 2 GB de RAM como mínimo (Strapi recomienda 4 GB) y Docker con el plugin `docker compose`.

- Dos registros DNS tipo A apuntando a la IP del servidor: el dominio del sitio y el del CMS.

- Nginx y Certbot instalados (o Caddy, que gestiona los certificados solo).

## Paso 1: el docker-compose

Un solo archivo describe los tres servicios. Lo importante es que cada contenedor publique su puerto **solo en localhost**, para que el único punto de entrada sea Nginx:

```yaml

services:

postgres:

image: postgres:17-alpine

restart: unless-stopped

environment:

POSTGRES_DB: ${DATABASE_NAME}

POSTGRES_USER: ${DATABASE_USERNAME}

POSTGRES_PASSWORD: ${DATABASE_PASSWORD}

volumes:

- pgdata:/var/lib/postgresql/data

healthcheck:

test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USERNAME} -d ${DATABASE_NAME}"]

interval: 10s

cms:

build: ../cms

restart: unless-stopped

depends_on:

postgres:

condition: service_healthy

environment:

DATABASE_CLIENT: postgres

DATABASE_HOST: postgres

# ...secretos de Strapi desde .env

volumes:

- uploads:/opt/app/public/uploads

ports:

- "127.0.0.1:1337:1337"

web:

build: ../web

restart: unless-stopped

environment:

CMS_URL: http://cms:1337 # red interna de Docker

CMS_TOKEN: ${CMS_TOKEN} # token de solo lectura

ports:

- "127.0.0.1:3000:3000"

volumes:

pgdata:

uploads:

```

Los secretos viven en un archivo `.env` que **nunca** se sube a git. Cada uno se genera con:

```bash

openssl rand -base64 32

```

## Paso 2: imágenes pequeñas con salida standalone

Next.js puede generar una carpeta `standalone` con solo lo necesario para ejecutar el servidor. Con un Dockerfile de varias etapas, la imagen final no lleva el código fuente ni las dependencias de desarrollo:

```dockerfile

FROM node:22-alpine AS deps

WORKDIR /app

COPY package.json package-lock.json ./

RUN npm ci

FROM node:22-alpine AS build

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules

COPY . .

RUN npm run build

FROM node:22-alpine AS run

WORKDIR /app

ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0

COPY --from=build /app/.next/standalone ./

COPY --from=build /app/.next/static ./.next/static

COPY --from=build /app/public ./public

USER node

CMD ["node", "server.js"]

```

Para activarlo, en `next.config.ts`:

```ts

const nextConfig = {

output: 'standalone',

};

export default nextConfig;

```

## Paso 3: Nginx como puerta de entrada

Un archivo por dominio en `/etc/nginx/sites-available/`. El del CMS sube el límite de tamaño para poder cargar videos:

```nginx

server {

listen 80;

server_name cms.tudominio.com;

client_max_body_size 100m;

location / {

proxy_pass http://127.0.0.1:1337;

proxy_set_header Host $host;

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_set_header X-Forwarded-Proto $scheme;

}

}

```

El del sitio desactiva el buffering, porque Next.js puede enviar las respuestas por partes:

```nginx

server {

listen 80;

server_name www.tudominio.com;

location / {

proxy_pass http://127.0.0.1:3000;

proxy_set_header Host $host;

proxy_set_header X-Forwarded-Proto $scheme;

proxy_buffering off;

}

}

```

## Paso 4: HTTPS con Certbot

```bash

sudo ln -s /etc/nginx/sites-available/cms.tudominio.com /etc/nginx/sites-enabled/

sudo nginx -t && sudo systemctl reload nginx

sudo certbot --nginx -d www.tudominio.com -d cms.tudominio.com

```

Certbot edita los archivos de Nginx para redirigir a HTTPS y renueva los certificados automáticamente.

## Paso 5: publicar sin desplegar

La parte que más me gusta: el contenido se actualiza solo. En Strapi se configura un webhook que, al publicar, llama a una ruta de Next.js con un secreto compartido:

```ts

// app/api/revalidate/route.ts

import { revalidateTag } from 'next/cache';

export async function POST(request: Request) {

if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {

return Response.json({ message: 'No autorizado' }, { status: 401 });

}

const { model } = await request.json();

revalidateTag(model === 'post' ? 'posts' : 'cms', { expire: 0 });

return Response.json({ revalidated: true });

}

```

Cada consulta al CMS lleva sus etiquetas de caché (`next: { tags: ['posts'] }`), así que solo se regeneran las páginas que dependen de lo que cambió. Como el webhook viaja por la red interna de Docker (`http://web:3000/api/revalidate`), nunca sale a internet.

En producción conviene comparar el secreto en tiempo constante (`crypto.timingSafeEqual`) en lugar de usar `!==`.

## Paso 6: copias de seguridad que se pueden restaurar

Una copia que nunca probaste restaurar no es una copia. Un script diario guarda la base de datos y los archivos subidos:

```bash

STAMP=$(date +%Y%m%d-%H%M%S)

docker compose exec -T postgres pg_dump -U strapi -d strapi --format=custom > backups/db-$STAMP.dump

docker compose run --rm --no-deps -v "$PWD/backups:/backup" --entrypoint sh cms \

-c "tar czf /backup/uploads-$STAMP.tar.gz -C /opt/app/public uploads"

find backups -type f -mtime +14 -delete

```

Y se programa con cron:

```bash

0 3 * * * cd /var/www/tudominio && bash infra/scripts/backup.sh >> /var/log/backup.log 2>&1

```

Una vez al mes, restaura una copia en un ambiente de prueba. Es la única forma de saber que funciona.

## Lo que aprendí en el camino

- **Construye primero el CMS y después el sitio.** Si las páginas se pre-generan en el build, el CMS tiene que estar respondiendo; si no, el sitio sale con datos de respaldo.

- **La API del CMS no tiene por qué ser pública.** Next.js lee desde el servidor con un token de solo lectura, que nunca llega al navegador.

- **Mantén la misma versión de npm** en tu máquina y en Docker. Un `package-lock.json` generado con otra versión puede hacer fallar `npm ci`.

- **Prueba en un subdominio antes de mover el DNS principal.** Y bloquea la indexación ahí (`robots.txt` con `Disallow: /`) para que Google no indexe el sitio de pruebas.

## Resumen

Con un servidor, Docker Compose y Nginx tienes un blog con panel editorial propio, publicación sin despliegues, HTTPS y copias de seguridad, sin pagar servicios aparte. Si quieres el código completo de este sitio o tienes dudas con tu despliegue, escríbeme.

Stiven Laiton

Desarrollador de software. ¿Te sirvió o tienes un caso parecido? Escríbeme.

Sigue explorando

← Volver al blog