GraphQL Server Deployment on Linux

GraphQL provides a flexible query language for APIs, and deploying a GraphQL server in production on Linux requires attention to schema design, resolver optimization, subscription handling, caching, and security. This guide covers deploying Apollo Server, configuring Nginx for GraphQL, handling subscriptions over WebSockets, implementing caching strategies, and securing GraphQL endpoints.

Prerequisites

  • Ubuntu 20.04+ or CentOS/Rocky 8+ with root access
  • Node.js 18+ (or Python with Strawberry/Ariadne for Python GraphQL)
  • PM2 installed globally: sudo npm install -g pm2
  • Redis (for subscriptions and caching)

Setting Up Apollo Server

# Create application directory
sudo useradd -r -m -d /opt/graphql -s /bin/bash gqlapp
sudo mkdir -p /opt/graphql/app
sudo chown -R gqlapp:gqlapp /opt/graphql

# Install Node.js dependencies
sudo -u gqlapp bash -c "
  cd /opt/graphql/app
  npm init -y
  npm install @apollo/server graphql graphql-ws ws express cors
  npm install --save-dev nodemon
"

Create the server entry point:

sudo tee /opt/graphql/app/src/index.js << 'EOF'
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const express = require('express');
const cors = require('cors');
const { typeDefs } = require('./schema');
const { resolvers } = require('./resolvers');
const { createContext } = require('./context');

const app = express();
const PORT = process.env.PORT || 4000;

async function startServer() {
    const server = new ApolloServer({
        typeDefs,
        resolvers,
        // Disable introspection in production
        introspection: process.env.NODE_ENV !== 'production',
        formatError: (formattedError, error) => {
            // Don't expose internal errors to clients
            if (process.env.NODE_ENV === 'production') {
                return { message: formattedError.message };
            }
            return formattedError;
        },
    });

    await server.start();

    app.use(
        '/graphql',
        cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*' }),
        express.json({ limit: '10mb' }),
        expressMiddleware(server, {
            context: createContext,
        })
    );

    app.get('/health', (req, res) => {
        res.json({ status: 'healthy', timestamp: new Date().toISOString() });
    });

    app.listen(PORT, '127.0.0.1', () => {
        console.log(`GraphQL server ready at http://127.0.0.1:${PORT}/graphql`);
        if (process.send) process.send('ready');
    });
}

startServer().catch(console.error);
EOF

Schema Design and Resolvers

sudo tee /opt/graphql/app/src/schema.js << 'EOF'
const { gql } = require('graphql-tag');

const typeDefs = gql`
    type Query {
        users(limit: Int, offset: Int): [User!]!
        user(id: ID!): User
        posts(userId: ID): [Post!]!
    }

    type Mutation {
        createUser(input: CreateUserInput!): User!
        updateUser(id: ID!, input: UpdateUserInput!): User!
        deleteUser(id: ID!): Boolean!
    }

    type Subscription {
        userCreated: User!
        postPublished(userId: ID): Post!
    }

    type User {
        id: ID!
        name: String!
        email: String!
        posts: [Post!]!
        createdAt: String!
    }

    type Post {
        id: ID!
        title: String!
        content: String!
        author: User!
        publishedAt: String
    }

    input CreateUserInput {
        name: String!
        email: String!
        password: String!
    }

    input UpdateUserInput {
        name: String
        email: String
    }
`;

module.exports = { typeDefs };
EOF
sudo tee /opt/graphql/app/src/resolvers.js << 'EOF'
const { PubSub } = require('graphql-subscriptions');

const pubsub = new PubSub();

const resolvers = {
    Query: {
        users: async (_, { limit = 20, offset = 0 }, { db }) => {
            return db.users.findAll({ limit, offset });
        },
        user: async (_, { id }, { db }) => {
            return db.users.findById(id);
        },
    },

    Mutation: {
        createUser: async (_, { input }, { db }) => {
            const user = await db.users.create(input);
            // Publish subscription event
            pubsub.publish('USER_CREATED', { userCreated: user });
            return user;
        },
    },

    Subscription: {
        userCreated: {
            subscribe: () => pubsub.asyncIterator(['USER_CREATED']),
        },
    },

    // Field resolvers for nested data (N+1 query prevention via DataLoader)
    User: {
        posts: async (user, _, { loaders }) => {
            return loaders.postsByUser.load(user.id);
        },
    },

    Post: {
        author: async (post, _, { loaders }) => {
            return loaders.usersById.load(post.authorId);
        },
    },
};

module.exports = { resolvers, pubsub };
EOF

Subscriptions with WebSockets

sudo tee /opt/graphql/app/src/subscription-server.js << 'EOF'
const { createServer } = require('http');
const { WebSocketServer } = require('ws');
const { useServer } = require('graphql-ws/lib/use/ws');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const { typeDefs } = require('./schema');
const { resolvers } = require('./resolvers');

const schema = makeExecutableSchema({ typeDefs, resolvers });

function setupSubscriptions(httpServer) {
    const wsServer = new WebSocketServer({
        server: httpServer,
        path: '/graphql/subscriptions',
    });

    return useServer(
        {
            schema,
            context: async (ctx) => {
                // Authenticate WebSocket connections
                const token = ctx.connectionParams?.authorization;
                if (token) {
                    // Verify token and return context
                    return { user: await verifyToken(token) };
                }
                return {};
            },
            onConnect: (ctx) => {
                console.log(`WebSocket connected: ${ctx.extra.request.socket.remoteAddress}`);
            },
            onDisconnect: () => {
                console.log('WebSocket disconnected');
            },
        },
        wsServer
    );
}

module.exports = { setupSubscriptions };
EOF

Caching Strategies

Install Redis for caching:

sudo apt install redis-server   # Ubuntu
sudo dnf install redis          # CentOS/Rocky
sudo systemctl enable --now redis
sudo tee /opt/graphql/app/src/cache.js << 'EOF'
const { createClient } = require('redis');

const redis = createClient({
    url: process.env.REDIS_URL || 'redis://localhost:6379',
});

redis.connect().catch(console.error);

// Cache resolver results
async function cachedResolver(cacheKey, ttlSeconds, resolver) {
    const cached = await redis.get(cacheKey);
    if (cached) {
        return JSON.parse(cached);
    }

    const result = await resolver();
    await redis.setEx(cacheKey, ttlSeconds, JSON.stringify(result));
    return result;
}

// Cache invalidation
async function invalidateCache(pattern) {
    const keys = await redis.keys(pattern);
    if (keys.length > 0) {
        await redis.del(keys);
    }
}

module.exports = { redis, cachedResolver, invalidateCache };
EOF

Add cache directives to your schema:

# Cache user data for 5 minutes
type Query {
    user(id: ID!): User @cacheControl(maxAge: 300)
    users: [User!]! @cacheControl(maxAge: 60)
}

Nginx Configuration for GraphQL

sudo tee /etc/nginx/sites-available/graphql << 'EOF'
map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
}

upstream graphql_backend {
    server 127.0.0.1:4000;
    keepalive 32;
}

server {
    listen 80;
    server_name api.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    # Rate limiting for GraphQL endpoint
    limit_req_zone $binary_remote_addr zone=graphql:10m rate=100r/m;
    limit_req_zone $binary_remote_addr zone=graphql_subscriptions:10m rate=5r/m;

    # Main GraphQL endpoint
    location /graphql {
        limit_req zone=graphql burst=30 nodelay;
        
        proxy_pass http://graphql_backend;
        proxy_http_version 1.1;
        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 60s;
    }

    # WebSocket subscriptions endpoint
    location /graphql/subscriptions {
        limit_req zone=graphql_subscriptions burst=5 nodelay;

        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_read_timeout 3600s;    # Long timeout for WebSockets
        proxy_send_timeout 3600s;
    }

    location /health {
        proxy_pass http://graphql_backend;
        access_log off;
    }
}
EOF

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

Security Best Practices

Limit query complexity to prevent abuse:

npm install graphql-query-complexity
const { createComplexityLimitRule } = require('graphql-validation-complexity');

const server = new ApolloServer({
    typeDefs,
    resolvers,
    validationRules: [
        // Reject queries with complexity > 1000
        createComplexityLimitRule(1000, {
            onCost: (cost) => console.log('Query complexity:', cost),
        }),
    ],
});

Disable introspection in production:

const server = new ApolloServer({
    typeDefs,
    resolvers,
    introspection: process.env.NODE_ENV !== 'production',
    // Also block schema introspection query
    plugins: [
        {
            requestDidStart: async () => ({
                didResolveOperation: async ({ request }) => {
                    if (process.env.NODE_ENV === 'production' &&
                        request.query.includes('__schema')) {
                        throw new Error('Introspection is disabled');
                    }
                },
            }),
        },
    ],
});

Monitoring and Performance

Start with PM2:

sudo tee /opt/graphql/ecosystem.config.js << 'EOF'
module.exports = {
  apps: [{
    name: 'graphql-api',
    script: './src/index.js',
    instances: 'max',
    exec_mode: 'cluster',
    env_production: {
      NODE_ENV: 'production',
      PORT: 4000,
    },
    env_file: '/etc/graphql/.env',
    log_file: '/var/log/graphql/combined.log',
    error_file: '/var/log/graphql/error.log',
    max_memory_restart: '512M',
    wait_ready: true,
  }]
};
EOF

sudo -u gqlapp bash -c "cd /opt/graphql && pm2 start ecosystem.config.js --env production"
sudo -u gqlapp pm2 save

Add Prometheus metrics:

npm install @promster/express prom-client
const { createMiddleware } = require('@promster/express');

app.use(createMiddleware({ app }));

app.get('/metrics', (req, res) => {
    res.set('Content-Type', 'text/plain');
    res.send(require('prom-client').register.metrics());
});

Troubleshooting

WebSocket connections dropping:

# Check Nginx timeout settings
grep timeout /etc/nginx/sites-available/graphql
# Increase proxy_read_timeout for subscriptions

# Check if Redis pub/sub is working
redis-cli PING
redis-cli SUBSCRIBE test &
redis-cli PUBLISH test "hello"

N+1 query problem causing slow responses:

# Install DataLoader
npm install dataloader
# Implement batch loading for related resources
# Each loader.load() call is batched into a single DB query

GraphQL queries too slow:

# Add query timing logging
# Check resolver execution with Apollo Studio or:
curl -X POST http://localhost:4000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ users { id name } }"}' \
  -w "\nTime: %{time_total}s\n"

"Introspection is disabled" error during development:

# Check NODE_ENV setting
echo $NODE_ENV
# In development, set: NODE_ENV=development
# Never set introspection based on env in the ecosystem config

Conclusion

Deploying a GraphQL server in production requires balancing flexibility with security—disable introspection in production, implement query complexity limits to prevent abuse, and use DataLoader to solve N+1 query problems. Run multiple instances with PM2 cluster mode for horizontal scaling, use Redis for subscriptions pub/sub and response caching, and configure Nginx to handle both HTTP GraphQL queries and WebSocket subscription connections on the same domain.