Deploying a Full-Stack Node.js Application on Ubuntu Server Without Docker

Deploying a full-stack application directly on a Linux server without the use of containers was one of the most instructive experiences I have encountered recently. Certainly, Docker is highly popular at present, but there is a certain satisfaction in maintaining full control over your infrastructure and being able to perform direct debugging in a production environment. Allow me to guide you through the process of deploying a React UI alongside a Node.js backend and a PostgreSQL database on Ubuntu 24.04.

Reasons for Deploying Without Containers

We all are aware of Docker’s popularity. However, it is important to recognise that direct deployment on Ubuntu offers several significant advantages that are often overlooked. The application starts faster, troubleshooting is way easier when you have direct file access, there’s less resource overhead (which matters when you’re on a budget), and logging integration with systemd is straightforward. If you’re working on a small to medium application or you’re just getting started with DevOps, this approach gives you a solid foundation before jumping into the containerised world.

Prerequisites Required Prior to Commencing

Things You Need to Do Before You Start

Make sure that these basic parts are taken care of:

  • Ubuntu 24.04 LTS server with admin rights
  • You need at least 2GB of RAM and 10GB of disc space
  • SSH access set up. You can get root or sudo access.

For this example, I’m using a simple Todo List app that has a React frontend, an Express.js backend, and a PostgreSQL database. Perfect for getting the hang of things.

Setting Up Node.js the Right Way

Ubuntu’s default repositories have older Node.js versions, and trust me, you don’t want to deal with version mismatches. Here’s how I installed the latest LTS version from NodeSource:

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs

Quick verification to make sure everything’s working:

node --version
npm --version

You should see something like v20.19.6 and 10.8.2. Perfect!

Getting PostgreSQL Up and Running

PostgreSQL is my go-to database for production apps. Installation is straightforward:

sudo apt install -y postgresql postgresql-contrib

I always check if the service is actually running (can’t tell you how many times I’ve forgotten this step):

psql --version
sudo systemctl status postgresql

You’ll see PostgreSQL 16.11 running, which is solid and stable.

Nginx for Web Serving and Reverse Proxy

Nginx is going to serve your static React files and proxy API requests to the Node.js backend. It’s fast and reliable:

sudo apt install -y nginx
nginx -v

Should show nginx version 1.24.0.

PM2 – Your Application’s Guardian Angel

PM2 is seriously one of the best tools out there. It keeps your Node.js app running 24/7 and automatically restarts it if something goes wrong:

sudo npm install -g pm2
pm2 --version

Version 6.0.14 is what I’m running.

Configuring PostgreSQL (The Fun Part)

Now let’s set up the database. I like to create a dedicated database and user for each application – it’s just cleaner:

sudo -u postgres psql -c "CREATE DATABASE tododb;"
sudo -u postgres psql -c "CREATE USER todouser WITH PASSWORD 'todopass123';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE tododb TO todouser;"
sudo -u postgres psql -d tododb -c "GRANT ALL ON SCHEMA public TO todouser;"

Creating the table structure:

sudo -u postgres psql -d tododb -c "CREATE TABLE todos (
  id SERIAL PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  description TEXT,
  completed BOOLEAN DEFAULT false,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);"

I always add some test data to make sure everything works:

sudo -u postgres psql -d tododb -c "INSERT INTO todos (title, description, completed) VALUES
  ('Welcome to Todo App', 'This is your first todo item', false),
  ('Learn React', 'Build amazing frontend applications', false),
  ('Learn Node.js', 'Create powerful backend APIs', true);"

Setting Up the Backend

Time to get the Node.js backend in place. I organize my projects like this:

mkdir -p ~/todo-app/backend
cd ~/todo-app/backend

Here’s my package.json – keeping dependencies minimal and focused:

{
  "name": "todo-backend",
  "version": "1.0.0",
  "description": "Todo app backend API",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "pg": "^8.11.3",
    "cors": "^2.8.5",
    "dotenv": "^16.3.1"
  }
}

Installing dependencies:

npm install

Pro tip: Always use environment variables for sensitive data. Here’s my .env setup:

cat > .env << EOF
PORT=5000
DB_USER=todouser
DB_HOST=localhost
DB_NAME=tododb
DB_PASSWORD=todopass123
DB_PORT=5432
EOF

Important: In production, use stronger passwords! Consider tools like AWS Secrets Manager or dotenv-vault.

Launching with PM2

This is where the magic happens. PM2 will keep your app running forever:

pm2 start server.js --name todo-api

You’ll see a nice table showing your app is online. Beautiful!

Now, make sure it starts automatically on server reboots (I forgot this once and had a fun surprise after maintenance):

pm2 startup systemd
# Run the command it gives you
pm2 save

Some useful PM2 commands I use all the time:

pm2 list          # See all running apps
pm2 logs todo-api # View real-time logs
pm2 monit         # Monitor resources
pm2 restart todo-api # Restart the app

Building the React Frontend

Time to create the production build of your React app:

cd ~/todo-app/frontend
npm run build

Vite builds it super fast – usually done in under a second. The optimized files go into the dist directory.

Configuring Nginx (The Critical Part)

This is where a lot of people get stuck, so I’ll walk through it carefully. Create your Nginx config:

sudo nano /etc/nginx/sites-available/todo-app

Here’s the configuration I use:

server {
    listen 80;
    server_name your_server_ip;

    # Serve React frontend
    location / {
        root /home/yourusername/todo-app/frontend/dist;
        index index.html;
        try_files $uri $uri/ /index.html;
    }

    # Proxy API requests to backend
    location /api {
        proxy_pass http://localhost:5000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        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;
        proxy_cache_bypass $http_upgrade;
    }
}

Enable the site:

sudo ln -s /etc/nginx/sites-available/todo-app /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default

Always test the config before restarting (learned this lesson the hard way):

sudo nginx -t
sudo systemctl restart nginx

The Permission Headache (And How I Fixed It)

If you see a 500 error, it’s probably permissions. Check the Nginx error log:

sudo tail -20 /var/log/nginx/error.log

You’ll likely see “Permission denied” errors. Here’s the fix:

chmod 755 /home/yourusername
chmod -R 755 ~/todo-app

This gave me a headache for about 30 minutes before I figured it out!

Testing Everything

Let’s make sure everything works. Test the backend directly:

curl http://localhost:5000/api/health
curl http://localhost:5000/api/todos

Test through Nginx:

curl http://localhost/api/health

And from outside your server:

curl http://your_server_ip/api/health

Open your browser and visit your server IP – you should see your Todo app!

Troubleshooting Common Issues

App keeps crashing? Check PM2 logs:

pm2 logs todo-api --lines 50

Database connection errors? Test PostgreSQL:

psql -h localhost -U todouser -d tododb

502 Bad Gateway? Make sure your backend is running:

pm2 status
netstat -tulpn | grep 5000

Security Best Practices

Don’t skip this part! Set up a basic firewall:

sudo ufw allow 22/tcp    # SSH
sudo ufw allow 80/tcp    # HTTP
sudo ufw allow 443/tcp   # HTTPS
sudo ufw enable

Change your database password to something strong:

sudo -u postgres psql -c "ALTER USER todouser WITH PASSWORD 'YourStrongPasswordHere';"

Add security headers to Nginx (inside your server block):

add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;

Performance Tips

Want better performance? Run PM2 in cluster mode:

pm2 delete todo-api
pm2 start server.js -i max --name todo-api

This runs one instance per CPU core – really helps with load handling.

Add Nginx caching for static files:

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

Maintenance and Updates

When you need to deploy updates:

cd ~/todo-app/backend
npm install  # If dependencies changed
pm2 restart todo-api

For frontend updates:

cd ~/todo-app/frontend
npm run build

Regular database backups are crucial:

pg_dump -U todouser -h localhost tododb > backup_$(date +%Y%m%d).sql

To restore:

psql -U todouser -h localhost tododb < backup_20251224.sql

Conclusion

And at the last. We have successfully deployed full-stack Node.js app on Ubuntu 24.04 without Docker. You can do the same for production: PM2 keeps things alive, Nginx serves fast, and PostgreSQL keeps data safe.

Key takeaways from my experience:
PM2 is a must-have for keeping your application running 24/7. Nginx is high performance and efficient for static file serving and reverse proxying. PostgreSQL with proper user isolation is a solid database.
File permissions can be a problem, check them first.
Regular monitoring and backups avoid future problems

I recommend Let’s Encrypt for SSL certificates for production, automated backups, Grafana for monitoring, and log rotation. But this way you have control over your infrastructure, and it’s simple and easy to debug.
If you have any problems, let me know, I have probably been there before.

Avatar photo

Asif Khan

Responsible and proactive professional with more than 13 years of experience in IT systems, open source software applications, DevOps, Linux systems, and cloud operations. My main goals are to automate things, keep them safe, and make sure they are strong. I am very good at planning and building the infrastructure for services that people really want. I was drawn to the fast-paced world of cloud computing because it has resources that can be scaled up or down as needed. One of my best skills is being able to use a lot of different DevOps tools to set up, release management, and microservices ecosystems, as well as for provisioning, orchestration, and configuration management.