Ever wondered how to streamline your full-stack development process? This guide walks you through setting up a full-stack monorepo with the latest tools and technologies. Follow these step-by-step instructions to create a modern, robust, and scalable application using:
> TypeScript
> Vite
> pnpm
> Docker
> And more!
Full-Stack Monorepo Setup Documentation
This documentation covers the setup and usage of a full-stack monorepo project with the following technologies and tools:
Here are the tools grouped into relevant categories:
Programming Language and Frameworks
- TypeScript
- Express
- React
Build and Development Tools
- Vite
- pnpm
- Docker
- ESLint
- Prettier
- Husky
Database and ORM
- Sequelize
- PostgreSQL
Authentication and Security
- jsonwebtoken
- bcryptjs
- cookie-parser
- AWS WAF
AWS and Cloud Services
- AWS SDK v3
- Terraform
Logging and Validation
- Winston
- Zod
Networking and API
- Axios
UI Frameworks and Libraries
- Bootstrap
CI/CD and DevOps
- GitHub Actions
- SonarQube
- Trivy
These groupings can help organize your tools by their purpose, making it easier for your readers to understand the different aspects of your full-stack monorepo setup.
Project Structure
my-monorepo/
│
├── packages/
│ ├── server/
│ │ ├── Dockerfile
│ │ ├── src/
│ │ │ ├── config/
│ │ │ │ └── sequelize.ts
│ │ │ ├── controllers/
│ │ │ │ └── userController.ts
│ │ │ ├── middleware/
│ │ │ │ └── auth.ts
│ │ │ ├── models/
│ │ │ │ └── user.ts
│ │ │ ├── repositories/
│ │ │ │ └── userRepository.ts
│ │ │ ├── routes/
│ │ │ │ ├── auth.ts
│ │ │ │ └── user.ts
│ │ │ ├── services/
│ │ │ │ └── userService.ts
│ │ │ ├── utils/
│ │ │ │ ├── logger.ts
│ │ │ │ └── syncEnv.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── .env.development
│ │ ├── .env.beta
│ │ ├── .env.test
│ │ ├── .env.production
│ │ ├── sequelize-cli/
│ │ │ ├── config/
│ │ │ │ └── config.js
│ │ │ ├── models/
│ │ │ ├── migrations/
│ │ │ └── seeders/
│ ├── client/
│ │ ├── src/
│ │ │ ├── axios.ts
│ │ │ ├── context/
│ │ │ │ └── AuthContext.tsx
│ │ │ ├── components/
│ │ │ │ └── AuthExample.tsx
│ │ │ ├── App.tsx
│ │ │ ├── main.tsx
│ │ │ └── index.html
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── shared/
│ │ ├── src/
│ │ │ └── utils.ts
│ │ ├── package.json
│ │ └── tsconfig.json
├── terraform/
│ ├── development/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── production/
│ │ └── main.tf
├── .github/
│ └── workflows/
│ └── deploy.yml
├── .eslintrc.json
├── .prettierrc
├── docker-compose.yml
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.json
Step-by-Step Setup
1. Initialize the Monorepo
- Create Project Structure:
mkdir my-monorepo
cd my-monorepo
mkdir packages
mkdir packages/server packages/client packages/shared
touch pnpm-workspace.yaml
touch .env.development .env.beta .env.test .env.production
touch docker-compose.yml
touch .eslintrc.json .prettierrc
-
Initialize the Monorepo with
pnpm
:
pnpm init -y
-
Set Up Workspaces in
pnpm-workspace.yaml
:
packages:
- 'packages/*'
2. Environment Variables
- Create Environment Files:
.env.development:
VITE_NODE_ENV=development
VITE_DATABASE_URL=postgres://user:password@db:5432/mydb_dev
VITE_PORT=4000
VITE_JWT_SECRET=your_jwt_secret
.env.beta, .env.test, .env.production: similarly create these files with appropriate values.
3. Docker Configuration
-
Create
docker-compose.yml
:
version: '3.8'
services:
server:
build: ./packages/server
ports:
- "${VITE_PORT}:${VITE_PORT}"
env_file:
- ./.env.development
depends_on:
- db
db:
image: postgres:latest
ports:
- "5432:5432"
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb_development
-
Create
Dockerfile
for Server inpackages/server/Dockerfile
:
FROM node:22
WORKDIR /app
COPY package*.json ./
COPY pnpm-lock.yaml ./
RUN npm install -g pnpm
RUN pnpm install
COPY . .
RUN pnpm -r build
CMD ["pnpm", "dev"]
4. TypeScript Configuration
-
Create the Root
tsconfig.json
:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@shared/*": ["packages/shared/src/*"],
"@client/*": ["packages/client/src/*"],
"@server/*": ["packages/server/src/*"]
}
},
"include": ["packages/*/src"]
}
- Install TypeScript:
pnpm add -D typescript
5. Linting and Formatting
-
Create
.eslintrc.json
:
{
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"env": {
"browser": true,
"es2021": true,
"node": true
},
"rules": {}
}
-
Create
.prettierrc
:
{
"semi": true,
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"trailingComma": "all"
}
- Install ESLint and Prettier:
pnpm add -D eslint prettier eslint-plugin-prettier eslint-config-prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser
6. Husky
- Install Husky:
pnpm add -D husky
pnpm dlx husky-init && pnpm install
- Add a Pre-commit Hook:
npx husky add .husky/pre-commit "pnpm lint"
7. Configure Server with Controller-Service-Repository Pattern, Logging, and Authentication
- Install Dependencies:
pnpm add express sequelize pg pg-hstore jsonwebtoken bcryptjs cookie-parser zod winston @aws-sdk/client-secrets-manager
pnpm add -D @types/express @types/node @types/jsonwebtoken @types/bcryptjs @types/cookie-parser @types/sequelize
-
Configure
vite.config.ts
:
packages/server/vite.config.ts
:
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: Number(import.meta.env.VITE_PORT) || 4000,
}
});
- Create Sequelize Configuration:
packages/server/src/config/sequelize.ts
:
import { Sequelize } from 'sequelize';
const sequelize = new Sequelize(import.meta.env.VITE_DATABASE_URL as string, {
dialect: 'postgres',
});
export default sequelize;
- Create User Model:
packages/server/src/models/user.ts
:
import { DataTypes, Model } from 'sequelize';
import sequelize from '../config/sequelize';
class User extends Model {
public id!: number;
public email!: string;
public password!: string;
}
User.init(
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
},
{
sequelize,
tableName: 'users',
}
);
export default User;
- Create Repository:
packages/server/src/repositories/userRepository.ts
:
import User from '../models/user';
class UserRepository {
async create(email: string, password: string) {
return User.create({ email, password });
}
async findByEmail(email: string) {
return User.findOne({ where: { email } });
}
async findById(id: number) {
return User.findByPk(id);
}
}
export default new UserRepository();
- Create Service:
packages/server/src/services/userService.ts
:
import bcrypt from 'bcryptjs';
import userRepository from '../repositories/userRepository';
import { generateToken } from '../utils/auth';
import User from '../models/user';
class UserService {
async register(email: string, password: string) {
const hashedPassword = await bcrypt.hash(password, 10);
const user = await userRepository.create(email, hashedPassword);
const token = generateToken(user);
return { user, token };
}
async login(email: string, password: string) {
const user = await userRepository.findByEmail(email);
if (!user) throw new Error('Invalid email or password');
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) throw new Error('Invalid email or password');
const token = generateToken(user);
return { user, token };
}
async getUserById(id: number) {
return userRepository.findById(id);
}
}
export default new UserService();
- Create Controller:
packages/server/src/controllers/userController.ts
:
import { Request, Response } from 'express';
import userService from '../services/userService';
import logger from '../utils/logger';
class UserController {
async register(req: Request, res: Response) {
try {
const { email, password } = req.body;
const { user, token } = await userService.register(email, password);
res.cookie('token', token, { httpOnly: true });
res.status(201).json({ message: 'User registered', user });
} catch (error) {
logger.error('Error in register: %o', error);
res.status(400).json({ message: error.message });
}
}
async login(req: Request, res: Response) {
try {
const { email, password } = req.body;
const { user, token } = await userService.login(email, password);
res.cookie('token', token, { httpOnly: true });
res.status(200).json({ message: 'User logged in', user });
} catch (error) {
logger.error('Error in login: %o', error);
res.status(400).json({ message: error.message });
}
}
async logout(req: Request, res: Response) {
res.clearCookie('token');
res.status(200).json({ message: 'User logged out' });
}
async profile(req: Request, res: Response) {
try {
const user = await userService.getUserById(req.user.id);
res.json({ user });
} catch (error) {
logger.error('Error in profile: %o', error);
res.status(400).json({ message: error.message });
}
}
}
export default new UserController();
- Authentication Middleware:
packages/server/src/middleware/auth.ts
:
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import User from '../models/user';
export const authenticateJWT = (req: Request, res: Response, next: NextFunction) => {
const token = req.cookies.token;
if (token) {
jwt.verify(token, import.meta.env.VITE_JWT_SECRET as string, (err, user) => {
if (err) {
return res.sendStatus(403);
}
req.user = user;
next();
});
} else {
res.sendStatus(401);
}
};
export const generateToken = (user: User) => {
return jwt.sign({ id: user.id, email: user.email }, import.meta.env.VITE_JWT_SECRET as string, { expiresIn: '1h' });
};
- Logging with Winston:
packages/server/src/utils/logger.ts
:
import { createLogger, format, transports } from 'winston';
const logger = createLogger({
level: 'info',
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.errors({ stack: true }),
format.splat(),
format.json()
),
defaultMeta: { service: 'user-service' },
transports: [
new transports.File({ filename: 'error.log', level: 'error' }),
new transports.File({ filename: 'combined.log' }),
],
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new transports.Console({
format: format.combine(
format.colorize(),
format.simple()
)
}));
}
export default logger;
- Create Routes:
packages/server/src/routes/auth.ts
:
import express from 'express';
import userController from '../controllers/userController';
const router = express.Router();
router.post('/register', userController.register);
router.post('/login', userController.login);
router.post('/logout', userController.logout);
export default router;
packages/server/src/routes/user.ts
:
import express from 'express';
import { authenticateJWT } from '../middleware/auth';
import userController from '../controllers/userController';
const router = express.Router();
router.get('/profile', authenticateJWT, userController.profile);
export default router;
- Sync Environment Variables from AWS Secrets Manager:
packages/server/src/utils/syncEnv.ts
:
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import dotenv from "dotenv";
dotenv.config();
const client = new SecretsManagerClient({ region: "your-region" });
async function syncEnv() {
const secretName = "your-secret-name";
const command = new GetSecretValueCommand({ SecretId: secretName });
try {
const data = await client.send(command);
if (data.SecretString) {
const secrets = JSON.parse(data.SecretString);
for (const key in secrets) {
import.meta.env[key] = secrets[key];
}
}
} catch (err) {
console.error(err);
}
}
syncEnv();
- Main Server File:
packages/server/src/index.ts
:
import express from 'express';
import cookieParser from 'cookie-parser';
import dotenv from 'dotenv';
import authRoutes from './routes/auth';
import userRoutes from './routes/user';
import sequelize from './config/sequelize';
import './utils/syncEnv';
import logger from './utils/logger';
dotenv.config();
const app = express();
const port = import.meta.env.VITE_PORT || 4000;
app.use(express.json());
app.use(cookieParser());
app.use('/auth', authRoutes);
app.use('/user', userRoutes);
app.get('/', (req, res) => {
res.send('Hello from Express and TypeScript!');
});
sequelize.sync().then(() => {
app.listen(port, () => {
logger.info(`Server is running at http://localhost:${port}`);
});
});
8. Frontend Configuration
-
Create
package.json
for Client:
{
"name": "client",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest",
"e2e": "playwright test"
},
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2",
"bootstrap": "^5.0.2",
"axios": "^0.21.1"
},
"devDependencies": {
"vite": "^2.3.8",
"vitest": "^0.0.134",
"typescript": "^4.2.4",
"@vitejs/plugin-react": "^1.1.0",
"@playwright/test": "^1.12.3"
}
}
-
Create
tsconfig.json
in Client:
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "esnext",
"target": "es6",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}
-
Create
vite.config.ts
for Client:
packages/client/vite.config.ts
:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000
}
});
- Set Up Axios:
packages/client/src/axios.ts
:
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:4000',
withCredentials: true,
});
export default api;
- Auth Context:
packages/client/src/context/AuthContext.tsx
:
import React, { createContext, useState, useEffect, ReactNode } from 'react';
import api from '../axios';
interface AuthContextProps {
user: any;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextProps | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<any>(null);
useEffect(() => {
// Check if the user is authenticated on mount
const fetchUser = async () => {
try {
const response = await api.get('/user/profile');
setUser(response.data.user);
} catch (err) {
setUser(null);
}
};
fetchUser();
}, []);
const login = async (email: string, password: string) => {
const response = await api.post('/auth/login', { email, password });
setUser(response.data.user);
};
const logout = async () => {
await api.post('/auth/logout');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
- Auth Component Example:
packages/client/src/components/AuthExample.tsx
:
import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext';
const AuthExample: React.FC = () => {
const { user, login, logout } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleLogin = async () => {
await login(email, password);
};
return (
<div>
{user ? (
<div>
<h1>Welcome, {user.email}</h1>
<button onClick={logout}>Logout</button>
</div>
) : (
<div>
<h1>Login</h1>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button onClick={handleLogin}>Login</button>
</div>
)}
</div>
);
};
export default AuthExample;
- Main Component:
packages/client/src/App.tsx
:
import React from 'react';
import { AuthProvider } from './context/AuthContext';
import AuthExample from './components/AuthExample';
const App: React.FC = () => {
return (
<AuthProvider>
<AuthExample />
</AuthProvider>
);
};
export default App;
- Index File:
packages/client/src/main.tsx
:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import 'bootstrap/dist/css/bootstrap.min.css';
ReactDOM.render(<App />, document.getElementById('root'));
- HTML File:
packages/client/src/index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Step 9: CI/CD Configuration with GitHub Actions
-
Create
.github/workflows/deploy.yml
:
name: CI/CD Pipeline
on:
push:
branches:
- main
- development
- beta
- test
- production
pull_request:
branches:
- main
jobs:
build:
name: Build and Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: '22'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run tests
run: pnpm test
- name: Run linting
run: pnpm lint
- name: Run formatting
run: pnpm format
sonar:
name: SonarQube Scan
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: '22'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run SonarQube scan
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
pnpm install -g sonar-scanner
sonar-scanner \
-Dsonar.projectKey=my-project \
-Dsonar.sources=. \
-Dsonar.host.url=${{ secrets.SONAR_HOST_URL }} \
-Dsonar.login=${{ secrets.SONAR_TOKEN }}
trivy:
name: Trivy Scan
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build Docker image
run: docker build -t my-app .
- name: Run Trivy scan
uses: aquasecurity/trivy-action@master
with:
image-ref: my-app
deploy:
name: Deploy
needs: [build, sonar, trivy]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development' || github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/test' || github.ref == 'refs/heads/production'
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: '22'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Build project
run: pnpm run build
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Deploy frontend to S3 and CloudFront
run: |
aws s3 sync packages/client/dist s3://${{ secrets.S3_BUCKET_NAME }}
aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"
- name: Deploy backend with Terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
cd terraform/${{ github.ref_name }}
terraform init
terraform apply -auto-approve
release:
name: Create Release
runs-on: ubuntu-latest
needs: deploy
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Create release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v1.0.${{ github.run_number }}
release_name: Release v1.0.${{ github.run_number }}
draft: false
prerelease: false
Step 10: Configure Secrets in GitHub
In your GitHub repository, navigate to Settings
-> Secrets
and add the following secrets:
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
S3_BUCKET_NAME
CLOUDFRONT_DISTRIBUTION_ID
SONAR_HOST_URL
SONAR_TOKEN
-
GITHUB_TOKEN
(usually auto-generated by GitHub Actions)
Step 11: Install Dependencies and Run
- Install all dependencies:
pnpm install
- Run local development:
pnpm run dev
- Push to GitHub:
git add .
git commit -m "Initial setup with CI/CD, Docker, and Terraform"
git push origin main
Step 12: Sequelize Commands
Generate a new migration:
npx sequelize-cli migration:generate --name migration_name
Run migrations:
npx sequelize-cli db:migrate
Create a new model:
npx sequelize-cli model:generate --name ModelName --attributes name:string,age:integer
Generate seed data:
npx sequelize-cli seed:generate --name seed_name
Run seeders:
npx sequelize-cli db:seed:all
Top comments (0)