DEV Community

Pau Dang
Pau Dang

Posted on

15-Minute Node.js Setup: From Zero to Production (GraphQL, PostgreSQL, Docker & CI/CD)

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 POST requests).
    • 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.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install Dev dependencies (TypeScript):

npm install -D typescript @types/node @types/express @types/cors @types/morgan ts-node tsconfig-paths
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
  }
`;
Enter fullscreen mode Exit fullscreen mode

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' } });
      }
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Fetch 100% real user data:

query GetAllUsers {
  getAllUsers {
    id
    name
    email
  }
}
Enter fullscreen mode Exit fullscreen mode


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:

npx nodejs-quickstart-structure init
Enter fullscreen mode Exit fullscreen mode
  • Select TypeScript
  • Select Clean Architecture
  • Select PostgreSQL Database
  • Select GraphQL Communication
  • Select GitHub Actions CI/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!

Top comments (0)