GraphQL lets clients ask for exactly the fields they need from a single endpoint, and Apollo Server is the most widely used GraphQL server for Node.js. Because clients can compose arbitrary queries, a production deployment needs a few protections that a REST API does not: limits on query depth, no schema introspection for anonymous users and rate limiting. In this tutorial you will build a small Apollo Server application on Ubuntu 24.04, run it as a hardened systemd service and publish it over HTTPS through Nginx.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM.
  • A non-root user with sudo privileges.
  • A domain name with a DNS A record pointing to your server's public IP. This guide uses api.your_domain as the placeholder; replace it with your own hostname.
  • Ports 80 and 443 reachable from the Internet.

Step 1 - Installing Node.js LTS

Apollo Server requires a supported Node.js release, and the nodejs package in the Ubuntu 24.04 archive (Node.js 18) is already end of life. Install Node.js 24 LTS from the NodeSource repository. Start with the tools needed to add the repository key:

sudo apt update
sudo apt install -y ca-certificates curl gnupg

Add the NodeSource signing key and repository:

sudo install -m 0755 -d /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

Install Node.js and check the version:

sudo apt update
sudo apt install -y nodejs
node --version
v24.9.0

Step 2 - Creating the project and installing Apollo Server

Create a system user called graphql to run the server, and an application directory owned by it:

sudo useradd --system --home-dir /opt/graphql --shell /usr/sbin/nologin graphql
sudo mkdir -p /opt/graphql
sudo chown graphql:graphql /opt/graphql

Initialize a Node.js project as that user and switch it to ES modules, which the Apollo Server examples use:

cd /opt/graphql
sudo -u graphql npm init -y
sudo -u graphql npm pkg set type=module

Install Apollo Server, the graphql reference implementation it depends on, and graphql-depth-limit, a validation rule that rejects overly nested queries:

sudo -u graphql npm install @apollo/server graphql graphql-depth-limit

Confirm the packages are installed:

sudo -u graphql npm ls --depth=0

Your versions may be newer.

Step 3 - Writing the schema and resolvers

The example API manages books and authors in memory. Book and Author reference each other, which is typical of real schemas and is exactly what makes deeply nested, expensive queries possible. Create the entry point:

sudo -u graphql nano /opt/graphql/index.js
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import depthLimit from 'graphql-depth-limit';

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
    authors: [Author!]!
  }

  type Mutation {
    addBook(title: String!, authorId: ID!): Book!
  }
`;

// In-memory data. Replace with calls to your database.
const authors = [
  { id: '1', name: 'Ursula K. Le Guin' },
  { id: '2', name: 'Stanislaw Lem' },
];
const books = [
  { id: '1', title: 'The Left Hand of Darkness', authorId: '1' },
  { id: '2', title: 'Solaris', authorId: '2' },
];

const resolvers = {
  Query: {
    books: () => books,
    book: (_, { id }) => books.find((b) => b.id === id),
    authors: () => authors,
  },
  Mutation: {
    addBook: (_, { title, authorId }) => {
      const book = { id: String(books.length + 1), title, authorId };
      books.push(book);
      return book;
    },
  },
  Book: {
    author: (book) => authors.find((a) => a.id === book.authorId),
  },
  Author: {
    books: (author) => books.filter((b) => b.authorId === author.id),
  },
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
  // Reject queries nested more than 7 levels deep
  validationRules: [depthLimit(7)],
});

const port = Number(process.env.PORT) || 4000;

const { url } = await startStandaloneServer(server, {
  listen: { host: '127.0.0.1', port },
});

console.log(`GraphQL server ready at ${url}`);

startStandaloneServer runs Apollo Server on Node's built-in HTTP server and serves GraphQL at /. Binding to 127.0.0.1 keeps it reachable only through Nginx. Apollo Server also handles SIGTERM and SIGINT: it stops accepting new requests and finishes the ones in progress before exiting.

When NODE_ENV is production, Apollo Server changes two defaults that matter for security: introspection is disabled, so anonymous clients cannot download your full schema, and error responses no longer include stack traces.

Start the server in development mode to test it:

sudo -u graphql node /opt/graphql/index.js
GraphQL server ready at http://127.0.0.1:4000/

In a second SSH session, run a query:

curl -s http://127.0.0.1:4000/ \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ books { title author { name } } }"}'
{"data":{"books":[{"title":"The Left Hand of Darkness","author":{"name":"Ursula K. Le Guin"}},{"title":"Solaris","author":{"name":"Stanislaw Lem"}}]}}

Now send a query that nests author and books repeatedly. On a real database, each level multiplies the number of lookups:

curl -s http://127.0.0.1:4000/ \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ books { author { books { author { books { author { books { author { name } } } } } } } } }"}'

The depth limit rejects it with HTTP status 400 before any resolver runs. The response looks like this (abbreviated; in development mode it also includes a stack trace):

{"errors":[{"message":"'' exceeds maximum operation depth of 7","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}

Stop the server with CTRL+C in the first session.

Step 4 - Running Apollo Server as a systemd service

Create a unit file that runs the server in production mode, restarts it on failure and sandboxes it:

sudo nano /etc/systemd/system/graphql.service
[Unit]
Description=Apollo GraphQL server
After=network.target

[Service]
Type=exec
User=graphql
Group=graphql
WorkingDirectory=/opt/graphql
Environment=NODE_ENV=production
Environment=PORT=4000
ExecStart=/usr/bin/node /opt/graphql/index.js
Restart=on-failure
RestartSec=5

# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
PrivateDevices=true

[Install]
WantedBy=multi-user.target

If your resolvers need secrets such as a database URL, put them in a file readable only by root (for example /etc/graphql/graphql.env with mode 600) and add EnvironmentFile=/etc/graphql/graphql.env to the [Service] section.

Load and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now graphql.service
sudo systemctl status graphql.service
● graphql.service - Apollo GraphQL server
     Loaded: loaded (/etc/systemd/system/graphql.service; enabled; preset: enabled)
     Active: active (running) since Thu 2026-09-25 11:20:14 UTC; 4s ago
   Main PID: 6120 (node)

Check that the server responds. A GET request with the { __typename } query is a cheap health check; the apollo-require-preflight header satisfies Apollo Server's built-in CSRF protection, which blocks simple GET requests without it:

curl -s 'http://127.0.0.1:4000/?query=%7B__typename%7D' -H 'apollo-require-preflight: true'
{"data":{"__typename":"Query"}}

Confirm that introspection is now disabled:

curl -s http://127.0.0.1:4000/ \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ __schema { types { name } } }"}'

The response is an error with the code GRAPHQL_VALIDATION_FAILED explaining that introspection is not allowed. If you need the schema for client code generation, export it from your development environment instead of enabling introspection in production.

Step 5 - Configuring Nginx with rate limiting

Nginx terminates TLS and limits how many requests each client IP can send, which protects the server from floods of expensive queries. Install it:

sudo apt install -y nginx

Create a server block:

sudo nano /etc/nginx/sites-available/graphql
# 10 requests per second per client IP, tracked in a 10 MB shared zone
limit_req_zone $binary_remote_addr zone=graphql:10m rate=10r/s;

server {
    listen 80;
    listen [::]:80;
    server_name api.your_domain;

    # GraphQL requests are small; reject oversized bodies early
    client_max_body_size 1m;

    location / {
        limit_req zone=graphql burst=20 nodelay;
        limit_req_status 429;

        proxy_pass http://127.0.0.1:4000;
        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;
        proxy_read_timeout 30s;
    }
}

limit_req_zone must be defined at the http level. Files in sites-enabled are included inside the http block of /etc/nginx/nginx.conf, so placing it at the top of this file is valid. burst=20 nodelay allows short spikes of up to 20 extra requests, and clients that exceed the limit receive 429 Too Many Requests.

Enable the site and reload Nginx:

sudo ln -s /etc/nginx/sites-available/graphql /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Allow SSH and web traffic through UFW:

sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

From your own computer, run a query through Nginx:

curl -s http://api.your_domain/ \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ authors { name } }"}'
{"data":{"authors":[{"name":"Ursula K. Le Guin"},{"name":"Stanislaw Lem"}]}}

Step 6 - Enabling HTTPS with Let's Encrypt

Install Certbot with the Nginx plugin and request a certificate:

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d api.your_domain

Certbot adds the certificate to the server block and redirects HTTP to HTTPS. Verify it with the health check query:

curl -s 'https://api.your_domain/?query=%7B__typename%7D' -H 'apollo-require-preflight: true'
{"data":{"__typename":"Query"}}

The certbot.timer unit renews the certificate automatically; test renewal with sudo certbot renew --dry-run.

Step 7 - Deploying updates

To release a new version, update /opt/graphql (for example with git pull), install dependencies from the lockfile and restart the service. Apollo Server finishes requests in progress before the old process exits:

cd /opt/graphql
sudo -u graphql npm ci --omit=dev
sudo systemctl restart graphql.service

Follow the log to confirm the new version started:

sudo journalctl -u graphql.service -n 20 --no-pager

Troubleshooting

SyntaxError: Cannot use import statement outside a module. The project is not configured for ES modules. Run sudo -u graphql npm pkg set type=module in /opt/graphql and restart the service.

GET requests fail with a CSRF error. Apollo Server blocks GET requests that a browser could send cross-site without a preflight. Send Content-Type: application/json on POST requests, or add the apollo-require-preflight: true header (or x-apollo-operation-name) to GET requests.

Legitimate queries fail with exceeds maximum operation depth. Your clients need deeper queries than the limit allows. Raise the value passed to depthLimit() just enough to cover them, and restart the service.

Clients receive 429 Too Many Requests. The Nginx rate limit is too strict for your traffic pattern. Increase rate or burst in limit_req_zone and limit_req, then run sudo nginx -t && sudo systemctl reload nginx.

Nginx returns 502 Bad Gateway. The Node.js process is not running or listens on another port. Check sudo systemctl status graphql.service, sudo journalctl -u graphql.service -n 50 and /var/log/nginx/error.log.

Conclusion

Your Apollo GraphQL server now runs as a sandboxed systemd service in production mode, rejects overly nested queries, hides its schema from introspection and sits behind Nginx with HTTPS and per-client rate limiting.

As next steps, you can connect the resolvers to a real database and batch related lookups with the dataloader package to avoid N+1 queries, add authentication by reading a token in Apollo Server's context function, and monitor the health check query from an external uptime service.