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 nodejs de 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:

PaqueteFunción
@apollo/serverEl servidor GraphQL
@as-integrations/express5Integración con Express 5 (en Apollo Server 5 ya no viene incluida)
@graphql-tools/schemaConstruye un esquema ejecutable, compartido por HTTP y WebSocket
graphql-ws y wsServidor WebSocket con el protocolo graphql-transport-ws para suscripciones
graphql-subscriptionsPubSub en memoria para publicar eventos
graphql-depth-limitRegla 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 };

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 sin Content-Type: application/json ni 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.