Deploy Full-Stack Web App on Hostinger VPS, Namecheap DNS & CDN

by Fahim

Managed cloud platforms make deployments dead simple, but the invoices sting the moment your app gets traction. Paying $50 to $100 a month for container runners and tiny databases when a $6/month VPS can handle the same workload just doesn’t make sense. I wanted a lean, reliable setup for my full-stack TypeScript project without paying managed hosting markups.

Here is my exact production setup: a clean Ubuntu VPS on Hostinger running an Express API and a React/Vite frontend, wired to Namecheap DNS with a CDN sitting in front to cache static assets and take load off the box.

Deploy full stack web app on Hostinger VPS with Namecheap DNS and Nginx
Deploy full stack web app on Hostinger VPS with Namecheap DNS and Nginx

1. Provisioning the Hostinger VPS and Hardening Access

Grab a KVM 2 or KVM 4 instance running Ubuntu 24.04 from the Hostinger dashboard. The first thing you should do after it spins up is copy over your SSH key so you never touch the root password again.

SSH in as root, create a non-root deploy user with sudo privileges, and copy your authorized keys over:

# Connect to your new VPS
ssh root@YOUR_SERVER_IP # Create a non-root user with sudo permissions
adduser deployer
usermod -aG sudo deployer # Copy root SSH keys to deployer
rsync --archive --chown=deployer:deployer ~/.ssh /home/deployer/

Next, lock down the server with UFW (Uncomplicated Firewall). Open ports will get hit with automated SSH brute-force bots within minutes of being assigned an IP, so allow only SSH, HTTP, and HTTPS:

# Configure basic firewall rules
ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Disconnect and log back in as your deployer user. If you plan to run multiple apps or staging domains on separate boxes down the line, check out our guide on how to route subdomains to different servers in Namecheap DNS.

2. Installing the Runtime and Process Manager

We need Node.js, a reverse proxy (Nginx), and a process manager (PM2) to keep the backend running if it hits an uncaught exception. I stick with Node 20 LTS installed via NodeSource.

Run these commands on the server to grab the repositories and install the toolchain:

# Update package lists
sudo apt update && sudo apt upgrade -y # Install Node.js 20 LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs nginx git build-essential # Install PM2 globally
sudo npm install -g pm2

Verify what was installed with node -v and nginx -v. You want Node 20.x and Nginx 1.24+ so everything handles modern HTTP/2 directives properly.

3. Cloning the Code and Building the App

I put all application code in /var/www/ rather than the user home directory to keep permissions clean and predictable. In this setup, we have an Express backend listening on port 3000 and a React/Vite frontend that compiles static files into a dist folder.

Set up the folder permissions and clone your repo:

# Create web directory and take ownership
sudo mkdir -p /var/www/myapp
sudo chown -R deployer:deployer /var/www/myapp # Clone your repository
git clone https://github.com/your-username/your-repo.git /var/www/myapp
cd /var/www/myapp # Install dependencies and build client + server
npm ci
npm run build

Create your production .env file in the project root. Keep your database URLs, tokens, and secrets in here:

NODE_ENV=production
PORT=3000
DATABASE_URL="postgresql://dbuser:secretpass@localhost:5432/myapp_prod"
APP_SECRET="64_byte_random_string_here"
CORS_ORIGIN="https://yourdomain.com"

Now configure PM2 using an ecosystem file. This lets us run in cluster mode and restart automatically if the process crashes. Create ecosystem.config.js in your project root:

module.exports = { apps: [ { name: 'myapp-api', script: './dist/server.js', instances: 'max', exec_mode: 'cluster', env: { NODE_ENV: 'production', PORT: 3000 } } ]
};

Fire up the app with PM2 and generate the systemd startup script so it boots automatically after server reboots:

pm2 start ecosystem.config.js
pm2 save
pm2 startup systemd

For more details on tuning PM2 configurations, take a look at our guide on how to deploy Node.js on Hostinger VPS with Nginx and DNS.

4. Setting Up Nginx as a Reverse Proxy

Nginx sits in front of Node. It handles SSL termination, serves our pre-built frontend files straight off disk in milliseconds, and passes API calls over to PM2 on port 3000.

Create a fresh site config file at /etc/nginx/sites-available/myapp.conf:

sudo nano /etc/nginx/sites-available/myapp.conf

Add this configuration, swapping in your real domain and build paths:

server { listen 80; server_name yourdomain.com www.yourdomain.com; root /var/www/myapp/dist/client; index index.html; # Serve static files directly location / { try_files $uri $uri/ /index.html; } # Pass API requests to the PM2 cluster location /api/ { proxy_pass http://127.0.0.1:3000; 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 by linking it to sites-enabled, remove the default Nginx welcome page, and test the config syntax:

sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

5. Configuring Namecheap DNS Records and Free SSL

Hop into your Namecheap dashboard, go to Domain List > Manage > Advanced DNS, and point your domain records to your Hostinger VPS IP address.

Add these two records in the Host Records section:

  • Type: A Record | Host: @ | Value: YOUR_HOSTINGER_VPS_IP | TTL: Automatic
  • Type: A Record | Host: www | Value: YOUR_HOSTINGER_VPS_IP | TTL: Automatic

If you have existing email on this domain, be careful not to delete your MX or TXT records. See our walkthrough on how to point Namecheap DNS to Hostinger without breaking email if you are migrating live domains.

Once DNS propagates (check with dig yourdomain.com +short), issue a free Let’s Encrypt certificate with Certbot following the official Certbot documentation:

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot automatically updates your Nginx configuration to force HTTPS and configures a systemd timer for auto-renewals.

6. Setting Up CDN Caching and Static Headers

Routing traffic through a CDN speeds up delivery for global visitors and shields your VPS from traffic spikes. In your CDN dashboard, set the origin host to yourdomain.com and enable SSL pass-through (or Full SSL).

The biggest issue with CDNs is stale frontend bundles after a new deployment. You fix this with proper Cache-Control headers in Nginx: cache hashed assets forever, but never cache index.html. Update your site config block:

# Cache immutable static assets for 1 year
location ~* .(?:css|js|woff2?|png|jpg|jpeg|gif|ico|svg)$ { root /var/www/myapp/dist/client; expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; access_log off;
} # Never cache index.html so updates load instantly
location = /index.html { root /var/www/myapp/dist/client; add_header Cache-Control "no-store, no-cache, must-revalidate";
}

For more nuanced edge caching rules and cross-origin policies, check out our guide on CORS and Cache-Control headers for CDN static assets.

7. Gotchas I Hit in Production and How to Fix Them

Here are two specific problems that tripped me up the first time I ran this architecture.

The Reverse Proxy Dropped WebSocket Connections

Real-time updates failed silently in production, and the browser console threw WebSocket connection failed: 400 Bad Request.

Nginx needs explicit headers to upgrade HTTP connections to WebSockets. Inside your location /api/ block, make sure you have both of these lines:

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

If either header is missing, Nginx terminates the WebSocket handshake before it reaches your Node app.

Memory Spikes on Small VPS Instances

Running npm run build on a 2GB RAM instance caused Vite to crash with JavaScript heap out of memory. The Linux kernel OOM-killer killed the build process halfway through compiling chunks.

You do not need to upgrade your VPS plan just for builds. Add a 2GB swap file on Ubuntu to absorb the compile-time memory spike:

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

With swap enabled, the build completed in 42 seconds without crashing.

8. Automated Zero-Downtime Deployments with Git Hooks

You do not need a bloated CI/CD pipeline just to deploy updates. A simple bare Git repository and a post-receive hook let you run git push production main to deploy directly from your local machine.

Create a bare Git repo on the VPS:

mkdir -p /home/deployer/repos/myapp.git
cd /home/deployer/repos/myapp.git
git init --bare # Create the deploy hook
nano hooks/post-receive

Create the deploy hook script at hooks/post-receive:

#!/bin/bash
TARGET="/var/www/myapp"
GIT_DIR="/home/deployer/repos/myapp.git" BRANCH="main" while read oldrev newrev ref
do if [[ $ref =~ .*/$BRANCH$ ]]; then echo "Deploying branch $BRANCH to production..." git --work-tree=$TARGET --git-dir=$GIT_DIR checkout -f $BRANCH cd $TARGET npm ci --omit=dev npm run build pm2 reload ecosystem.config.js --update-env echo "Deploy completed successfully." fi
done

Make the script executable:

chmod +x /home/deployer/repos/myapp.git/hooks/post-receive

Back on your local machine, add the VPS remote and push:

git remote add production deployer@YOUR_SERVER_IP:/home/deployer/repos/myapp.git
git push production main

Because we use pm2 reload instead of restart, PM2 spins up new worker processes before killing old ones, giving you seamless zero-downtime updates.

Frequently Asked Questions

Why use Nginx instead of exposing the Node.js port directly?

Node.js is great at running application logic, but it shouldn’t handle SSL negotiation, gzip compression, or static file delivery. Nginx serves cached static files straight from the OS kernel in under 12ms and shields Node from slow-client attacks.

How do I handle database migrations during Git deployments?

Add your migration step (e.g., npx prisma migrate deploy or npm run migrate) right before the pm2 reload command inside your post-receive hook. If migrations fail, the script exits before reloading the running processes.

Can I run multiple web apps on the same Hostinger VPS?

Yes. Give each app its own folder in /var/www/, run the Node servers on different ports (like 3000, 3001, 3002), and add a separate Nginx config file in /etc/nginx/sites-available/ for each domain.

Next Steps for Your VPS Stack

Now that your full-stack app is up and serving requests, your next priorities should be automated database backups and basic log monitoring. If you want to squeeze even more performance out of your edge layer, check our guide on how to connect a Namecheap domain to Hostinger through StackPath CDN to fine-tune edge cache hit rates and block malicious traffic.

Official resources

all_in_one_marketing_tool