Running multiple containers in production gets messy fast when you’re manually stitching together reverse proxies, DNS records, and SSL certs. I moved three Node.js and Postgres projects off overpriced managed platforms onto a basic Hostinger VPS using this exact Docker Compose stack. If you’ve already checked out our guide to deploy full-stack web apps on Hostinger VPS, this setup takes things further with isolated multi-container networking.

Provision and Secure the Hostinger VPS
Spin up an Ubuntu 24.04 LTS (or 22.04 LTS) instance in your Hostinger hPanel. Once it’s running, grab the server’s public IPv4 address from your dashboard.
SSH in as root to handle initial setup:
ssh root@YOUR_SERVER_IP Never run your application containers under the root account. We’ll set up a dedicated deploy user with sudo privileges, patch the system, and lock down ports with UFW so only SSH, HTTP, and HTTPS are open:
# Update system packages
apt update && apt upgrade -y # Create deploy user and grant sudo permissions
adduser --gecos "" deploy
usermod -aG sudo deploy # Copy root SSH keys to deploy user
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys # Configure firewall
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable Drop your root session and reconnect as your new user with ssh deploy@YOUR_SERVER_IP before moving forward.
Configure Namecheap DNS A Records
Before Certbot can issue certificates, your domain must resolve to your VPS. In Namecheap, go to Domain List, hit Manage next to your domain, and switch to the Advanced DNS tab.
Add these two records under Host Records:
- A Record: Host
@, ValueYOUR_SERVER_IP, TTLAutomatic(or1 minwhile configuring) - A Record: Host
api(orwww), ValueYOUR_SERVER_IP, TTLAutomatic
If you’re unsure whether an A Record or CNAME makes sense for your apex domain, check our walkthrough on how to point root domain in Namecheap DNS. Splitting traffic across different boxes? See how to route subdomains to different servers in Namecheap DNS.
Confirm the records have propagated locally with dig or nslookup:
dig +short example.com
dig +short api.example.comBoth queries must return your Hostinger VPS IP. If they return old records or nothing at all, wait a few minutes before trying to generate SSL certificates.
Install Docker Engine and Docker Compose
Don’t install Docker from default Ubuntu repos—those packages lag behind. Pull the latest Docker Engine and Compose plugin straight from the official Docker Ubuntu repository.
Run the official setup script and repo configuration:
# Install prerequisite packages
sudo apt install -y ca-certificates curl gnupg # Add Docker GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg # Add Docker APT repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker packages
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # Allow non-root deploy user to run docker commands
sudo usermod -aG docker $USER Log out and back in so the docker group membership takes effect, then verify the install:
docker --version
docker compose versionYou should see Docker 26.x+ and Compose v2.27+.
Structure the Application and Compose Stack
We’ll set up a production-ready stack with three containers: our Node.js app, PostgreSQL 16, and an Nginx reverse proxy tied to Certbot.
Create the app folder under /var/www/app:
sudo mkdir -p /var/www/app/nginx
sudo mkdir -p /var/www/app/certbot/conf
sudo mkdir -p /var/www/app/certbot/www
sudo chown -R $USER:$USER /var/www/app
cd /var/www/app Add your environment config file .env in /var/www/app:
DOMAIN_NAME=example.com
API_DOMAIN=api.example.com
POSTGRES_DB=production_db
POSTGRES_USER=app_user
POSTGRES_PASSWORD=SuperSecretDatabasePassword123!
NODE_ENV=production
PORT=3000 Next, write the docker-compose.yml file:
services: app: image: node:20-alpine restart: unless-stopped working_dir: /usr/src/app environment: NODE_ENV: ${NODE_ENV} DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} PORT: ${PORT} volumes: - ./src:/usr/src/app command: ["node", "server.js"] networks: - internal_net depends_on: - db db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - db_data:/var/lib/postgresql/data networks: - internal_net nginx: image: nginx:alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./certbot/conf:/etc/letsencrypt:ro - ./certbot/www:/var/www/certbot:ro networks: - internal_net depends_on: - app certbot: image: certbot/certbot volumes: - ./certbot/conf:/etc/letsencrypt:rw - ./certbot/www:/var/www/certbot:rw volumes: db_data: networks: internal_net: driver: bridge Notice that Postgres port 5432 and Node port 3000 aren’t published to the host. They talk across the private bridge network internal_net. Only Nginx opens ports 80 and 443 to the world.
Create a quick dummy app in src/server.js so we have something to test:
const http = require('http'); const port = process.env.PORT || 3000; const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'online', timestamp: new Date().toISOString(), host: req.headers.host }));
}); server.listen(port, () => { console.log(`Server running on port ${port}`);
});Bootstrap SSL Certificates with Certbot
There’s a classic chicken-and-egg problem here: Nginx won’t start if it’s configured for SSL certificates that don’t exist yet, but Certbot needs a running web server to solve the HTTP-01 challenge. We fix this by starting Nginx with a temporary HTTP-only config first.
Create nginx/default.conf with this bootstrap setup, following the Nginx HTTP server documentation:
server { listen 80; server_name example.com api.example.com; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 200 'Bootstrapping SSL certificate...'; add_header Content-Type text/plain; }
}Spin up the Nginx container:
docker compose up -d nginxNow issue your cert via the Certbot container per the Certbot documentation:
docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot -d example.com -d api.example.com --email your-email@example.com --agree-tos --no-eff-email Certbot should confirm that your certificate chain was saved to /etc/letsencrypt/live/example.com/fullchain.pem.
Finalize Nginx Configuration with HTTPS and Reverse Proxy
Now that the cert files are sitting in ./certbot/conf/live/example.com/ on the host, we can replace the bootstrap config with our real reverse proxy setup: HTTP-to-HTTPS redirect, modern TLS settings, and proxy headers for Node.js.
Update nginx/default.conf:
server { listen 80; server_name example.com api.example.com; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; }
} server { listen 443 ssl http2; server_name example.com api.example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; location / { proxy_pass http://app: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 https; proxy_cache_bypass $http_upgrade; }
}Launch the entire stack in the background:
docker compose down
docker compose up -dVerify everything works with curl:
curl -I https://example.com You should get an HTTP/2 200 back with your app’s payload.
Automate Let’s Encrypt Certificate Renewals
Let’s Encrypt certificates expire every 90 days. Since Certbot is containerized, a simple host-level cron job can trigger renewal and reload Nginx without dropping connections.
Create /var/www/app/renew-certs.sh:
#!/usr/bin/env bash
cd /var/www/app
/usr/bin/docker compose run --rm certbot renew --webroot --webroot-path /var/www/certbot
/usr/bin/docker compose exec nginx nginx -s reloadMake it executable:
chmod +x /var/www/app/renew-certs.shSchedule it in cron to run every Monday at 3:30 AM:
(crontab -l 2>/dev/null;
echo "30 3 * * 1 /var/www/app/renew-certs.sh >> /var/log/certbot-renew.log 2>&1") | crontab - Run /var/www/app/renew-certs.sh manually once to verify it completes cleanly. The Nginx reload should take less than half a second.
Gotchas I Hit When Shipping Docker Stacks
A few real-world edge cases to watch out for on a fresh VPS:
- Docker bypasses UFW by default: Docker writes directly to
iptables. If you publish a port like0.0.0.0:3000:3000or5432:5432, UFW will not block external traffic to it. Keep internal databases and app servers off the host network entirely and route everything through Nginx over Docker networks. - Nginx crashing on reboot: If the VPS reboots and Nginx comes up before the Certbot directories are accessible, it crashes immediately. Using
restart: unless-stoppedand mounting persistent host paths (like./certbot/conf) keeps it resilient. - Uncapped Docker logs eating your disk: Docker containers will eventually fill your SSD with
json-filelogs unless you set rotation limits in daemon config.
To cap log sizes across every container on your Hostinger VPS, add this to /etc/docker/daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" }
}Restart Docker to apply the log limits:
sudo systemctl restart dockerFrequently Asked Questions
Can I run multiple Docker Compose apps on the same Hostinger VPS?
Yes. Run a single shared Nginx reverse proxy container listening on ports 80 and 443 that connects to separate Compose project networks, or use Traefik to automatically route traffic using container labels.
Why is Certbot failing with an ACME challenge 404 error?
This happens when Nginx serves the /.well-known/acme-challenge/ route from a different directory than where Certbot writes the challenge token. Ensure both containers share the exact same ./certbot/www:/var/www/certbot mount.
How do I update my application code without downtime?
Rebuild the image and recreate only the app container: docker compose build app && docker compose up -d --no-deps app. Nginx stays up and routes incoming requests during the swap.
Do I need to buy an SSL certificate from Namecheap?
No. Let’s Encrypt certs are free and renew automatically with Certbot. If you already bought a custom certificate from Namecheap, follow our guide on how to deploy Node.js on Hostinger VPS with Nginx and CDN.
Next Steps for Your Production Stack
Your multi-container stack is live behind Nginx on your Hostinger VPS, wired up to Namecheap DNS with automatic Let’s Encrypt renewals.
To put edge caching and DDoS protection in front of your server, read our guide on how to deploy full-stack web apps on Hostinger VPS with Namecheap DNS and CDN.

