Self-Hosted Web Server on VPS With Multi-Domain Support

Stop relying on rigid shared hosting. If you have a VPS, you have the power to host unlimited websites, applications, and subdomains from a single environment. The challenge isn’t resources; it’s architecture.

This guide provides a production-ready blueprint for setting up a web server container that serves multiple domains with full PHP support, seamlessly integrated with Nginx Proxy Manager (NPM).

Why Multi-Site Hosting on a VPS? Running a separate container for every static site or simple PHP app waste resources. By decoupling the web server (Nginx) and the PHP processor (PHP-FPM) into a shared stack, you create a lightweight, scalable foundation. You can add new sites in seconds by dropping in a config file, rather than spinning up new containers.

Prerequisites

Before we build the stack, ensure your environment is ready:

  • You have a Hostinger (or similar) VPS running Ubuntu.
  • Docker and Docker Compose are installed.
  • Nginx Proxy Manager (NPM) is running in a Docker container.
  • Your NPM container is connected to a Docker network named web.

Step 1: Create the Project Directory Structure

We need a dedicated home for your configurations and website data. This structure separates your server logic (conf.d) from your public content (www), making backups and updates effortless.

Run the following to set up the directory tree:

# Create the main project directory
mkdir ~/webserver
cd ~/webserver
# Create subdirectories for Nginx configuration and website files
mkdir -p conf.d www

Your structure will look like this:

webserver/
├── conf.d/          # For individual website configuration files
└── www/             # For all your website/public files

Step 2: Create the docker-compose.yml File

This file defines the relationship between the Nginx web server and the PHP-FPM processor. They share volumes so Nginx can serve static files and PHP-FPM can process scripts.

Create the file:

nano ~/webserver/docker-compose.yml

Paste the following configuration:


services:
  nginx-webserver:
    image: nginx:alpine
    container_name: nginx-webserver
    restart: unless-stopped
    volumes:
      - ./www:/usr/share/nginx/html
      - ./conf.d:/etc/nginx/conf.d
    networks:
      - web
    depends_on:
      - php-fpm

  php-fpm:
    image: php:fpm-alpine
    container_name: php-fpm
    restart: unless-stopped
    volumes:
      - ./www:/usr/share/nginx/html
    networks:
      - web

networks:
  web:
    external: true

Save and exit (Ctrl+X, then Y, then Enter).

Step 3: Launch the Containers

With the definition in place, bring the stack online.

cd ~/webserver
docker-compose up -d

This command pulls the Alpine Linux images for Nginx and PHP and starts them on your external web network.

Step 4: Add Your First Website

We will use example.com as our test case. The workflow involves creating the content, defining the server block, and connecting it to the outside world via Nginx Proxy Manager.

A. Create the Website Directory and Files

Create a specific folder for the domain to keep assets isolated:

mkdir -p ~/webserver/www/example.com

Create a sample index file to verify functionality:

nano ~/webserver/www/example.com/index.html

Paste this content:

<!DOCTYPE html>
<html>
<head>
    <title>Welcome to example.com!</title>
</head>
<body>
    <h1>Success! The example.com site is working.</h1>
</body>
</html>

Save and exit.

B. Create the Nginx Configuration File

Now, tell Nginx how to handle this specific domain. The filename must end with .conf.

nano ~/webserver/conf.d/example.com.conf

Paste the following block. Note the fastcgi_pass directive, which offloads PHP processing to the sibling container.

server {
    listen 80;
    server_name example.com [www.example.com](https://www.example.com);

    root /usr/share/nginx/html/example.com;
    index index.html index.php;

    # --- PHP HANDLING BLOCK ---
    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass php-fpm:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }

    location / {
        try_files $uri $uri/ =404;
    }
}

Save and exit.

C. Reload Nginx Configuration

Apply the changes instantly without bringing the server down:

docker exec nginx-webserver nginx -s reload

D. Configure the Proxy Host in Nginx Proxy Manager

Your internal server is ready. Now you must route traffic from the internet to it.

  1. Log in to your NPM dashboard.
  2. Go to Hosts -> Proxy Hosts.
  3. Click Add Proxy Host.
  4. Details Tab:
    • Domain Names: example.com (and www.example.com on a new line).
    • Scheme: http
    • Forward Hostname / IP: nginx-webserver
    • Forward Port: 80
  5. SSL Tab:
    • SSL Certificate: Request a new SSL Certificate
    • Enable Force SSL, HTTP/2 Support, and HSTS.
    • Agree to the terms and enter your email.
  6. Click Save.

Your site https://example.com is now live and secured.

How to Add More Websites

Scalability is the main benefit of this setup. To add app.example.com or a completely different domain, simply repeat Step 4:

  1. mkdir -p ~/webserver/www/app.example.com
  2. Create your index.html or index.php.
  3. nano ~/webserver/conf.d/app.example.com.conf (Paste the template, updating server_name and root).
  4. docker exec nginx-webserver nginx -s reload
  5. Add the new Proxy Host in NPM.

The Default Server Block: Why It Matters

You might wonder if a “default” server block is strictly necessary. While your sites will function without it, omitting it is a security risk.

Without a default block, Nginx routes requests for unknown domains or direct IP access to the first server block it loads. This leads to unpredictable behavior where a stranger accessing your IP might see your internal admin portal or a client’s website.

We recommend two approaches to handle stray traffic.

Option 1: Drop the Connection (Recommended)

This approach immediately closes the connection for any request that doesn’t match your configured domains. It is stealthy and resource-efficient.

  1. Edit default.conf:nano ~/webserver/conf.d/default.conf
  2. Paste this content:server { listen 80 default_server; server_name _; # Drop the connection without responding return 444; }
  3. Reload Nginx:docker exec nginx-webserver nginx -s reload

Option 2: Serve a Custom Placeholder Page

If you prefer to be polite, you can serve a generic 404 page.

  1. Create the default directory and file:mkdir -p ~/webserver/www/default nano ~/webserver/www/default/index.html
  2. Paste your HTML content:<!DOCTYPE html> <html> <head><title>Website Not Found</title></head> <body> <h1>Oops! Website Not Found</h1> <p>The website you requested is not configured on this server.</p> </body> </html>
  3. Edit default.conf to point to this file:server { listen 80 default_server; server_name _; root /usr/share/nginx/html/default; index index.html; location / { # This will always serve your custom index.html } }
  4. Reload Nginx.

Optional: Set File Permissions

If you encounter “403 Forbidden” or “404 Not Found” errors immediately after setup, it is often due to permission mismatches between your user and the web server container.

Run these commands from the ~/webserver directory to normalize permissions:

# Replace 'your_user' with your actual username (run 'whoami' to find it)
sudo chown -R your_user:www-data www/

# Set permissions: directories get 755, files get 644
sudo find www/ -type d -exec chmod 755 {} \;
sudo find www/ -type f -exec chmod 644 {} \;

You now have a robust, multi-tenant hosting environment running entirely on your own VPS.

Leave a Reply

Your email address will not be published. Required fields are marked *