# How to Deploy a GraphQL API with Apollo Server
Apollo Server is the most popular way to build a GraphQL API in Node.js. Getting it running locally is a few lines; getting it production-ready means hardening the schema against abuse, disabling introspection where appropriate, and wiring a real database. This tutorial covers a safe deployment.
A minimal production-shaped server
Apollo Server 4 favors the standalone server for simple cases or integration with a framework like Express for more control. Here is a clean standalone setup that reads its port from the environment:
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers.js';
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
});
const { url } = await startStandaloneServer(server, {
listen: { port: Number(process.env.PORT) || 8080 },
context: async ({ req }) => ({ user: await authenticate(req) }),
});
console.log(`Ready at ${url}`);Step 1: secure the schema surface
GraphQL's flexibility is also its risk: a single endpoint that can be probed and abused.
- Introspection. Apollo enables introspection in development; consider disabling it in production (as above) so attackers cannot easily map your entire schema. This is a judgment call — public APIs may want it on.
- Disable the landing page playground in production, or protect it.
- Authentication in context. Resolve the user once in
contextand enforce authorization in resolvers.
Step 2: defend against expensive queries
The classic GraphQL attack is a deeply nested or hugely expanded query that melts your database. Two defenses:
Depth limiting
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)],
});Complexity / cost analysis
Assign a cost to fields and reject queries over a budget (libraries like graphql-cost-analysis or Apollo's operation limits). This stops a query that requests users { posts { comments { author { posts ... } } } } from fanning out into millions of resolver calls.
Step 3: solve N+1 with DataLoader
Naive resolvers issue one DB query per item — the infamous N+1 problem. Batch them with DataLoader:
import DataLoader from 'dataloader';
const userLoader = new DataLoader(async (ids) => {
const rows = await db.query('SELECT * FROM users WHERE id = ANY($1)', [ids]);
const byId = new Map(rows.map(r => [r.id, r]));
return ids.map(id => byId.get(id));
});
// in resolver: return userLoader.load(post.authorId);This collapses N queries into one per request tick — often the single biggest GraphQL performance win.
Step 4: wire the database
Read the connection string from the environment and create the pool once at startup:
import pg from 'pg';
export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });Create DataLoaders per-request (inside context) so caching does not leak across users.
Step 5: containerize
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV PORT=8080
EXPOSE 8080
CMD ["node", "index.js"]Step 6: health checks and observability
GraphQL servers respond to POST, so add a lightweight GET health route for the platform's probes (use the Express integration if you need custom routes), and enable Apollo's usage reporting or your own tracing. Log slow operations so you can spot abusive or inefficient queries.
Production checklist
[ ] Introspection/playground decision made for prod
[ ] Depth limit + complexity limit enabled
[ ] DataLoader batching for all list relations
[ ] Auth resolved in context, enforced in resolvers
[ ] DATABASE_URL from environment
[ ] Health check endpoint for the platform
[ ] Slow-query logging / tracing onCommon pitfalls
- Leaving introspection wide open on a private API. Hands attackers your schema map.
- No query limits. One malicious nested query can take down the DB.
- Per-request DataLoaders shared globally. Cache bleeds between users — a security bug.
- Returning raw DB errors to clients. Mask internal errors in production.
Deploying on PandaStack
PandaStack auto-detects Node apps or builds from your Dockerfile, so the Apollo container above deploys straight from a git push. Provision a managed PostgreSQL and DATABASE_URL is injected automatically — your pg.Pool picks it up with no extra config. Live app logs surface slow or abusive queries, custom domains get automatic SSL for your /graphql endpoint, and rollbacks let you revert a bad schema change instantly. The free tier (5 web services + 1 database) is plenty for a GraphQL API plus its data store.
References
- [Apollo Server documentation](https://www.apollographql.com/docs/apollo-server/)
- [Apollo Server security best practices](https://www.apollographql.com/docs/apollo-server/security/authentication/)
- [graphql-depth-limit](https://github.com/stems/graphql-depth-limit)
- [DataLoader](https://github.com/graphql/dataloader)
- [GraphQL specification](https://spec.graphql.org/)
---
A production GraphQL API is about discipline: limit queries, batch resolvers, and lock down the schema. PandaStack handles the build, managed Postgres, and SSL so you can focus on the schema — deploy free at [dashboard.pandastack.io](https://dashboard.pandastack.io).