Project: Host Multiple Sites with Nginx
One of the most common real-world DevOps tasks is hosting more than one website on a single server. You rarely need a separate machine per site — a single small VPS (Virtual Private Server, a rented Linux machine in the cloud) can comfortably serve a dozen low-traffic sites. The trick is Nginx server blocks: small config files that tell Nginx, “when a request arrives for blog.example.com, serve these files; when it arrives for shop.example.com, forward it to this app instead.” In this project you will configure several domains and subdomains on one Ubuntu server, give some of them static files and some a proxied app, and secure every one of them with HTTPS.
What you are building
By the end you will have one Ubuntu 22.04/24.04 LTS server answering for three names:
| Hostname | What it serves | How |
|---|---|---|
example.com | A static HTML site | Files in /var/www/example.com |
blog.example.com | A second static site | Files in /var/www/blog.example.com |
app.example.com | A running Node.js app on port 3000 | Reverse proxy (Nginx forwards to the app) |
A server block (Nginx’s name for what Apache calls a “virtual host”) is just a configuration file describing how to handle requests for one hostname. Nginx reads the Host header that every browser sends and routes the request to the matching block. This is why one IP address can host many sites.
Step 1: Prerequisites
Install Nginx and open the firewall. ufw is Ubuntu’s “Uncomplicated Firewall”, the simple front-end for managing which ports are reachable.
sudo apt update
sudo apt install -y nginx
sudo ufw allow 'Nginx Full'
sudo systemctl enable --now nginx
Output:
Rule added
Rule added (v6)
Synchronizing state of nginx.service with SysV service script...
You also need DNS (Domain Name System, the internet’s phone book that maps names to IP addresses) records. In your domain registrar, create an A record for each name pointing at your server’s public IP:
example.com A 203.0.113.10
blog.example.com A 203.0.113.10
app.example.com A 203.0.113.10
When to use a wildcard instead: if you expect many subdomains, create one wildcard A record
*.example.com → 203.0.113.10so you don’t edit DNS every time. Do NOT use a wildcard if you want each subdomain to resolve to a different server.
Step 2: Create the document roots
The document root is the folder on disk whose files Nginx serves for a site. Make one per static site and drop a test page in each.
sudo mkdir -p /var/www/example.com /var/www/blog.example.com
echo '<h1>Main site</h1>' | sudo tee /var/www/example.com/index.html
echo '<h1>Blog</h1>' | sudo tee /var/www/blog.example.com/index.html
sudo chown -R www-data:www-data /var/www/example.com /var/www/blog.example.com
www-data is the low-privilege user Nginx runs as on Ubuntu. Giving it ownership lets Nginx read the files without running as root.
Step 3: Write the server blocks
On Ubuntu, site configs live in /etc/nginx/sites-available/, and you “switch on” a site by creating a symbolic link (a pointer) to it inside /etc/nginx/sites-enabled/. This two-folder pattern lets you keep a config on disk but disabled.
Static site block
# /etc/nginx/sites-available/example.com
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
server_name is the list of hostnames this block answers for. try_files tells Nginx to look for the requested file, then the directory, then return a 404 if neither exists. Save a near-identical file for the blog, changing server_name to blog.example.com and root to /var/www/blog.example.com.
Reverse-proxy block
A reverse proxy is a server that sits in front of your application and forwards requests to it. Use it when the site is a running program (Node, Python, etc.) rather than plain files.
# /etc/nginx/sites-available/app.example.com
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
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;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
The proxy_set_header lines pass the real client details through to your app, since otherwise the app only sees Nginx’s local address.
Step 4: Enable the sites and reload
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/blog.example.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
nginx -t tests the config before you apply it — always run it first.
Output:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Removing the bundled default site matters: whichever block Nginx finds first becomes the default server for unmatched requests, and the stock default would otherwise shadow yours.
Step 5: Add HTTPS to every site
HTTPS encrypts traffic; you need a TLS certificate per hostname. Certbot automates getting free certificates from Let’s Encrypt and rewrites your Nginx blocks to use them.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com -d blog.example.com -d app.example.com
Certbot edits each server block to listen on 443 (the HTTPS port), adds a redirect from HTTP to HTTPS, and installs a systemd timer that renews certificates automatically. Confirm renewal works with a dry run:
sudo certbot renew --dry-run
Output:
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/example.com/fullchain.pem (success)
Security gotcha: never put real secrets or admin panels behind only an IP address. Each public site is reachable by anyone who knows the hostname, so put authentication on
app.example.comat the app level — Nginx routing is not access control.
Verifying it works
curl -I https://blog.example.com
Output:
HTTP/2 200
server: nginx
content-type: text/html
Check the per-site logs if something is wrong — each block can log separately, and the defaults live in /var/log/nginx/access.log and /var/log/nginx/error.log.
Best practices
- Name each config file after its hostname (
blog.example.com) so the folder is self-documenting. - Run
sudo nginx -tbefore everyreload; a bad config that gets loaded can take down all your sites at once. - Give each site its own
access_loganderror_logpath so traffic and errors are easy to separate. - Keep apps bound to
127.0.0.1(localhost) so they are reachable only through Nginx, never directly from the internet. - Use Certbot’s automatic renewal timer and test it with
--dry-run; an expired certificate breaks the whole site. - Add a wildcard DNS record only when you genuinely host many subdomains, and review enabled sites periodically to remove stale ones.