In this step, we’ll prepare our MERN backend application for production deployment. We’ll create separate configuration files for production, update our application code to handle production settings, and set up Docker and Docker Compose files specifically for a production environment.
First, let’s create a .env.prod
file in the root of our project. This file will contain environment variables specific to our production environment.
NODE_ENV=production
PORT=3001
# MongoDB
MONGODB_HOST=localhost
MONGODB_PORT=27019
MONGODB_DATABASE=mern-db
MONGODB_USER=admin
MONGODB_PASSWORD=admin
# Redis
REDIS_HOST=localhost
REDIS_PORT=6381
REDIS_PASSWORD=redispassword
Note: In a real production environment, you should use strong, unique passwords and consider using a secure method to manage these secrets, such as environment variables on your deployment platform or a secrets management service.
As an added precaution, I modified the port numbers to ensure that there are no conflicts between the production and development Docker containers if you choose to run both simultaneously.
We need to update the code to use the variables defined in the .env.prod
file.
env.ts
Update the env.ts
file to include the new environment variables:
import dotenv from "dotenv";
// Load environment variables from .env file
dotenv.config();
// Export environment variables
export const {
NODE_ENV,
PORT,
MONGODB_URI,
MONGODB_HOST,
MONGODB_PORT,
MONGODB_USER,
MONGODB_PASSWORD,
MONGODB_DATABASE,
REDIS_URL,
REDIS_HOST,
REDIS_PORT,
REDIS_PASSWORD,
} = process.env;
In this update version of env.ts
, we are adding MONGODB_USER
, MONGODB_PASSWORD
and REDIS_PASSWORD
to the list of exported variables.
database.ts
Modify database.ts
to handle authentication for MongoDB:
import {
MONGODB_HOST,
MONGODB_PORT,
MONGODB_DATABASE,
MONGODB_USER,
MONGODB_PASSWORD,
} from "./env";
import mongoose from "mongoose";
// Construct the connection URI with optional authentication
let CONNECTION_URI = `mongodb://${MONGODB_HOST}:${MONGODB_PORT}/${MONGODB_DATABASE}`;
// Add credentials and authSource only if username and password are provided
if (MONGODB_USER && MONGODB_PASSWORD) {
CONNECTION_URI =
`mongodb://` +
`${MONGODB_USER}:${MONGODB_PASSWORD}` +
`@` +
`${MONGODB_HOST}:${MONGODB_PORT}` +
`/` +
`${MONGODB_DATABASE}` +
`?` +
`authSource=admin`;
}
const connectDB = async () => {
try {
await mongoose.connect(CONNECTION_URI);
console.log("Connected to MongoDB");
} catch (error) {
console.error("Error connecting to MongoDB:", error);
throw error; // Rethrow to handle it in the caller
}
};
export default connectDB;
export { mongoose }; // Export the mongoose instance for use in other files
The change in the database.ts
file is to handle the authentication for MongoDB. In this code, we checking if the MONGODB_USER
and MONGODB_PASSWORD
are provided in the environment variables. If they are, we construct the MongoDB connection URI with the username and password.
Take note of the authSource=admin
query parameter in the connection URI. This additional parameter instructs MongoDB to authenticate using the admin user (root) credentials provided through environment variables (MONGODB_USER
and MONGODB_PASSWORD
). While connecting as the admin user is acceptable for small projects, it’s generally considered best practice to create a non-root user account for larger projects.
redis.ts
Update redis.ts
to include password authentication:
import { REDIS_HOST, REDIS_PORT, REDIS_PASSWORD } from "./env";
import { createClient } from "redis";
const CONNECTION_URL = `redis://${REDIS_HOST}:${REDIS_PORT}`;
const client = createClient({
url: CONNECTION_URL,
password: REDIS_PASSWORD,
});
console.log("Redis Connection URL:", CONNECTION_URL);
// Connect to Redis
const connectRedis = async () => {
try {
await client.connect();
console.log("Connected to Redis");
} catch (error) {
console.error("Error connecting to Redis:", error);
throw error; // Rethrow to handle it in the caller
}
};
client.on("error", (err) => console.log("Redis Client Error", err));
export default connectRedis;
export { client }; // Export the client instance for use in other files
In this updated version of redis.ts
, we include the password in the createClient
options to authenticate with the Redis server. The REDIS_PASSWORD
is read from the environment variables. If this variable does not exist, the Redis client will attempt to connect without a password.
In server.ts
, add a health check route:
app.get("/health", (req: Request, res: Response) => {
res.status(200).send("OK");
});
This route will return a 200 OK
response when accessed, indicating that the server is running and healthy. It’s a common practice to include a health check route in your applications to monitor the server’s status in production environments.
Next, we’ll create Docker and Docker Compose configuration files specifically for a production environment. These files will build and run the application in a production-ready containerized environment.
Dockerfile.prod
Create a Dockerfile.prod
in the root of your project:
# Stage 1: Build
FROM node:18-bullseye-slim as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --production
# Stage 2: Production Image
FROM node:18-bullseye-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/out ./out
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["npm", "run", "start"]
This Dockerfile uses a multi-stage build process to create a smaller, more secure production image. Let’s break down the Dockerfile:
Stage 1 (Build): This stage uses the node:18-bullseye-slim
image as the base image. It sets the working directory to /app
, copies the package.json
and package-lock.json
files, installs the dependencies using npm ci
, copies the source code, builds the TypeScript code, and prunes the development dependencies. By the end of this stage, we have a production-ready build of our application in the /app/out
directory.
Stage 2 (Production Image): This stage uses the node:18-bullseye-slim
image as the base image. It sets the working directory to /app
, copies the package.json
, package-lock.json
, out
directory, and node_modules
directory from the previous stage. It exposes port 3000
and sets the default command to run the application using npm run start
. This stage creates a lightweight production image that only contains the necessary files to run the application. Notably, it does not include the development dependencies or the source code.
docker-compose.prod.yml
Create a docker-compose.prod.yml
file in the root of your project:
version: "3"
networks:
backend_prod:
driver: bridge
services:
app_prod:
build:
context: .
dockerfile: Dockerfile.prod
ports:
- "${PORT}:${PORT}"
networks:
- backend_prod
environment:
- NODE_ENV=production
- PORT=${PORT}
- MONGODB_HOST=mongodb_prod
- MONGODB_PORT=27017
- MONGODB_DATABASE=${MONGODB_DATABASE}
- MONGODB_USER=${MONGODB_USER}
- MONGODB_PASSWORD=${MONGODB_PASSWORD}
- REDIS_HOST=redis_prod
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD}
depends_on:
mongodb_prod:
condition: service_healthy
redis_prod:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT}/health"]
interval: 1m
timeout: 10s
retries: 3
mongodb_prod:
image: mongo
ports:
- "${MONGODB_PORT}:27017"
volumes:
- mongodb_data_prod:/data/db
networks:
- backend_prod
environment:
- MONGO_INITDB_ROOT_USERNAME=${MONGODB_USER}
- MONGO_INITDB_ROOT_PASSWORD=${MONGODB_PASSWORD}
- MONGO_INITDB_DATABASE=${MONGODB_DATABASE}
healthcheck:
test:
[
"CMD",
"mongosh",
"--authenticationDatabase=admin",
"-u",
"${MONGODB_USER}",
"-p",
"${MONGODB_PASSWORD}",
"--eval",
"db.runCommand('ping').ok",
"--quiet",
]
interval: 10s
timeout: 10s
retries: 5
restart: always
redis_prod:
image: redis
ports:
- "${REDIS_PORT}:6379"
volumes:
- redis_data_prod:/data
networks:
- backend_prod
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 10s
retries: 5
restart: always
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
volumes:
mongodb_data_prod:
redis_data_prod:
In this docker-compose.prod.yml
file, we define the production services for our application:
app_prod
Service: This service builds the production image using the Dockerfile.prod
file. It exposes the application port, sets the necessary environment variables, and depends on the mongodb_prod
and redis_prod
services. The health check ensures that the application is running and healthy.
mongodb_prod
Service: This service uses the official MongoDB image, exposes the MongoDB port, sets the necessary environment variables for authentication, and includes a health check to verify the MongoDB connection.
redis_prod
Service: This service uses the official Redis image, exposes the Redis port, sets the necessary environment variables for authentication, and includes a health check to verify the Redis connection.
package.json
Update the scripts
section in your package.json
:
"scripts": {
"start": "node out/server.js",
"build": "tsc",
"dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/server.ts",
"seed": "tsc && node out/scripts/seedDB.js",
"test": "echo \"Error: no test specified\" && exit 1",
"docker:dev:build": "docker-compose -f docker-compose.yml build",
"docker:dev:up": "docker-compose -f docker-compose.yml up -d",
"docker:dev:down": "docker-compose -f docker-compose.yml down",
"docker:prod:build": "docker-compose -p mern-backend-prod --env-file .env.prod -f docker-compose.prod.yml build",
"docker:prod:up": "docker-compose -p mern-backend-prod --env-file .env.prod -f docker-compose.prod.yml up -d",
"docker:prod:down": "docker-compose -p mern-backend-prod --env-file .env.prod -f docker-compose.prod.yml down"
},
These new scripts provide easy commands to build, start, and stop your production Docker environment.
To test your production Docker setup:
Build the production Docker images:
npm run docker:prod:build
Start the production Docker containers:
npm run docker:prod:up
Test the application by visiting:
http://localhost:3001/
to ensure the Express app is runninghttp://localhost:3001/health
to check the health endpointhttp://localhost:3001/users
to verify the users endpoint (should return an empty array as we don’t seed the production database)When you’re done testing, stop the production Docker containers:
npm run docker:prod:down
By following these steps, you’ve prepared your MERN backend application for production deployment. The production setup includes separate environment variables, authenticated database connections, and a streamlined Docker configuration optimized for production use. Remember to always use strong, unique passwords and proper security measures when deploying to an actual production environment.