Getting Production Ready
It works on your machine...but does it work in production?
The answer unless you knew what you were doing or got lucky is usually no, from a small issue to everything being wrong, it's not uncommon to have a lot of issues when you deploy your code to production.
What is Production?
Production is the environment where your code is running, it's the environment where your users are using your code, and unless you are willing to leave your computer on 24/7 and setup networking to make your computer accessible to the internet, it's somebody else's computer - aka a server somewhere.
Now I'm not writing about how to make any code production ready, speifically I'm writing about how to make Node.js web applications production ready. I'm also not talking about simpler frontend-only web apps, those generally don't require many changes to be production ready.
The example I'm going to be going with is a Node.js web application using Express.js, with a Vite React frontend, where the backend needs to connect to a MongoDB database.
Additionally - because most applications have them and there are some common issues encounterd during development - Passport.js to authenticate users locally.
The Backend - Express
To describe what this application does, it allows a user to sign up and then login, after which they have access to a page that shows their current count, and a button to increment their count.
That's it, any more additions wouldn't change the production requirements.
import path from 'path';
import express from 'express';
import { MongoClient, ObjectId } from 'mongodb';
import cors from 'cors';
import session from 'express-session';
import MongoStore from 'connect-mongo';
import flash from 'connect-flash';
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
let db;
MongoClient.connect('mongodb://localhost:27017/database-name').then(client => {
db = client.db('database-name');
});
//#region Passport
passport.use(new LocalStrategy((username, password, done) => {
db.collection('users').findOne({ username }).then(user => {
if (!user) {
return done(null, false, { message: 'Incorrect username.' });
}
if (user.password !== password) {
return done(null, false, { message: 'Incorrect password.' });
}
return done(null, user);
}).catch(err => done(err));
}));
passport.serializeUser((user, done) => done(null, user._id.toString()));
passport.deserializeUser((id, done) =>
db.collection('users').findOne({ _id: ObjectId(id) }).then(user => {
done(null, user);
}).catch(err => done(err))
);
//#endregion Passport
//#region Express Middlewares
const app = express();
app.use(express.static(path.join('public')));
app.use(express.json());
app.use(cors({
origin: 'http://127.0.0.1:5173',
credentials: true,
}));
app.use(session({
secret: 'secret',
resave: false,
saveUninitialized: true,
store: MongoStore.create({ mongoUrl: 'mongodb://localhost:27017/database-name' })
}));
app.use(flash());
app.use(passport.initialize());
app.use(passport.session());
//#endregion Express Middlewares
//#region Auth Routes
app.post('/api/user/login', passport.authenticate('local', { successRedirect: '/api/user', failureRedirect: '/api/user', failureFlash: true }));
app.post('/api/user/signup', async (request, response, next) => {
try {
const { username, password } = request.body;
const existingUser = await db.collection('users').findOne({ username, password });
if (existingUser) {
return response.json({ user: null, messages: { error: ['User already exists']} });
}
const user = await db.collection('users').insertOne({ username, password });
request.login(user, (err) => {
if (err) {
return next(err);
}
return response.json({ user });
});
} catch (err) {
next(err);
}
});
app.get('/api/user', (request, response) =>
response.send({
user: request.user || null,
messages: request.flash()
})
);
//#endregion Auth Routes
//#region Counter Routes
app.route('/api/counter')
.get(async (request, response, next) => {
try {
const counter = await db.collection('counters').findOne({ owner: ObjectId(request.user._id) });
return response.send({ count: counter?.count ?? 0 });
} catch (err) {
next(err);
}
})
.post(async (request, response, next) => {
try {
let counter = await db.collection('counters').findOne({ owner: ObjectId(request.user._id) }) || {
_id: ObjectId(),
count: 0
};
counter = (await db.collection('counters').findOneAndUpdate({ owner: ObjectId(request.user._id) }, { $set: { count: counter.count + +request.body.change } }, { upsert: true, returnDocument: 'after' })).value;
return response.send({ count: counter.count });
} catch (err) {
next(err);
}
});
//#endregion Counter Routes
app.listen(3000);
Customizable via Environment Variables
Environment Variables are a common way to configure your application, they are a way to set variables that are specific to the environment your application is running in.
They're not a magical fourth kind of JavaScript variable though, they exist in the environment your application is running in, and are accessed via process.env
. You actually already providing possibly hundreds of environment variables to your application, just by default.
The most common one used is the PORT
environment variable, which is used to tell your application what port to listen on, and is used by default on many hosting services to tell your application what port to listen on. This is important because if you don't listen to the port the provider has opened you to listen on, they won't be able to forward requests to your application.
-app.listen(3000);
+const PORT = process.env.PORT || 3000;
+
+app.listen(PORT);
It's also common practice when using any environment variable to not only assign it to a constant variable - usually of the same name - but if there is a default value, to assign that to the constant if the environment variable wasn't set.
Logging
Knowing when something happens - both good and bad - is important, and logging is a common way to do that. I'm not about to throw some complicated logging library at you, the console is enough for most purposes
- app.listen(PORT);
+ app.listen(PORT, () => console.log(`Listening at https://localhost:${PORT}/`));
Here we see the first advantage of assigning said PORT
environment variable to a constant, we can reuse it again in the log message.
Why output a full URL instead of just the port? It's easier to click on / copy from your terminal to open it in your browser.
Another advantage of this log message is that it shows if the set environment variable was used or not, allowing you to narrow your search for the problem if you're having related issues.
MongoDB Environment Variable
The last environment variable I'm going to talk about - at least on the backend - is the MongoDB connection string, which is used to connect to the database.
This is different from the PORT
because it's meant to be a secret, and shouldn't be shared with anyone, so unlike the PORT
where you can safly include a real default value, here you should use a placeholder value.
+const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/database-name';
+const DB_NAME = MONGODB_URI.split('/').at(-1).split('?')[0];
let db;
-MongoClient.connect('mongodb://localhost:27017/database-name').then(client => {
- db = client.db('database-name');
+MongoClient.connect(MONGODB_URI).then(client => {
+ db = client.db(DB_NAME);
app.use(session({
- store: MongoStore.create({ mongoUrl: 'mongodb://localhost:27017/database-name' })
+ store: MongoStore.create({ mongoUrl: MONGODB_URI })
Proper Order of Operations
The first thing you should do is make sure that your code runs and is usable in the order you expect it to be, in the above code since the database and server are started at the time, it's possible that the database isn't ready when the first request comes in.
Encorcing the order of these two operations - connecting to the database and starting the server - is thankfully easy with Promises.
-let db;
+console.log(`Connecting to MongoDB "${DB_NAME}"...`)
MongoClient.connect(MONGODB_URI).then(client => {
- db = client.db(DB_NAME);
-});
+ const db = client.db(DB_NAME);
+ console.log(`Connected to MongoDB "${DB_NAME}".`);
app.listen(PORT, () => console.log(`Listening at https://localhost:${PORT}/`));
+});
Unecessary cors
If you had installed the cors
package to resolve the errors you were receiving from your frontend React and have no intention of deploying the backend and frontend to different domains, you can safely it - following steps will make it unecessary.
-import cors from 'cors';
- app.use(cors({
- origin: 'http://127.0.0.1:5173',
- credentials: true,
- }));
Don't forget nodemon
If you are a user of nodemon
, ensure you are using it as a dev
and not start
script, as it is not meant to be used in production and can cause issues over time.
- "start": "nodemon index.js",
+ "start": "node index.js",
+ "dev": "nodemon index.js",
While we're manaing dev dependencies, ensure that nodemon
- and any other packages that are only used for development - are installed as devDependencies
and not dependencies
.
The Frontend - React
Now most of the actual code for React should work on production if it works locally, but there is one way you may have written your frontend code that will not work on production.
fetch('http://localhost:3000/api/user')
If you have any fetch or network requests that are meant to be going to the backend hardcoded like this, they will not work on production, because the backend is not guarnteed to be running on port 3000
. Now while you can do the same as you did on the backend and create & use a PORT
variable, the better way to do it is to prepare it for being on the same domain as the backend.
- fetch('http://localhost:3000/api/user')
+ fetch('/api/user')
If you were planning on deplying this application as a seperate frontend and backend, you would use an additional frontend envnironment variable -
BACKEND_URL
for example - to set the URL of the backend during production.
Now the inverse problem happens, this doesn't work in development. We need to make sure that the development frontend server is proxying requests to the backend, in Vite it's done by adding a little code your vite.config.js
:
export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:3000',
}
}
});
This tells Vite that any requests to /api/*
should be proxied to http://localhost:3000/api/*
, and since we changed our fetch request to /api/user
it will now work in both development and production.
But I'm using Create React App
It's actually simpler in Create React App, you only need to add the proxy
key to your package.json
with the server location:
"proxy": "http://localhost:3000"
It behaves slightly differenty then the Vite proxy as you can see, it's all failed requests that are proxied, not just requests to /api/*
.
Combining Both
The way you probably get these running is by opening one terminal to the backend folder, running npm run start
, and then opening another to the frontend folder, running your chosen frontend command, and then opening your browser to http://localhost:3000
.
But production will expect you to be running a single server, not two, not to mention that the frontend is being ran in development mode, which is highly inefficient.
Building the Frontend
The first thing we need to do is build the frontend, this is done by running npm run build
in the frontend folder, which will create a dist
/build
folder with all the files needed to run the frontend.
Now this is a matter of opinion, but I personally like to move the contents of this folder into the backend folder, allowing platforms that support it to purge the frontend source folder entirely.
This can be done in Vite by adding the following to your vite.config.js
:
build: {
outDir: '../backend/public',
emptyOutDir: true,
}
Again if deploying as seperate frontend and backend, you could make this customizable by another environment variable.
or in Create React App via the BUILD_PATH
environment variable, which can easily prefixed to the build
script in package.json
:
"build": "BUILD_PATH='../backend/public' react-scripts build",
Like any generated asset these aren't generally meant to ever be included in a git repository, so you should add the public
folder to your .gitignore
.
Connecting it all together
This is the point where we will provide the programatic instructions for doing what you've been doing manually, and we'll do it in the root package.json
:
{
"name": "production-ready",
"version": "1.0.0",
"scripts": {
"start": "npm run start --workspace=backend",
"build": "npm run build --workspace=frontend",
},
"workspaces": [
"backend",
"frontend"
]
}
If you were to adopt the concurrency
package, you could also add a dev
script to run both the frontend and backend in development mode without having to open two terminals:
"dev": "concurrently \"npm run dev --workspace=backend\" \"npm run dev --workspace=frontend\"",
Why the Workspaces?
You would be correct that it's possible to not use workspaces, the main reason would be perhaps if you were running an older version of npm
that doesn't support workspaces.
In that case you would replace all --workspace
usages with cd
prefixes:
"scripts": {
"start": "cd backend && npm run start",
"build": "cd frontend && npm run build",
"dev": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\"",
"postinstall": "(cd backend && npm install) && (cd frontend && npm install)"
},
You'll notice there's a new script, because the three package.json
s have nothing linking them together, we can't rely on the workspaces to install the dependencies for us, so we need to do it manually - since npm
will run the postinstall
script after the install
command, we can use that to install the dependencies for the sub-packages.
Why not use the --prefix
flag?
I actually had used the --prefix
flag for a bit, until I attempted to find official documentation about it and discovered that it's actually eventually going to be removed, and introduces it's own set of bugs.
Running it all together
The great thing about preparing your code for production is that you can emulate at least a majority of the production behavior locally, and that's exactly what we're going to do.
If you happen to know what exact commands your platform of choise runs, you'd run those here, but for the most part any platform supporting Node.js runs
install
andbuild
, possible with some variations.
First delete all three node_modules
folders, and then run npm install
in the root folder, this should install all the dependencies for both the frontend and backend - lastly run npm run build
to build the frontend, and it should all work when you run npm run start
.
If it doesn't, well then it almost certantly won't work in production - so we just saved ourselves a lot of time pushing and waiting.
At this point there's nothing more to do except actually deploy your code, however your platform requires you to do so, and don't forget to set your environment variables as needed.
Conclusion
As you can see, being done with an application and being production-ready can be two wildly different things, but the more you stick with best practices and make your code as production-ready the first time you write it, the less you'll have to do when it comes time to actually deploy it.
If you wanted to see the code evolve through these steps, here's a link to the repository, feel free to traverse the commit history to see the changes.