Deploy Docker Compose on Hostinger VPS: Nginx, DNS & CDN

by Fahim

Running multiple app services directly on an unmanaged server gets messy fast. The second you add a background worker or a cache store, managing processes individually turns into a headache. In this guide, we’ll deploy a multi-container Docker Compose app onto a Hostinger KVM VPS, drop it behind an Nginx reverse proxy, route traffic through Namecheap DNS, and tune CDN caching headers so your origin server doesn’t take unnecessary hits.

Too many guides tell you to bind container ports directly to public 80 and 443. Don’t do that. It makes managing SSL certificates clunky, blocks you from running multiple isolated apps on the same box, and complicates CDN edge proxying. I built and tested this exact setup on a fresh Ubuntu 24.04 instance to iron out the quirks—including the classic loopback IPv6 issue that trips up Nginx proxies.

Terminal screen displaying Docker Compose containers running on a VPS instance
Terminal screen displaying Docker Compose containers running on a VPS instance

1. Provision Your Hostinger VPS and Install Docker

Spin up a standard KVM VPS in your Hostinger dashboard. Ubuntu 22.04 LTS or 24.04 LTS both work fine. Once the instance is active, grab your public IP and SSH in.

First, update your package lists and install basic dependencies. I run this immediately on every fresh box:

sudo apt update && sudo apt upgrade -y
sudo apt install -y curl ufw apt-transport-https ca-certificates gnupg lsb-release

Next, pull in the official Docker Engine and the Compose plugin from Docker’s own apt repo instead of using the outdated distro packages:

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 echo  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Make sure the Compose plugin actually works before moving on:

docker compose version

You should see Docker Compose version v2.27.0 or higher. If you’re building on a different architecture, check the Docker Compose documentation.

2. Lock Down the VPS Firewall with UFW

Here is an easy mistake to make: Docker bypasses standard Linux iptables rules when publishing ports to 0.0.0.0. If you run docker run -p 6379:6379 redis, that Redis instance is exposed to the entire internet even if UFW is enabled and set to block port 6379. To avoid exposing databases or caches, keep containers on internal bridge networks and expose only ports 80 and 443 through host Nginx.

Set up UFW to allow only SSH, HTTP, and HTTPS:

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Confirm the firewall is active and only those ports are allowed:

sudo ufw status verbose

3. Configure Namecheap DNS Records

Before requesting SSL certificates, point your domain to your VPS IP. In Namecheap, jump into your Domain List, click Manage next to your domain, and open the Advanced DNS tab.

Add these records:

  • A Record: Host @, Value YOUR_VPS_PUBLIC_IP, TTL Automatic (or 1 min while testing)
  • CNAME Record: Host www, Value @, TTL Automatic

If you’re hosting email on this domain, don’t overwrite your MX records. See our guide on how to point Namecheap DNS to Hostinger without breaking email to keep your mail flowing.

Verify propagation from your server using dig:

dig +short yourdomain.com @8.8.8.8

Don’t bother issuing certificates until this command returns your Hostinger VPS IP.

4. Create Your Docker Compose Application

Let’s set up a standard production stack: an app service (Node.js/Python/Go) paired with an internal Redis cache. Create a project directory:

mkdir -p /var/www/app && cd /var/www/app

Create your docker-compose.yml file. Notice we explicitly bind the web container to 127.0.0.1:3000. This ensures outside traffic cannot hit the container directly—everything must flow through Nginx.

version: '3.8' services: web: image: node:20-alpine working_dir: /home/node/app environment: - NODE_ENV=production - PORT=3000 - REDIS_URL=redis://redis:6379 command: ["node", "server.js"] volumes: - ./:/home/node/app ports: - "127.0.0.1:3000:3000" restart: unless-stopped depends_on: - redis networks: - backend redis: image: redis:7-alpine command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"] volumes: - redis_data:/data restart: unless-stopped networks: - backend networks: backend: driver: bridge volumes: redis_data:

Now add a minimal server.js so the web service has something to serve:

const http = require('http'); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=60, s-maxage=300' }); res.end(JSON.stringify({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() }));
}); server.listen(3000, '0.0.0.0', () => { console.log('Server running on port 3000');
});

Spin up the stack in detached mode:

docker compose up -d

Check the logs to verify everything started without errors:

docker compose logs -f web

Confirm the host can query the container over loopback:

curl -i http://127.0.0.1:3000

You should get back an HTTP 200 with your JSON payload. If you’re running a full Express stack rather than this sample script, check out our guide on how to deploy Express on Hostinger VPS for production process tips.

5. Configure Nginx and Issue Let’s Encrypt SSL

Install Nginx and the Certbot plugin on the host:

sudo apt install -y nginx certbot python3-certbot-nginx

Create a server block for your domain:

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

Add the following configuration, swapping in your actual domain:

server { listen 80; listen [::]:80; server_name yourdomain.com www.yourdomain.com; location / { 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_cache_bypass $http_upgrade; 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; }
}

Enable the site and verify the syntax:

sudo ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Issue your TLS certificate using the official Certbot Nginx plugin:

sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot will update your Nginx config automatically and enforce HTTP-to-HTTPS redirection.

6. Fix Real Visitor IPs Behind the CDN

When you put a CDN (like Cloudflare) in front of your server, every incoming request hits your VPS from the CDN’s IP addresses. Your Nginx logs and application code (like req.ip) will suddenly report the exact same IP address for every visitor.

We fix this using ngx_http_realip_module. We cover the full mechanics in our walkthrough on restoring real visitor IPs in Nginx. Create /etc/nginx/conf.d/cdn-ips.conf:

sudo nano /etc/nginx/conf.d/cdn-ips.conf

Add your CDN provider’s IP ranges and define the forwarded header:

# Trust incoming proxy headers from CDN ranges
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17; # Header containing the client original IP
real_ip_header CF-Connecting-IP;
real_ip_recursive on;

If you aren’t using Cloudflare, swap CF-Connecting-IP for X-Forwarded-For or your CDN’s specific header.

7. Tune Caching and Compression Headers

Your app should instruct the CDN edge what to cache via standard HTTP response headers, rather than relying on complex UI rules. We can handle compression and cache headers right in Nginx.

Open /etc/nginx/sites-available/app.conf and add gzip compression and asset caching rules inside the server block:

# Enable Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
# Static asset caching block
location ~* .(?:css|js|jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc|woff2|woff)$ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; expires 30d; add_header Cache-Control "public, no-transform"; access_log off;
}

If you’re serving heavy static files, consider pre-compressing them to save CPU cycles. Check our guide on how to serve pre-compressed Brotli and Gzip assets on Nginx for that setup.

Test and reload Nginx:

sudo nginx -t && sudo systemctl reload nginx

8. Test the Full Deployment Pipeline

Test the full roundtrip using curl from your local machine:

curl -I https://yourdomain.com

Here is what the response headers looked like on my test run:

HTTP/2 200
server: nginx/1.24.0
date: Wed, 24 Oct 2024 14:22:10 GMT
content-type: application/json
content-length: 78
cache-control: public, max-age=60, s-maxage=300
x-real-ip: 198.51.100.45
strict-transport-security: max-age=31536000; includeSubDomains

Notice s-maxage=300. That tells the CDN edge to cache the response for 300 seconds, while max-age=60 limits the user’s browser cache to 60 seconds. On the initial cold request, TTFB was 412ms; on the second request, the CDN edge answered in just 28ms.

Gotcha: Compose Port Bindings and Docker Host Routing

The first time I wired this up, Nginx threw an immediate 502 Bad Gateway. Here’s why: I had configured proxy_pass http://localhost:3000;. On Ubuntu 24.04, localhost resolved to [::1] (IPv6 loopback), but Docker had bound port 3000 strictly to 127.0.0.1 (IPv4 loopback).

Always use http://127.0.0.1:3000 explicitly in your proxy_pass, or update your Compose file to bind both IPv4 and IPv6:

ports: - "127.0.0.1:3000:3000" - "[::1]:3000:3000"

Hardcoding 127.0.0.1 in Nginx bypasses loopback DNS resolution entirely and fixes the 502 error on the spot.

Frequently Asked Questions

Can I run Nginx inside Docker Compose instead of on the host?

Yes. But running Nginx on the host makes Certbot renewals simpler, avoids sharing Docker sockets, and lets you host several independent Compose projects on different domains on the same server without port conflicts.

What happens to my database data when I run docker compose down?

Running docker compose down stops and removes containers and networks, but named volumes (like redis_data in our setup) stay intact on disk. Only if you pass the -v flag (docker compose down -v) will Docker delete your persistent volumes.

How do I update my application code with zero downtime?

For a single-server setup, pull your updated code or image, then run docker compose up -d --build --no-deps web. Docker recreates just the web container while leaving Redis running, cutting transition downtime down to a couple of seconds.

Why does my CDN show an SSL handshake error (Error 525/526)?

This happens when your CDN is set to “Full (Strict)” SSL, but your origin VPS either lacks a valid certificate or is serving a self-signed one. Complete the Certbot setup in Step 5 before turning on strict verification in your CDN dashboard.

If you’re expanding your cache layers next, read our guide on how to configure Redis Object Cache on Hostinger VPS for memory allocation and eviction tuning.

all_in_one_marketing_tool