Key Takeaways
- Moving from local development to production means handling environment variables, error handling, process management, and security in a way your local setup never required.
- Managed Node.js hosting takes care of most of the underlying infrastructure, so you can focus on code instead of setting up PM2, Nginx, and SSL certificates by hand.
- Deployment options range from unmanaged VPS setups where you handle everything yourself to fully serverless platforms, each with different tradeoffs on control, cost, and complexity.
Getting a Node.js app to run on your laptop is one thing. Getting it live on a real server, safely, and in a way that doesn’t fall over the moment real users hit it, is a whole different challenge.
There’s environment variables to handle, security to think about, a process manager to keep the app alive, and the actual question of where to host it in the first place. Most tutorials skip right past all of that.
In this blog, I’ll break down the practical stuff that goes into deploying a Node.js app, cover the different places you can deploy it, and then build a small URL shortener from scratch and get it live on Cloudways.
What Actually Matters When Deploying a Node.js App
Deploying isn’t just running npm start on a different machine. A few practical things matter more than people usually give them credit for.
Environment variables are the first. Anything sensitive or environment-specific, API keys, ports, base URLs, shouldn’t be hardcoded in the source. Locally, that stuff lives in a .env file. In production, the hosting platform holds onto those values and passes them into the app at runtime.
Error handling is another one. One unhandled error in production can take down the whole app for every user, or worse, leak a raw stack trace that exposes internal details. A basic error-handling middleware catches all that and shows users something sensible instead.
Then there’s the security side. Live apps get scanned by bots almost the second they’re online. A couple of easy wins go a long way here, Helmet for sensible HTTP headers, rate limiting so no one can flood a route, and basic input validation so the app doesn’t just trust whatever a random visitor types in.
And finally, keeping the app running. When you close your terminal, node server.js dies with it, obviously not going to fly on a live server. PM2 is one way to handle this yourself, but on a managed platform, this bit’s already sorted for you.
Skip the Server Setup — Deploy on Cloudways
Cloudways Managed Node.js Hosting handles the server, process management, and deployment plumbing so you can focus on the app itself.
Where to Actually Deploy Your Node.js App
There’s no single right place to deploy a Node.js app, it really depends on how much of the underlying stuff you feel like managing yourself.
Unmanaged VPS — You get a bare server and control over everything on it. That also means you’re installing Node yourself, setting up PM2, configuring Nginx, sorting SSL, and handling OS updates. Full flexibility, but also full responsibility.
Managed Node.js hosting — The provider handles the server, process management, and deployment plumbing. You hand it a Git repo, pick your framework and Node version, and the app goes live. Way less to worry about.
Serverless — Instead of a persistent server, your code runs on demand whenever a request comes in. Nice for spiky, low-traffic apps, but it usually means rewriting parts of the code to fit that model.
For this walkthrough, I’ll use Cloudways’ Managed Node.js Hosting, mainly because it lets me focus on the actual app instead of spending time configuring a server from scratch.
How to Deploy a Node.js App: A Practical Walkthrough
To pull all of this together, I’ll build a small URL shortener, an app that takes a long URL, gives you a short one back, and redirects to the original whenever someone hits the short link.
By the time I’m done, this app will be live on a real server, protected by the basic security stuff mentioned earlier, and pulling its config from environment variables instead of anything hardcoded.
What I’ll Be Using
- Node.js
- VS Code
- Express, EJS, and SQLite
- Helmet, express-rate-limit, and nanoid
Step 1: Setting Up the Node.js Project
Before I can write any code, I need Node.js on my machine.
I’m working on my office laptop, which has IT restrictions, so I’ll grab the standalone binary version of Node.js from nodejs.org instead of running a normal installer.

Once it downloads, I’ll unzip it into my Downloads folder.

Opening the project folder in Command Prompt
I’ll create a folder for this project on my desktop and just call it node-url-shortener.
Then I’ll open up the Command Prompt and move into my project folder:
cd C:\Users\abdulrehman\Desktop\node-url-shortener
Pointing Command Prompt to the Node binary
Command Prompt doesn’t automatically know where the Node files are on my machine, so I’ll point it there manually using this command:
set PATH=%PATH%;C:\Users\abdulrehman\Downloads\node-v24.18.0-win-x64\node-v24.18.0-win-x64

To make sure it’s actually working, I’ll run:
node -v npm -v
Both commands returned the version numbers, so I’m good to continue.
Creating the Node.js project
I’ll initialize a plain Node.js project first:
npm init -y

Then I’ll install everything this project actually needs: Express for routing, EJS to render the HTML, better-sqlite3 for a tiny file-based database, nanoid to generate short codes, dotenv to load environment variables, and Helmet plus express-rate-limit for the two most basic security concerns any live app should have.
npm install express ejs better-sqlite3 nanoid dotenv helmet express-rate-limit
Step 2: Building the URL Shortener
Now I’ll open my project folder in VS Code. To do this, I’ll go to File > Open Folder and select my node-url-shortener folder.

Alright, my project’s opened in VS Code, so now it’s time to actually build the thing.
Setting Up the Database
First thing I want to do is get the database set up so short links can actually stick around when the app restarts. To do this, in the root of my project folder, I’ll create a folder called db, and inside it, a file called database.js. I’ll paste this in:
const path = require('path');
const Database = require('better-sqlite3');
const db = new Database(path.join(__dirname, 'urls.db'));
db.exec(`
CREATE TABLE IF NOT EXISTS urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
original_url TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
module.exports = db;

This creates a SQLite database file the first time the app runs, with a table for the original URL, its short code, and when it was created.
Adding Environment Variables
Next, I need somewhere to keep config that shouldn’t be hardcoded. To do this, in my project root, I’ll create a new file called .env and paste in:
BASE_URL=http://localhost:3000 NODE_ENV=development

BASE_URL is what gets used when building each short link. I’ll switch it to the live Cloudways URL later. NODE_ENV=development is fine for now while I’m testing locally, but I’ll flip it to production on the live server.
Now, since I don’t want any of this ending up in my public GitHub repo, I’ll create one more file in my project root called .gitignore, and paste in:
node_modules .env db/urls.db

This tells Git to skip all three whenever I push. node_modules because it’s just downloaded packages. .env because it has my actual config values. And db/urls.db because that’s the local database file, which shouldn’t ship with the code.
Setting Up the Server
This is where I’ll build the actual app: it takes the long URL, generates a short code for it, stores both in the database, and handles the redirect when someone hits the short link.
I’ll create a file called server.js in my project root, and paste this in:

require('dotenv').config();
const express = require('express');
const path = require('path');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { nanoid } = require('nanoid');
const db = require('./db/database');
const app = express();
const PORT = process.env.PORT || 3000;
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(helmet());
app.use(express.urlencoded({ extended: true }));
// Limit the shorten route to 10 requests per minute per IP
const shortenLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
message: 'Too many requests, please slow down.',
});
app.get('/', (req, res) => {
res.render('index', { shortUrl: null, error: null });
});
app.post('/shorten', shortenLimiter, (req, res) => {
const { url } = req.body;
try {
new URL(url);
} catch {
return res.render('index', { shortUrl: null, error: 'Please enter a valid URL.' });
}
const code = nanoid(7);
db.prepare('INSERT INTO urls (code, original_url) VALUES (?, ?)').run(code, url);
res.render('index', { shortUrl: `${BASE_URL}/${code}`, error: null });
});
app.get('/:code', (req, res) => {
const row = db.prepare('SELECT original_url FROM urls WHERE code = ?').get(req.params.code);
if (!row) {
return res.status(404).render('not-found');
}
res.redirect(row.original_url);
});
// Basic error handler so users never see a raw stack trace
app.use((err, req, res, next) => {
console.error(err);
res.status(500).send('Something went wrong on our end.');
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Helmet’s applied across the whole app, the shorten route is wrapped in a rate limiter, and there’s an error handler at the bottom that makes sure users never end up looking at a stack trace if things break.
That server.js points to a template called index that doesn’t exist yet, so next I’ll create the page it needs.
Building the UI
I’ll create a folder called views in my project root, and inside it, a file called index.ejs. I’ll paste this in:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Shorten a URL</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
<div class="card">
<h1>Shorten a URL</h1>
<form action="/shorten" method="POST">
<input type="text" name="url" placeholder="Paste a long URL here" required>
<button type="submit">Shorten</button>
</form>
<% if (error) { %>
<p class="error"><%= error %></p>
<% } %>
<% if (shortUrl) { %>
<div class="result">
<p>Here's your short link:</p>
<a href="<%= shortUrl %>" target="_blank"><%= shortUrl %></a>
</div>
<% } %>
</div>
</main>
</body>
</html>

Inside the same views folder, I’ll create another file called not-found.ejs, this is what shows up when someone hits a short code that doesn’t exist. I’ll paste this in:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Link Not Found</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
<div class="card">
<h1>Link Not Found</h1>
<p>That short link doesn't exist, or it may have expired.</p>
<a href="/">Go back</a>
</div>
</main>
</body>
</html>

And last thing for this step, I’ll create a folder called public in my project root, and inside it, a file called style.css. I’ll paste this in:
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, #667eea, #764ba2);
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
main {
width: 100%;
max-width: 500px;
}
.card {
background: #ffffff;
border-radius: 12px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
h1 {
margin-top: 0;
color: #1f2937;
}
form {
display: flex;
gap: 10px;
margin-top: 20px;
}
input {
flex: 1;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
}
button {
background: #667eea;
color: #ffffff;
border: none;
padding: 12px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #5568d3;
}
.error {
color: #dc2626;
margin-top: 15px;
}
.result {
margin-top: 25px;
padding: 15px;
background: #f3f4f6;
border-radius: 8px;
}
.result a {
color: #667eea;
word-break: break-all;
}

This gives the whole thing a clean, card-based look, nothing crazy, just enough styling so the app doesn’t look like plain, unstyled text.
Testing What We Built
Alright, time to test. I’ll add a start script to my package.json so I don’t have to keep typing the full node command:
"scripts": {
"start": "node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
}

Then, back in Command Prompt:
npm start
I’ll verify it started by opening my browser and going to http://localhost:3000. I’ll paste in some long URL, hit Shorten, and I should get a short link back. Clicking it takes me straight to the original.
Everything’s flowing exactly how I want it to.

Step 3: Pushing the Project to GitHub
Before I deploy anywhere, I want this pushed to GitHub first.
I’ll head over to GitHub and create a new repository called node-url-shortener.
Then, back in Command Prompt, I’ll run these one at a time to upload my project:
git init git add . git commit -m "First commit" git branch -M main git remote add origin https://github.com/abdulrehman293/node-url-shortener git push -u origin main
Now I’ll go back to my GitHub repo and refresh. And as expected, all my files are uploaded, minus the .env, node_modules, and the local database file.

Step 4: Deploying to Cloudways
At this point, my project’s working on the local setup and it’s also pushed to GitHub. Now I’ll move it from local to a live server. For this, I’ll use Cloudways’ Managed Node.js Hosting, built exactly for deploying projects like this directly from a Git repo.
From the Cloudways dashboard, I’ll click Node.js from the menu, and then click Launch Now.

From here, I’ll pick the Starter plan since this project doesn’t need heavy resources to run.

Now from the Deploy Your Node.js Web App screen, I’ll click Connect Via Git, and sign into my GitHub account when it asks.
Once signed in, I’ll pick my node-url-shortener repo from the list and continue.

Step 5: Configuring and Going Live
This next part is where I tell Cloudways how to run the project.
I’ll choose Express as the Framework Preset. For the Node Version, I’ll choose Node 24 (LTS). I’ll leave the Root Directory as default.

Next, I’ll click on the Change option under Build and output settings. Then, I’ll set Package Manager to npm, and the Entry File to server.js.

Now, since my .env file intentionally didn’t get uploaded to GitHub, I need to add those values manually. I’ll go to the Environment Variables section and hit Add. Here, I can add my variables.
For the first one, I’ll set Key to NODE_ENV and Value to production.
Then I’ll click + Add more and set Key to BASE_URL. And for the value, for now, I’ll put in a placeholder value like http://placeholder.com since I don’t have my temporary Cloudways URL yet. I’ll come back and update this after the first deploy.
Once both are in, I’ll click Save.


Once that’s filled in, I’ll hit Deploy Now.
Now, Cloudways will pull the code from GitHub, install the dependencies, and start the app using the entry file I pointed it to.

Moment of Truth
Once the deployment shows as successful, I’ll grab the temporary Cloudways URL from the app’s Overview page.

I’ll head back to Environment Variables, update BASE_URL to that URL, and redeploy so the change actually takes effect.



Now if I open my temporary Cloudways URL in the browser, paste in some long URL, and hit Shorten, I get back a short link pointing to my live Cloudways domain. Clicking it takes me straight to the original.

The whole thing’s up, and any short link I make from here on out will keep working as long as the app’s running.
Wrapping Up
That wraps up this run-through on deploying a Node.js application. In this blog, I covered what really goes into moving from local development to a live server, environment variables, error handling, security, and keeping the app alive, and then went through the different places you can actually deploy.
I also built a small URL shortener with a working UI, added basic production security, and pushed the finished project to my GitHub. Feel free to clone it and reuse the code.
And if you also want to try deploying something similar yourself, our Managed Node.js Hosting handles this exact kind of workflow, connect your repo, pick your framework, and you’re live.
Ready to Deploy Your Own Node.js Project?
Connect your Git repo, pick your framework, and get your app live on Cloudways in minutes.
Q. Do I need a special server to deploy a Node.js application?
Not really. A plain VPS works if you’re comfortable managing the server yourself, or you can go with a managed platform like Cloudways where the server, process management, and deployment plumbing is handled for you. Serverless is a third option, though it tends to fit certain kinds of apps more than others.
Q. What’s the difference between running a Node.js app locally and in production?
Locally, you’re the only user, crashes aren’t a big deal, and config lives in a file on your machine. In production, real users are on the app, one unhandled error can bring the whole thing down, and config has to live outside the code so different environments can use different values.
Q. Do I need PM2 to keep my Node.js app running?
Only if you’re managing the server yourself. PM2 restarts the app if it crashes and keeps it running after you close the terminal. On a managed platform, that’s already handled behind the scenes.
Q. Is it safe to deploy a Node.js app straight from GitHub?
Yep, as long as anything sensitive lives in environment variables and not in the code. Your .env file should be excluded via .gitignore so it never ends up in the repo, and the deployment platform stores those same values separately and passes them in at runtime.
Start Growing with Cloudways Today.
Our Clients Love us because we never compromise on these
Abdul Rehman
Abdul is a tech-savvy, coffee-fueled, and creatively driven marketer who loves keeping up with the latest software updates and tech gadgets. He's also a skilled technical writer who can explain complex concepts simply for a broad audience. Abdul enjoys sharing his knowledge of the Cloud industry through user manuals, documentation, and blog posts.