Dockerizing a NestJS + React + MySQL Stack for Development
A practical guide to setting up a Docker-based development environment for a NestJS backend, React frontend, and MySQL database with hot reloading and production parity.
We have shipped several full-stack projects using NestJS on the backend, React on the frontend, and MySQL as the database. The one thing that consistently saves time across all of them is a solid Docker-based development environment. New developers can run a single command and have the entire stack running in minutes, with the database pre-configured and seeded.
Here is the setup we have refined over multiple projects.
The Docker Compose File
The core of the setup is a docker-compose.yml that manages the database while letting you run the frontend and backend natively (with hot reloading). We found that containerizing MySQL while running Node processes locally gives the best developer experience — you get instant feedback on code changes without Docker volume mount performance issues on macOS or Windows.
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: project-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${MYSQL_DATABASE:-app_db}
MYSQL_USER: ${MYSQL_USER:-app_user}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-app_password}
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./docker/mysql/init:/docker-entrypoint-initdb.d
command: >
--default-authentication-plugin=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mysql_data:
A few things worth calling out:
Environment variable defaults. The ${MYSQL_USER:-app_user} syntax means developers can override credentials via a .env file, but sane defaults work out of the box. No one should need to configure anything just to run the dev environment.
UTF8MB4 character set. If you are building anything that might handle international text (and you should assume you will), set this from day one. Retrofitting a character set change on an existing database is painful. The utf8mb4 encoding supports the full Unicode range, including emoji and CJK characters.
Health checks. The healthcheck block lets other services use depends_on with a condition: service_healthy to wait for MySQL to actually be ready, not just for the container to start. MySQL can take 10-15 seconds to initialize on first run.
Init scripts. Any .sql file placed in ./docker/mysql/init/ runs automatically on first container creation. We use this for schema setup and seed data.
NestJS Backend Configuration
The NestJS backend connects to MySQL through TypeORM. The key is making the database configuration environment-aware:
// app.module.ts
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
username: process.env.DB_USER || 'app_user',
password: process.env.DB_PASSWORD || 'app_password',
database: process.env.DB_NAME || 'app_db',
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: process.env.NODE_ENV !== 'production',
})
In development, synchronize: true automatically applies entity changes to the database schema. In production, you should always use migrations. This is a common NestJS pattern, but it is worth emphasizing: never ship synchronize: true to production. It will drop columns and data without warning.
Start the backend with hot reloading:
cd backend && npm run start:dev
React Frontend with Vite
The frontend runs on Vite’s dev server with API proxying to avoid CORS issues during development:
// vite.config.ts
export default defineConfig({
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3000',
ws: true,
},
},
},
});
This proxy configuration means your React code can make requests to /api/users and Vite will forward them to the NestJS backend at localhost:3000. No CORS headers needed in development, and the same relative URLs work in production behind Nginx.
Nginx for Production Parity
In production, Nginx serves the built React files as static assets and proxies API requests to the NestJS backend:
server {
listen 443 ssl http2;
server_name app.example.com;
# SSL
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
# Frontend (static files)
root /var/www/app/frontend/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api {
proxy_pass http://127.0.0.1:3000;
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;
}
# WebSocket support
location /socket.io {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Cache static assets aggressively
location /assets {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
}
The try_files $uri $uri/ /index.html line is critical for React Router (or any SPA routing). Without it, refreshing the page on /dashboard/settings would return a 404 because there is no physical file at that path.
The Development Workflow
Here is the day-to-day workflow:
# Start the database (first time creates the volume and runs init scripts)
docker compose up -d
# Start the backend (hot reloads on file changes)
cd backend && npm run start:dev
# Start the frontend (hot reloads on file changes)
cd frontend && npm run dev
Three terminal tabs, three commands, full stack running with hot reloading. New developers clone the repo, install Node dependencies, and run these commands. No database installation, no MySQL configuration, no environment-specific setup guides.
Resetting the Database
When you need a clean slate:
docker compose down -v # -v removes the named volume
docker compose up -d # Recreates from scratch, runs init scripts again
The -v flag removes the mysql_data volume, which forces a fresh initialization. This is useful when testing migrations or when seed data gets into a bad state.
Tips From Production
Use named volumes, not bind mounts, for database data. Bind mounts can cause permission issues and are slower on macOS. Named volumes are managed by Docker and perform at native speed.
Pin your MySQL version. Using mysql:8.0 instead of mysql:latest prevents surprises when a new major version ships with breaking changes.
Add a Makefile or scripts. Wrapping common commands in a Makefile reduces the cognitive load for developers who are not Docker-fluent:
dev-db:
docker compose up -d
dev-backend:
cd backend && npm run start:dev
dev-frontend:
cd frontend && npm run dev
db-reset:
docker compose down -v && docker compose up -d
Keep your init scripts idempotent. Use CREATE TABLE IF NOT EXISTS and INSERT ... ON DUPLICATE KEY UPDATE so that init scripts can run multiple times without errors.
This setup has served us well across multiple projects. It is simple enough that junior developers are productive on day one, and robust enough that it mirrors the production architecture closely. The key insight is that you do not need to containerize everything — containerize the stateful parts (the database) and run the stateless parts (Node processes) natively for the best developer experience.
KeyQ builds full-stack applications and cloud infrastructure for businesses that need to ship fast. Let’s talk about your next project.