Hey DEV community! 👋
Following up on my previous guide on setting up a production-ready Node.js REST API link here, today we’re upgrading our skills with a modern, battle-tested stack for large applications: Node.js + GraphQL + PostgreSQL (managed via Flyway) + Docker + GitHub Actions.
If you’re tired of creating dozens of REST endpoints for a single complex UI screen, or struggling with over-fetching slowing down your mobile apps, GraphQL is your ultimate savior. This step-by-step guide is designed so even a Junior Developer can set everything up from scratch.
🎯 Source Code: I’ve prepared a highly standardized boilerplate repo so you can clone it and follow along with the code.
🔗 Reference Repo: nodejs-graphql-service (The complete backend code is generated using this tool).
1. RESTful API vs GraphQL: Understanding the Core
Before typing any code, let’s understand why we’re choosing GraphQL.
The RESTful API Perspective
-
Mechanism: Based on Resources. You access multiple URLs for different resources (
GET /users,GET /posts/1). -
Pros:
- Easy to understand and strictly patterned.
- Leverages HTTP-level caching (CDN, Browser cache, Varnish) perfectly.
- Straightforward file uploads via
multipart/form-data.
- Cons: The response data structure is inflexible. The backend returns a fixed set of fields, leading to Over-fetching (getting more data than you need) or Under-fetching (having to make additional API calls).
The GraphQL Perspective
-
Mechanism: Only ONE Endpoint (usually
POST /graphql). The frontend sends a specific query detailing exactly what fields it needs, and the backend returns precisely that shape. -
Pros:
- Smart Payload: Get exactly what you need—not a byte more.
- Combine multiple resource types (User, Post, Comment) in a single network request (highly optimized for mobile).
- Schema (Type Definitions) acts as living documentation. Front-end devs can code UI without waiting for the back-end implementation.
-
Cons:
- Harder to cache at the HTTP layer (since queries are
POSTrequests). - Prone to N+1 query problems on the backend if not carefully using DataLoader.
- File uploads are trickier out of the box compared to REST.
- Harder to cache at the HTTP layer (since queries are
2. Practical Application: When to use what?
There is no silver bullet in programming. Choose the right tool for the job:
-
Stick with RESTful when:
- Working on small projects with simple CRUD operations.
- Building Public APIs for 3rd parties (Webhooks, payment gateways).
- You need to heavily leverage HTTP caching mechanisms.
-
Switch to GraphQL when:
- The system has complex UIs or Dashboards pulling data from multiple tables/services.
- Developing Mobile Apps where bandwidth and payload optimization is crucial.
- Front-end and Back-end teams work independently (Front-end can request what they want via Schema).
3. Setup Source Code From Zero (Production Standard)
We will set up an apollo-server-express GraphQL server connecting to PostgreSQL via sequelize (Database config will be handled in the Docker section).
Step 3.1: Initialize & Install Dependencies
Create project folder:
mkdir nodejs-graphql-service
cd nodejs-graphql-service
npm init -y
Install Core libraries (Express, Apollo Server, Security, PostgreSQL):
npm install express @apollo/server graphql cors helmet hpp express-rate-limit dotenv morgan sequelize pg pg-hstore
Install Dev dependencies (TypeScript):
npm install -D typescript @types/node @types/express @types/cors @types/morgan ts-node tsconfig-paths
Open package.json and add these basic scripts for dev and CI/CD building:
"scripts": {
"dev": "ts-node -r tsconfig-paths/register src/index.ts",
"build": "tsc",
"test": "echo "Error: no test specified" && exit 0"
}
Step 3.2: PostgreSQL Connection & Model Setup (Sequelize)
Since real data lives in a database, let’s configure src/config/database.ts:
import { Sequelize } from 'sequelize';
import dotenv from 'dotenv';
dotenv.config();
const sequelize = new Sequelize(
process.env.DB_NAME || 'demo',
process.env.DB_USER || 'postgres',
process.env.DB_PASSWORD || 'root',
{
host: process.env.DB_HOST || '127.0.0.1',
dialect: 'postgres',
logging: false,
port: parseInt(process.env.DB_PORT || '5432')
}
);
export default sequelize;
Along with that, create a Model representing the Users table at src/models/User.ts:
import { DataTypes, Model, Optional } from 'sequelize';
import sequelize from '../config/database';
interface UserAttributes { id: number; name: string; email: string; }
interface UserCreationAttributes extends Optional<UserAttributes, 'id'> {}
class User extends Model<UserAttributes, UserCreationAttributes> implements UserAttributes {
public id!: number;
public name!: string;
public email!: string;
}
User.init({
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, allowNull: false, unique: true }
}, { sequelize, tableName: 'users', timestamps: false });
export default User;
Step 3.3: GraphQL TypeDefs & Resolvers
Create src/graphql/typeDefs.ts:
export const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
}
type Query {
getAllUsers: [User]
}
type Mutation {
createUser(name: String!, email: String!): User
}
`;
Create src/graphql/resolvers.ts. Notice that we query the Sequelize Model directly instead of using mock data:
import { GraphQLError } from 'graphql';
import User from '../models/User';
export const resolvers = {
Query: {
getAllUsers: async () => {
try {
return await User.findAll(); // Dive straight into PostgreSQL
} catch (error: any) {
throw new GraphQLError(error.message, { extensions: { code: 'INTERNAL_SERVER_ERROR' } });
}
}
},
Mutation: {
createUser: async (_: any, { name, email }: { name: string, email: string }) => {
try {
return await User.create({ name, email }); // INSERT new row
} catch (error: any) {
throw new GraphQLError(error.message, { extensions: { code: 'INTERNAL_SERVER_ERROR' } });
}
}
}
};
Step 3.4: Entry Point (src/index.ts)
A major challenge when working with Apollo Server is a blank screen accessing the Apollo Sandbox due to Content-Security-Policy (CSP) restrictions from helmet. Here is the complete fix:
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import dotenv from 'dotenv';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
import { typeDefs } from './graphql/typeDefs';
import { resolvers } from './graphql/resolvers';
import sequelize from './config/database';
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
// Fix blank screen for Apollo Sandbox with custom CSP
app.use(helmet({
crossOriginEmbedderPolicy: false,
contentSecurityPolicy: {
directives: {
imgSrc: [`'self'`, 'data:', 'apollo-server-landing-page.cdn.apollographql.com'],
scriptSrc: [`'self'`, `https: 'unsafe-inline'`],
manifestSrc: [`'self'`, 'apollo-server-landing-page.cdn.apollographql.com'],
frameSrc: [`'self'`, 'sandbox.embed.apollographql.com'],
},
},
}));
app.use(cors());
app.use(express.json());
const startServer = async () => {
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [ApolloServerPluginLandingPageLocalDefault({ embed: true })],
});
await server.start();
app.use('/graphql', expressMiddleware(server));
// Wait for DB connection before exposing port
await sequelize.sync();
app.listen(port, () => {
console.log(`🚀 GraphQL Server running at http://localhost:${port}/graphql`);
});
};
startServer();
4. Database Configuration (Docker, PostgreSQL & Flyway)
We shouldn’t run a barebones database locally. In production, your database schema evolves constantly. Thus, managing schema migrations using a tool like Flyway is mandatory. We will use docker-compose.yml to spin up our Node App, PostgreSQL, and Flyway containers seamlessly.
Create docker-compose.yml at the root folder:
services:
app:
build: .
ports:
- "${PORT:-3000}:3000"
depends_on:
- db
- flyway
environment:
- PORT=3000
- DB_HOST=db
- DB_USER=postgres
- DB_PASSWORD=root
- DB_NAME=demo
db:
image: postgres:15
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: root
POSTGRES_DB: demo
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- ./flyway/sql:/docker-entrypoint-initdb.d
flyway:
image: flyway/flyway
command: -connectRetries=60 migrate
volumes:
- ./flyway/sql:/flyway/sql
environment:
FLYWAY_URL: jdbc:postgresql://db:5432/demo
FLYWAY_USER: postgres
FLYWAY_PASSWORD: root
depends_on:
- db
Why is this powerful? When you run docker-compose up -d, the flyway container waits for db (Postgres) to be ready. It then automatically reads .sql files in ./flyway/sql and applies them (Migrations) before anything else.
To give Flyway a migration to run, create flyway/sql/V1__Create_users_table.sql:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
);
Only after all dependencies are ready will the Node.js app connect. This is exceptionally safe for production.
5. CI/CD Automation with GitHub Actions
Code working locally is no guarantee it won’t crash on the server due to dependency flaws. To automate code quality checks and tests on every git push, let’s inject a CI/CD workflow.
Create the .github/workflows/ folder and drop this ci.yml file into it:
name: Node.js CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x] # Run tests on multiple Node versions
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install Dependencies
run: npm ci # Use npm ci for reliable builds
- name: Lint Code
run: npm run lint
- name: Run Tests
run: npm test # Keep those tests green!
- name: Build Source
run: npm run build --if-present
6. Apollo Sandbox Live Demo
Everything is perfectly set up. Run docker-compose up -d and npm run dev. Open your browser to http://localhost:3000/graphql. You’ll seamlessly land on the Apollo Sandbox interface thanks to our prior Helmet CSP configuration.
Try running these snippets in the Query window:
Add a new User to PostgreSQL:
mutation CreateUser {
createUser(name: "DEV Expert", email: "author@dev.to") {
id
name
email
}
}
Fetch 100% real user data:
query GetAllUsers {
getAllUsers {
id
name
email
}
}

The data is fetched directly from Postgres and returned cleanly. If the frontend requests 3 fields (id, name, email), the backend gives exactly 3 fields. No bloated timestamps or unused columns!
7. The Ultimate Shortcut… 🤫
Reading through this detailed guide, you might feel a bit overwhelmed. Setting up package.json, configuring Apollo, fixing security policies, tweaking Docker-compose with Flyway, and writing GitHub Actions manually can drain an entire weekend.
How to never do this manual setup again:
From our painful experiences bootstrapping projects, my team developed a CLI Engine that generates an entire GraphQL Source Code (Clean Architecture/MVC) AUTOMATICALLY IN EXACTLY 10 SECONDS.
The tool is called: nodejs-quickstart-structure
In your terminal, simply run:
nodejs-quickstart init
- Select
TypeScript - Select
Clean Architecture - Select
PostgreSQLDatabase - Select
GraphQLCommunication - Select
GitHub ActionsCI/CD
BOOM! 💥 You instantly get a production-ready boilerplate identical to what we’ve just built together, fully layered with Controllers, Repositories, and Entities. Just configure the port and start coding your business logic without typing a single boilerplate character.
🔗 Check out the CLI tool here: github.com/paudang/nodejs-quickstart-structure
Don’t forget to drop a Star (⭐) on the repo if this tool saves your deadlines!

