Every GraphQL API needs a runtime to execute queries. Apollo Server is the most popular open-source choice in the Node.js ecosystem and it handles everything from schema validation to resolver execution right out of the box. Self-hosting it on a VPS gives you full control over your API infrastructure without the ongoing expense of managed cloud services. This guide walks you through a complete, production-ready deployment using Docker, Nginx and Let’s Encrypt SSL on Ubuntu.
What Is Apollo Server?
Apollo Server is an open-source, spec-compliant GraphQL server built and maintained by Apollo GraphQL. The current release, Apollo Server 4, runs as a standalone Node.js process or sits alongside popular HTTP frameworks like Express and Fastify. Point a browser at your running instance in non-production mode and a built-in GraphQL Sandbox loads automatically, giving you a browser-based IDE to write and test queries directly against your live schema.
Under the hood, Apollo Server manages the full GraphQL request lifecycle. It parses incoming queries, validates them against your type definitions, executes the matching resolvers and returns a neatly formatted JSON response. It also supports GraphQL subscriptions over WebSocket for real-time data delivery, which opens the door to live notifications, dashboards and collaborative features. Whether you’re building a mobile app backend, an internal microservice or a public-facing API, Apollo Server gives you a stable and extensible foundation to build on.
Prerequisites
Before you start, make sure your server meets these specifications and you have a domain name with its DNS A record already pointing at your server’s IP address:
| Component | Minimum | Recommended |
|---|---|---|
| CPU | 1 vCPU | 2 vCPUs |
| RAM | 1 GB | 2 GB |
| Disk | 20 GB SSD | 40 GB SSD |
| OS | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS |
| Domain | DNS A record pointing to server IP |
You’ll also need a non-root sudo user and basic comfort navigating the Linux terminal. Throughout this guide, replace your-domain.com with your actual domain name wherever it appears.
How to Install Apollo Server on a VPS
Step 1: Update and Prepare Your Server
Start every fresh server the same way: patch it before you touch anything else. Run the following to update your package index and upgrade installed packages in one shot:
sudo apt update && sudo apt upgrade -y
Next, install a handful of utilities that later steps depend on:
sudo apt install -y curl gnupg ca-certificates lsb-release
Step 2: Install Docker Engine
Resist the temptation to run sudo apt install docker.io. Ubuntu’s default repositories carry an older Docker version that regularly causes compatibility headaches. Always pull Docker Engine from the official Docker repository instead.
Add Docker’s official GPG signing key:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
Register the Docker repository with your system:
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
Install Docker Engine and the Compose plugin:
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Add your current user to the docker group so you don’t need sudo for every Docker command:
sudo usermod -aG docker $USER
newgrp docker
Confirm both tools installed correctly:
docker --version
docker compose version
Step 3: Create the Project Directory
Keeping application files under /opt is standard practice on production servers. Create the working directory and take ownership of it:
sudo mkdir -p /opt/apollo-server
sudo chown $USER:$USER /opt/apollo-server
cd /opt/apollo-server
Step 4: Write the Apollo Server Application
Create a package.json file to declare your project’s metadata and dependencies. This tells Node.js what to install when the Docker image builds:
nano package.json
Paste in the following:
{
"name": "apollo-server-app",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node index.mjs"
},
"dependencies": {
"@apollo/server": "^4.0.0",
"graphql": "^16.0.0"
}
}
Before you publish this guide, verify the latest @apollo/server release on npmjs.com and update the version string to match. Save and close the file, then create the main application entry point:
nano index.mjs
Paste in this starter schema and server definition:
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const typeDefs = `#graphql
type Query {
hello: String
status: String
}
`;
const resolvers = {
Query: {
hello: () => 'Hello from Apollo Server!',
status: () => 'operational',
},
};
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true,
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000, host: '0.0.0.0' },
});
console.log(`Apollo Server ready at ${url}`);
This is a minimal but fully functional GraphQL server. The introspection: true flag keeps schema exploration available regardless of the NODE_ENV value — handy since you’ll be running with NODE_ENV=production inside Docker. You’ll swap out the example schema for real types and resolvers as your project matures.
Step 5: Create the Dockerfile
The Dockerfile tells Docker exactly how to build your application image. Create it in the project root:
nano Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY index.mjs .
EXPOSE 4000
CMD ["node", "index.mjs"]
A few choices here are worth understanding. The Alpine base image weighs in at around 50 MB versus over 1 GB for the full Debian-based Node.js image, so startup is faster and the attack surface is smaller. The npm ci --omit=dev command installs only production dependencies from a clean slate, keeping the image lean and the build reproducible. Verify the node:20-alpine tag is still the active LTS release on Docker Hub before publishing.
Step 6: Configure Docker Compose
Docker Compose manages the full service lifecycle: building the image, starting the container, handling restarts and monitoring health. Create the Compose file:
nano docker-compose.yml
services:
apollo:
build: .
container_name: apollo-server
restart: unless-stopped
ports:
- "127.0.0.1:4000:4000"
environment:
- NODE_ENV=production
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/.well-known/apollo/server-health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
Pay attention to the port binding format: 127.0.0.1:4000:4000. Binding to the loopback address means Apollo Server is only reachable from within the server itself. Nginx becomes the sole gatekeeper for all public traffic. If you drop the 127.0.0.1 prefix and bind to 0.0.0.0, the container port is exposed directly to the internet, bypassing both your reverse proxy and your firewall. Don’t do that.
Step 7: Install and Configure Nginx
Install Nginx from Ubuntu’s package repositories:
sudo apt install -y nginx
Create a dedicated server block configuration for Apollo Server:
sudo nano /etc/nginx/sites-available/apollo-server
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header 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;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 3600s;
proxy_buffering off;
}
}
The Upgrade and Connection headers are non-negotiable for WebSocket support. Apollo Server’s subscription system relies on a persistent WebSocket connection and Nginx will silently drop all subscription traffic without those two lines. The extended proxy_read_timeout prevents idle subscriptions from timing out and proxy_buffering off ensures real-time data flows to clients without delay.
Enable the site, confirm the configuration syntax is clean and reload Nginx:
sudo ln -s /etc/nginx/sites-available/apollo-server /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Step 8: Secure with SSL via Certbot
No production API should run over plain HTTP. Install Certbot and its Nginx plugin to provision a free Let’s Encrypt certificate:
sudo apt install -y certbot python3-certbot-nginx
Request a certificate for your domain. Confirm your DNS A record is already resolving to your server before running this command:
sudo certbot --nginx -d your-domain.com
Certbot automatically edits your Nginx configuration to handle SSL termination and sets up an HTTP-to-HTTPS redirect. Verify that automatic renewal works correctly:
sudo certbot renew --dry-run
A clean dry run means your certificate will renew automatically every 90 days with zero manual intervention required.
Step 9: Configure the UFW Firewall
UFW makes firewall management straightforward. You only need three rules: SSH so you don’t lock yourself out, and both Nginx ports for web traffic:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status
The Nginx Full profile opens ports 80 and 443. Everything else stays closed, including port 4000. UFW acts as a second line of defense alongside the Docker port binding you configured in Step 6.
Step 10: Start Apollo Server
Everything is wired up. Build the Docker image and bring the service online:
cd /opt/apollo-server
docker compose up -d --build
Check the container status and stream the logs to confirm a clean startup:
docker compose ps
docker compose logs -f
When the logs show Apollo Server ready at http://0.0.0.0:4000/, the server is live. Open https://your-domain.com in a browser and Apollo’s built-in GraphQL Sandbox loads, ready for you to start writing and executing queries against your schema.
How to Update Apollo Server
Keeping Apollo Server current gets you the latest security patches and feature improvements. The upgrade process takes under two minutes.
Check the current @apollo/server release on npmjs.com and update the version string in package.json to match. Then bring the service down, rebuild and restart:
cd /opt/apollo-server
docker compose down
docker compose up -d --build
Confirm the updated container is healthy before moving on:
docker compose ps
If something goes wrong, roll back by restoring the previous version string in package.json and running the same build commands. Docker caches image layers locally so a rollback rebuild is fast.
Troubleshooting Apollo Server
Apollo Server Container Exits Immediately
A container that starts and stops within seconds almost always has a syntax error in index.mjs or a missing dependency in package.json. Pull up the container logs first:
docker compose logs apollo
Node.js stack traces are specific enough to point you straight to the offending line. Fix the error and rebuild:
docker compose up -d --build
Nginx Returns a 502 Bad Gateway Error
A 502 means Nginx is running but can’t reach Apollo Server on port 4000. Start by confirming the container is actually up and healthy:
docker compose ps
If the status shows “exited” or “unhealthy”, restart the service:
docker compose restart apollo
Also verify that the proxy_pass address in /etc/nginx/sites-available/apollo-server matches the port binding in docker-compose.yml. A single-digit typo in either file produces exactly this error.
SSL Certificate Fails to Issue
Certbot fails when it can’t verify domain ownership via HTTP. The most common cause is DNS propagation lag: your A record exists but hasn’t spread across resolvers yet. Use a tool like dnschecker.org to confirm your domain resolves to the correct IP. Once propagation is complete, re-run the Certbot command:
sudo certbot --nginx -d your-domain.com
Also confirm UFW has port 80 open. Certbot’s HTTP-01 challenge requires an accessible port 80 to complete successfully.
GraphQL Subscriptions Fail to Connect
Broken WebSocket connections during subscription setup almost always trace back to missing headers in your Nginx configuration. Open the site config and confirm these two lines are present inside the location / block:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
After editing the file, test the syntax and reload Nginx:
sudo nginx -t && sudo systemctl reload nginx
Schema Introspection Returns Empty Results
Apollo Server 4 disables introspection by default when NODE_ENV=production. If the GraphQL Sandbox shows an empty schema or throws introspection errors, confirm that introspection: true appears in your ApolloServer constructor inside index.mjs:
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true,
});
Save the file and rebuild the container to apply the change:
docker compose up -d --build
What to Build Next
A working Apollo Server is a starting line, not a finish line. Here are a few natural extensions to explore next:
- Connect a database: Wire up PostgreSQL or MongoDB using Prisma or Mongoose to add a persistent data layer behind your resolvers, turning your schema into a real data API.
- Add authentication: Implement JWT-based auth inside Apollo Server’s context function to protect individual resolvers behind authorization checks, keeping sensitive data accessible only to verified callers.
- Enable persisted queries: Cache approved query hashes on the server to reduce network payloads, improve response times for repeat operations and block arbitrary query injection.
- Set up Apollo Federation: Compose multiple subgraph services into a unified supergraph using Apollo Router, which scales far better than a monolithic schema as your team and data model grow.
- Add rate limiting: Protect your GraphQL endpoint from abusive clients by configuring an Nginx
limit_req_zonedirective to cap inbound requests per IP address.
Your Apollo Server instance is production-ready and built to scale. Start with a lean schema, iterate based on what your clients actually request and let the graph grow organically from there. That incremental flexibility is one of GraphQL’s real advantages over fixed REST endpoints and it’s entirely yours to put to work.


