GraphQL permite que el cliente pida exactamente los campos que necesita en una sola petición, pero esa flexibilidad obliga a proteger el servidor frente a consultas abusivas. En este tutorial desplegarás en Ubuntu 24.04 un servidor GraphQL con Apollo Server 5 sobre Express, con consultas, mutaciones y suscripciones en tiempo real por WebSocket, un límite de profundidad de consultas y la introspección desactivada en producción. Lo ejecutarás como servicio systemd y lo publicarás detrás de Nginx con HTTPS y límite de peticiones.
Requisitos previos
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con al menos 1 GB de RAM.
- Un usuario no root con privilegios
sudo. - Node.js 24 LTS instalado desde el repositorio de NodeSource. Apollo Server 5 requiere Node.js 20 o superior, y el paquete
nodejsde Ubuntu 24.04 es la versión 18. - Un dominio con un registro A apuntando al servidor. En esta guía se usa
your_domain. - Los puertos 80 y 443 accesibles desde Internet.
Si todavía no tienes Node.js 24, instálalo así:
sudo apt update
sudo apt install ca-certificates curl gnupg
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt update
sudo apt install nodejs
node --version
Paso 1: Crear el usuario y el proyecto
El servicio se ejecutará con un usuario de sistema propio. Créalo con /srv/graphql como directorio personal y abre una shell con él:
sudo adduser --system --group --home /srv/graphql --shell /bin/bash graphql
sudo -iu graphql
Como graphql, crea el proyecto e instala las dependencias:
mkdir -p ~/app/src && cd ~/app
npm init -y
npm pkg set type=commonjs
npm install @apollo/server @as-integrations/express5 express graphql \
@graphql-tools/schema graphql-ws ws graphql-subscriptions graphql-depth-limit
Qué hace cada paquete:
| Paquete | Función |
|---|---|
@apollo/server | El servidor GraphQL |
@as-integrations/express5 | Integración con Express 5 (en Apollo Server 5 ya no viene incluida) |
@graphql-tools/schema | Construye un esquema ejecutable, compartido por HTTP y WebSocket |
graphql-ws y ws | Servidor WebSocket con el protocolo graphql-transport-ws para suscripciones |
graphql-subscriptions | PubSub en memoria para publicar eventos |
graphql-depth-limit | Regla de validación que rechaza consultas demasiado anidadas |
Paso 2: Definir el esquema y los resolvers
Crea el esquema con libros y autores. La relación es circular a propósito (un libro tiene autor y un autor tiene libros), porque es justo lo que permite construir consultas anidadas sin fin y lo que protegerás en el paso siguiente:
nano ~/app/src/schema.js
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
// Datos en memoria para el ejemplo; en tu aplicación vendrán de la base de datos
const authors = [
{ id: '1', name: 'Gabriel García Márquez' },
{ id: '2', name: 'Julio Cortázar' },
];
const books = [
{ id: '1', title: 'Cien años de soledad', authorId: '1' },
{ id: '2', title: 'Rayuela', authorId: '2' },
];
const typeDefs = `#graphql
type Author {
id: ID!
name: String!
books: [Book!]!
}
type Book {
id: ID!
title: String!
author: Author!
}
type Query {
books: [Book!]!
book(id: ID!): Book
}
type Mutation {
addBook(title: String!, authorId: ID!): Book!
}
type Subscription {
bookAdded: Book!
}
`;
const resolvers = {
Query: {
books: () => books,
book: (_, { id }) => books.find((b) => b.id === id) ?? null,
},
Mutation: {
addBook: (_, { title, authorId }) => {
const book = { id: String(books.length + 1), title, authorId };
books.push(book);
pubsub.publish('BOOK_ADDED', { bookAdded: book });
return book;
},
},
Subscription: {
bookAdded: {
subscribe: () => pubsub.asyncIterableIterator(['BOOK_ADDED']),
},
},
Book: {
author: (book) => authors.find((a) => a.id === book.authorId),
},
Author: {
books: (author) => books.filter((b) => b.authorId === author.id),
},
};
module.exports = { typeDefs, resolvers };
Nota
PubSubdegraphql-subscriptionsguarda los eventos en la memoria del proceso. Sirve con una sola instancia; si ejecutas varias, usa un bus compartido como Redis (paquetegraphql-redis-subscriptions) para que todas reciban los eventos.
Paso 3: Crear el servidor HTTP y WebSocket
El servidor comparte un único puerto para las peticiones HTTP (consultas y mutaciones) y las conexiones WebSocket (suscripciones), ambas en la ruta /graphql:
nano ~/app/src/server.js
const http = require('node:http');
const express = require('express');
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@as-integrations/express5');
const { ApolloServerPluginDrainHttpServer } = require('@apollo/server/plugin/drainHttpServer');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/use/ws');
const depthLimit = require('graphql-depth-limit');
const { typeDefs, resolvers } = require('./schema');
const PORT = Number(process.env.PORT) || 4000;
const HOST = process.env.HOST || '127.0.0.1';
async function main() {
const schema = makeExecutableSchema({ typeDefs, resolvers });
const app = express();
app.set('trust proxy', 'loopback');
const httpServer = http.createServer(app);
// Suscripciones: servidor WebSocket en la misma ruta /graphql
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
const wsCleanup = useServer({ schema }, wsServer);
const server = new ApolloServer({
schema,
validationRules: [depthLimit(5)],
plugins: [
ApolloServerPluginDrainHttpServer({ httpServer }),
{
async serverWillStart() {
return {
async drainServer() {
await wsCleanup.dispose();
},
};
},
},
],
});
await server.start();
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use(
'/graphql',
express.json({ limit: '100kb' }),
expressMiddleware(server, {
context: async ({ req }) => ({ ip: req.ip }),
}),
);
await new Promise((resolve) => httpServer.listen(PORT, HOST, resolve));
console.log(`GraphQL listo en http://${HOST}:${PORT}/graphql`);
const shutdown = async (signal) => {
console.log(`${signal} recibido, cerrando`);
await server.stop();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Las decisiones de producción de este archivo:
depthLimit(5)rechaza en la fase de validación, antes de ejecutar ningún resolver, cualquier consulta con más de 5 niveles de anidamiento. Ajusta el valor a la consulta legítima más profunda de tus clientes.express.json({ limit: '100kb' })limita el tamaño del cuerpo, lo que también acota el tamaño de las consultas.- Con
NODE_ENV=production, Apollo Server desactiva la introspección por defecto, así que nadie puede descargar tu esquema completo. Además, su protección CSRF, activa por defecto, rechaza peticiones sinContent-Type: application/jsonni cabecera de preflight. server.stop()ejecuta los plugins de drenaje: deja de aceptar conexiones, espera a las peticiones en curso y cierra las suscripciones WebSocket de forma limpia.
Prueba el servidor antes de convertirlo en servicio:
NODE_ENV=production node src/server.js
GraphQL listo en http://127.0.0.1:4000/graphql
Déjalo en marcha, abre otra sesión SSH y lanza una consulta:
curl -s http://127.0.0.1:4000/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ books { title author { name } } }"}'
{"data":{"books":[{"title":"Cien años de soledad","author":{"name":"Gabriel García Márquez"}},{"title":"Rayuela","author":{"name":"Julio Cortázar"}}]}}
Ahora envía una consulta que abuse de la relación circular. La regla de profundidad la rechaza:
curl -s http://127.0.0.1:4000/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ books { author { books { author { books { author { name } } } } } } }"}'
{"errors":[{"message":"'' exceeds maximum operation depth of 5","locations":[{"line":1,"column":54}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
Detén el servidor con Ctrl+C y sal de la shell de graphql con exit.
Paso 4: Ejecutar el servidor con systemd
Crea el archivo de entorno con la configuración y, más adelante, los secretos de tu base de datos:
sudo install -d -m 0750 -o root -g graphql /etc/graphql
sudo nano /etc/graphql/graphql.env
NODE_ENV=production
HOST=127.0.0.1
PORT=4000
sudo chmod 0640 /etc/graphql/graphql.env
sudo chown root:graphql /etc/graphql/graphql.env
Crea la unidad de systemd:
sudo nano /etc/systemd/system/graphql.service
[Unit]
Description=Servidor GraphQL (Apollo Server)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=graphql
Group=graphql
WorkingDirectory=/srv/graphql/app
EnvironmentFile=/etc/graphql/graphql.env
ExecStart=/usr/bin/node src/server.js
Restart=on-failure
RestartSec=2
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
Actívala y comprueba el estado:
sudo systemctl daemon-reload
sudo systemctl enable --now graphql
systemctl status graphql --no-pager
curl -s http://127.0.0.1:4000/health
● graphql.service - Servidor GraphQL (Apollo Server)
Loaded: loaded (/etc/systemd/system/graphql.service; enabled; preset: enabled)
Active: active (running) since ...
{"status":"ok"}
Los logs del servidor están en el journal:
journalctl -u graphql -f
Paso 5: Configurar Nginx con WebSocket y límite de peticiones
Nginx termina el TLS, limita la tasa de peticiones por IP y actualiza a WebSocket las conexiones de suscripción. Instálalo y abre el firewall:
sudo apt install nginx
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
Crea el sitio:
sudo nano /etc/nginx/sites-available/graphql
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
limit_req_zone $binary_remote_addr zone=graphql_limit:10m rate=10r/s;
upstream graphql_backend {
server 127.0.0.1:4000;
}
server {
listen 80;
listen [::]:80;
server_name your_domain;
location /graphql {
limit_req zone=graphql_limit burst=20 nodelay;
limit_req_status 429;
proxy_pass http://graphql_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
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;
# Las suscripciones mantienen la conexión abierta
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
location = /health {
proxy_pass http://graphql_backend;
access_log off;
}
}
El map envía Connection: upgrade solo cuando el cliente pide un WebSocket, así que la misma location sirve para peticiones normales y suscripciones. limit_req admite 10 peticiones por segundo por IP con ráfagas de hasta 20; el exceso recibe un 429. Como una sola consulta GraphQL puede ser muy costosa, este límite complementa, no sustituye, al límite de profundidad.
Activa el sitio y recarga Nginx:
sudo ln -s /etc/nginx/sites-available/graphql /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
Paso 6: Activar HTTPS
Obtén el certificado con Certbot, que añade el bloque de 443 y la redirección desde HTTP:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain --redirect
sudo certbot renew --dry-run
Comprueba una consulta desde tu equipo:
curl -s https://your_domain/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ book(id: \"1\") { title } }"}'
{"data":{"book":{"title":"Cien años de soledad"}}}
Paso 7: Probar las suscripciones
Para probar el WebSocket a mano usa wscat con el subprotocolo graphql-transport-ws desde tu equipo:
npx wscat -c wss://your_domain/graphql -s graphql-transport-ws
Cuando aparezca el indicador >, inicia la conexión y suscríbete, pegando cada mensaje por separado:
{"type":"connection_init"}
{"id":"1","type":"subscribe","payload":{"query":"subscription { bookAdded { id title author { name } } }"}}
El servidor responde a la primera línea con {"type":"connection_ack"}. En otra terminal, lanza una mutación:
curl -s https://your_domain/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"mutation { addBook(title: \"Pedro Páramo\", authorId: \"1\") { id } }"}'
En la sesión de wscat recibirás el evento:
< {"id":"1","type":"next","payload":{"data":{"bookAdded":{"id":"3","title":"Pedro Páramo","author":{"name":"Gabriel García Márquez"}}}}}
Paso 8: Actualizar el servidor
Para desplegar una versión nueva, actualiza el código como graphql, instala las dependencias exactas del package-lock.json y reinicia el servicio:
sudo -iu graphql bash -c 'cd ~/app && git pull && npm ci --omit=dev'
sudo systemctl restart graphql
journalctl -u graphql -n 5 --no-pager
... node[6210]: SIGTERM recibido, cerrando
... systemd[1]: Stopped graphql.service - Servidor GraphQL (Apollo Server).
... systemd[1]: Started graphql.service - Servidor GraphQL (Apollo Server).
... node[6288]: GraphQL listo en http://127.0.0.1:4000/graphql
Los clientes de suscripción recibirán el cierre del WebSocket; los clientes de graphql-ws se reconectan automáticamente con su opción retryAttempts.
Solución de problemas
Las suscripciones fallan con 400 o se cierran al instante. Nginx no está reenviando las cabeceras Upgrade y Connection, o la ruta del WebSocketServer no coincide con la location. Revisa el bloque del paso 5 y /var/log/nginx/error.log.
This operation has been blocked as a potential Cross-Site Request Forgery. La petición no lleva Content-Type: application/json. Es la protección CSRF de Apollo Server; los clientes GraphQL la envían siempre, así que suele pasar solo con curl o formularios.
GraphQL introspection is not allowed. Es lo esperado en producción. Si tu equipo necesita explorar el esquema, hazlo contra un entorno de desarrollo, o pasa introspection: true al constructor de ApolloServer solo si el esquema no es sensible.
Las suscripciones dejan de llegar con varias instancias. PubSub en memoria no se comparte entre procesos. Usa graphql-redis-subscriptions con un Redis común.
429 Too Many Requests para clientes legítimos. Sube burst o rate en limit_req_zone. Si hay otro proxy delante de Nginx, configura el módulo realip para que $binary_remote_addr sea la IP del cliente y no la del proxy.
Conclusión
Tienes un servidor Apollo Server 5 en producción con consultas, mutaciones y suscripciones por WebSocket, protegido con límite de profundidad, límite de peticiones e introspección desactivada, gestionado por systemd y publicado con HTTPS. Como siguientes pasos, conecta los resolvers a tu base de datos usando dataloader para agrupar consultas y evitar el problema N+1, añade autenticación en la función context y valida también las operaciones de suscripción con la opción onSubscribe de useServer, que no pasan por las validationRules de Apollo.
