This guide covers a basic production deployment of a Node.js application on an AWS EC2 instance.
- AWS EC2
- Ubuntu 24.04 LTS
- Node.js 24.x LTS
- npm
- PM2
- Caddy
- Git
- HTTPS with automatic TLS
- Optional UFW firewall
Node.js version: As of August 2026, Node.js 24.x is the current LTS line. This guide uses Node.js 24 LTS rather than the non-LTS Current release for production.
Create an EC2 instance with:
- Ubuntu 24.04 LTS
- At least 2 GB RAM for a small application
- Elastic IP recommended for production
- SSH key configured
Allow inbound:
Type Port Source
SSH 22 Your IP only
HTTP 80 0.0.0.0/0
HTTPS 443 0.0.0.0/0
Do not expose your Node.js application port such as 3000 publicly
if Caddy is going to reverse proxy to it.
From your local machine:
ssh -i your-key.pem ubuntu@YOUR_EC2_PUBLIC_IPExample:
ssh -i production.pem ubuntu@54.123.123.123sudo apt update
sudo apt upgrade -yInstall basic utilities:
sudo apt install -y git curl unzip build-essentialVerify:
git --version
curl --versionFor a simple deployment, you can use the default ubuntu user.
For a more controlled production setup, create a dedicated user:
sudo adduser deployGive it sudo access if required:
sudo usermod -aG sudo deployFor a simple setup, you can continue using ubuntu.
The recommended approach is to use NVM (Node Version Manager). This makes it easy to change Node.js versions later without modifying the system packages.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bashReload your shell:
source ~/.bashrcVerify NVM:
nvm --versionnvm install 24Make Node.js 24 the default:
nvm alias default 24
nvm use 24Verify:
node -v
npm -vExpected Node.js version:
v24.x.x
PM2 keeps your Node.js application running in production and automatically restarts it if the process crashes.
Install:
npm install -g pm2Verify:
pm2 -vCreate an application directory:
mkdir -p ~/apps
cd ~/appsClone your repository:
git clone YOUR_GIT_REPOSITORY_URLExample:
git clone git@gitlab.com:company/my-app.gitEnter the project:
cd my-appFor production:
npm ciIf your project does not have a package-lock.json, use:
npm installCreate your production environment file:
nano .envExample:
NODE_ENV=production
PORT=3000
DATABASE_URL=your_database_url
REDIS_URL=your_redis_url
API_URL=https://api.example.comSave the file.
Make sure .env is not committed to Git:
echo ".env" >> .gitignoreIf the repository already contains .env.example, use it as a
reference:
cp .env.example .env
nano .envFor a typical Node.js application:
npm run buildFor a Next.js application:
npm run buildTest the application manually:
npm startIf the application starts successfully, stop it with:
CTRL + C
For a Node.js application whose production command is npm start:
pm2 start npm --name "my-app" -- startIf your application requires a specific port:
PORT=3000 pm2 start npm --name "my-app" -- startCheck the process:
pm2 statusView logs:
pm2 logs my-appView only the last 100 lines:
pm2 logs my-app --lines 100If the application is running on port 3000:
curl http://localhost:3000If you receive an HTTP response, your Node.js application is running correctly.
You can also check the port:
sudo ss -ltnp | grep 3000Run:
pm2 startupPM2 will print a command similar to:
sudo env PATH=$PATH:/home/ubuntu/.nvm/versions/node/v24.x.x/bin pm2 startup systemd -u ubuntu --hp /home/ubuntuCopy and run the exact command printed by PM2.
Then save the currently running applications:
pm2 saveVerify:
pm2 statusNow PM2 will restore your application after an EC2 reboot.
Caddy will act as the reverse proxy and handle HTTPS.
Install the official Caddy repository:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curlAdd the Caddy signing key:
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor \
-o /usr/share/keyrings/caddy-stable-archive-keyring.gpgAdd the Caddy repository:
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.listUpdate packages:
sudo apt updateInstall Caddy:
sudo apt install -y caddyVerify:
caddy versionCheck the service:
sudo systemctl status caddyCaddy's official Ubuntu package runs as a systemd service.
Before enabling HTTPS, point your domain/subdomain to the EC2 instance.
Example:
app.example.com
Create an A record:
Type: A
Name: app
Value: YOUR_EC2_ELASTIC_IP
TTL: 300
For example:
app.example.com -> 54.123.123.123
If using an Elastic IP, use the Elastic IP rather than the temporary EC2 public IP.
Verify DNS:
dig +short app.example.comIt should return your EC2 IP.
Open the Caddy configuration:
sudo nano /etc/caddy/CaddyfileFor a Node.js application running on port 3000:
app.example.com {
reverse_proxy localhost:3000
}Save the file.
Before reloading Caddy:
sudo caddy validate --config /etc/caddy/CaddyfileYou should see:
Valid configuration
sudo systemctl reload caddyCheck:
sudo systemctl status caddyView Caddy logs:
sudo journalctl -u caddy -fOnce:
- DNS points to the EC2 instance
- Port 80 is publicly accessible
- Port 443 is publicly accessible
- Caddy is running
Caddy will automatically obtain and renew the TLS certificate for:
https://app.example.com
You do not need to manually create or renew Let's Encrypt certificates.
Test:
curl -I https://app.example.comIf you want an additional firewall on the EC2 instance:
sudo apt install -y ufwAllow SSH first:
sudo ufw allow 22/tcpAllow HTTP:
sudo ufw allow 80/tcpAllow HTTPS:
sudo ufw allow 443/tcpEnable:
sudo ufw enableCheck:
sudo ufw statusImportant: Always allow SSH before enabling UFW, otherwise you can lock yourself out of the server.
The final architecture should look like:
Internet
|
|
app.example.com
|
v
+--------------+
| Caddy |
| :80/:443 |
+--------------+
|
| reverse proxy
v
+--------------+
| PM2 |
| Node.js |
| :3000 |
+--------------+
|
v
Application / APIs
The Node.js port (3000) should only be accessible locally.
Users should access:
https://app.example.com
not:
http://YOUR_EC2_IP:3000
Once the server is configured, a basic deployment becomes:
cd ~/apps/my-app
git pull
npm ci
npm run build
pm2 restart my-appCheck:
pm2 status
pm2 logs my-app --lines 100If the Caddy configuration was changed:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddyYou can create a simple deployment script:
nano ~/deploy.shAdd:
#!/bin/bash
set -e
APP_DIR="$HOME/apps/my-app"
echo "Starting deployment..."
cd "$APP_DIR"
echo "Pulling latest code..."
git pull
echo "Installing dependencies..."
npm ci
echo "Building application..."
npm run build
echo "Restarting application..."
pm2 restart my-app
echo "Deployment completed."
pm2 statusMake it executable:
chmod +x ~/deploy.shRun deployment:
~/deploy.shpm2 statuspm2 start npm --name "my-app" -- startpm2 restart my-apppm2 stop my-apppm2 delete my-apppm2 logs my-apppm2 monitpm2 savesudo systemctl status caddysudo systemctl restart caddysudo systemctl reload caddysudo caddy validate --config /etc/caddy/Caddyfilesudo journalctl -u caddy -fpm2 status
pm2 logs my-appCheck whether Node is listening:
sudo ss -ltnp | grep 3000Usually this means Caddy cannot reach your Node.js application.
Check:
pm2 statusThen:
curl http://localhost:3000If this fails, fix the Node.js application first.
Check DNS:
dig +short app.example.comCheck Caddy:
sudo journalctl -u caddy --no-pager -n 100Make sure ports 80 and 443 are open in the EC2 Security Group.
Remove the inbound 3000 rule from the EC2 Security Group.
Your architecture should be:
Internet
|
+--> 80/443 --> Caddy --> localhost:3000 --> Node.js
Not:
Internet --> 3000 --> Node.js
- EC2 instance created
- Elastic IP configured
- SSH access working
- Security Group configured
- Ports 80 and 443 allowed
- Node.js LTS installed
- npm working
- Git installed
- Application cloned
-
.envconfigured - Dependencies installed
- Application builds successfully
- PM2 installed
- Application running under PM2
- PM2 startup configured
-
pm2 savecompleted - Caddy installed
- DNS A record configured
- Caddyfile configured
- Caddy configuration validated
- Caddy reloaded
- HTTPS working
- Application accessible through domain
- Node.js application port not publicly exposed
- Deployment script created (optional)
After completing everything, your server should have:
EC2
│
├── Ubuntu 24.04
│
├── Node.js 24 LTS
│
├── npm
│
├── PM2
│ └── my-app
│ └── localhost:3000
│
└── Caddy
├── :80
├── :443
└── app.example.com
The production request flow is:
User
|
v
https://app.example.com
|
v
AWS EC2
|
v
Caddy :443
|
v
localhost:3000
|
v
Node.js Application
- Caddy installation: https://caddyserver.com/docs/install
- Caddy Caddyfile: https://caddyserver.com/docs/caddyfile
- Caddy HTTPS: https://caddyserver.com/docs/quick-starts/https
- Node.js releases: https://nodejs.org/en/about/previous-releases