Project: Deploy a Website on a VPS
In this project you will take a small static website from your laptop and publish it on the internet, on a server you control, with a domain name and HTTPS. Almost every lesson in this course appears along the way: SSH keys, users and sudo, packages, permissions, the firewall, systemd, logs, rsync and a Bash script.
The site belongs to Priya's family business, Sharma Sweets in Jaipur. Her laptop has the site in ~/projects/shop-site/ (an index.html, a style.css and an images folder). By the end, visitors will open https://shop.example.in and see it, and Priya will update it with one command. Replace the example IP address 203.0.113.10 and domain shop.example.in with your own throughout.
What you need
- An Ubuntu 26.04 LTS VPS from any cloud or hosting provider (the smallest plan is enough), with your SSH public key added when you create it.
- A domain name, or a subdomain of one you own, where you can edit DNS records.
- A small static website. Any folder with an
index.htmlworks.
If you do not have a domain yet, you can still do every step except HTTPS by visiting the server's IP address.
Step 1: log in and harden the server
Connect as root with your key, then follow the checklist from Basic Server Hardening: update everything, create your own sudo user, and copy your key to it.
ssh root@203.0.113.10
apt update && apt full-upgrade -y
adduser priya
usermod -aG sudo priya
rsync --archive --chown=priya:priya /root/.ssh /home/priya
hostnamectl set-hostname shop-serverFrom a second terminal on your laptop, confirm ssh priya@203.0.113.10 works and that sudo whoami prints root. Then, as priya, switch off root and password logins and turn on the firewall, allowing SSH before enabling it:
printf 'PermitRootLogin no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n' | sudo tee /etc/ssh/sshd_config.d/01-hardening.conf
sudo sshd -t && sudo systemctl reload ssh
sudo ufw allow OpenSSH
sudo ufw enableAdd a shortcut on your laptop in ~/.ssh/config, so later commands stay short:
Host shop
HostName 203.0.113.10
User priyaFrom now on ssh shop logs you in. Install fail2ban too if you like (sudo apt install fail2ban); Ubuntu's package protects SSH out of the box.
Step 2: install nginx
nginx (pronounced "engine-x") is a fast, widely used web server. Install it from Ubuntu's repository; the package starts the service and enables it at boot:
sudo apt install nginx
systemctl is-active nginx
sudo ufw allow 'Nginx Full'
curl -sI http://localhost | head -n 1active Rules updated Rules updated (v6) HTTP/1.1 200 OK
Nginx Full opens ports 80 (HTTP) and 443 (HTTPS). Open http://203.0.113.10 in your browser and you should see the "Welcome to nginx!" page. If you do not, check the firewall with sudo ufw status and your provider's network rules, as you learned in Networking Commands.
Step 3: create the site folder
Websites conventionally live under /var/www. Create a folder for this site and make Priya its owner, so she can upload files without sudo. nginx runs its worker processes as the user www-data, which only needs to read the files:
sudo mkdir -p /var/www/shop
sudo chown -R priya:www-data /var/www/shop
sudo chmod 2755 /var/www/shop
ls -ld /var/www/shopdrwxr-sr-x 2 priya www-data 4096 Oct 30 12:10 /var/www/shop
2755 is 755 plus the SGID bit (the s in the group position): every new file inside inherits the www-data group. Others get read access, which nginx needs, and nobody but Priya can change anything. Never make a web folder writable by www-data unless the site really needs to save uploads, and then only for that one sub-folder.
Step 4: configure an nginx server block
nginx can host many sites on one server. Each has a server block in /etc/nginx/sites-available/, and is switched on by a symbolic link in /etc/nginx/sites-enabled/. Create /etc/nginx/sites-available/shop:
server {
listen 80;
listen [::]:80;
server_name shop.example.in www.shop.example.in;
root /var/www/shop;
index index.html;
access_log /var/log/nginx/shop.access.log;
error_log /var/log/nginx/shop.error.log;
location / {
try_files $uri $uri/ =404;
}
}listen sets the ports for IPv4 and IPv6, server_name lists the names this block answers to, and root is the folder files are served from. try_files serves the requested file or folder if it exists and returns a 404 error otherwise. Separate log files per site make troubleshooting easier. Now enable the site, disable the default welcome page, test and reload:
sudo ln -s /etc/nginx/sites-available/shop /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginxnginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful
Removing the link in sites-enabled disables the default site but keeps its file in sites-available. Always test with nginx -t before reloading; a typo then never takes the site down.
Step 5: upload the website with rsync
On your laptop, push the site's contents to the server. The trailing slash on the source means "the contents of this folder", and the --exclude options keep private files off the server:
rsync -avz --exclude '.git' --exclude '*.env' ~/projects/shop-site/ shop:/var/www/shop/sending incremental file list ./ images/ index.html style.css images/logo.png sent 44,012 bytes received 96 bytes 29,405.33 bytes/sec total size is 43,809 speedup is 0.99
Visit http://203.0.113.10 again: you should now see Priya's shop page. If you see "403 Forbidden", nginx cannot read the files or there is no index.html; check permissions with ls -l /var/www/shop and read the error log with sudo tail -n 20 /var/log/nginx/shop.error.log.
Step 6: point the domain at the server
At your domain registrar or DNS provider, create two A records: shop.example.in and www.shop.example.in, both pointing to 203.0.113.10 (add AAAA records too if your server has IPv6). Then check from your laptop:
dig +short shop.example.in
dig +short www.shop.example.in
curl -sI http://shop.example.in | head -n 1203.0.113.10 203.0.113.10 HTTP/1.1 200 OK
DNS changes can take from a few minutes to a few hours to appear everywhere, depending on the TTL. Do not continue to HTTPS until both names resolve to your server, because the certificate check depends on it.
Step 7: free HTTPS with Let's Encrypt
HTTPS encrypts traffic between visitors and your server, and browsers mark plain HTTP sites as "Not secure". Let's Encrypt is a free certificate authority, and certbot is the tool that requests certificates from it and configures nginx. The certbot project recommends installing it as a snap:
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
sudo certbot --nginx -d shop.example.in -d www.shop.example.inSaving debug log to /var/log/letsencrypt/letsencrypt.log Requesting a certificate for shop.example.in and www.shop.example.in Successfully received certificate. Certificate is saved at: /etc/letsencrypt/live/shop.example.in/fullchain.pem Key is saved at: /etc/letsencrypt/live/shop.example.in/privkey.pem This certificate expires on 2027-01-28. These files will be updated when the certificate renews. Certbot has set up a scheduled task to automatically renew this certificate in the background. Deploying certificate Successfully deployed certificate for shop.example.in to /etc/nginx/sites-enabled/shop Successfully deployed certificate for www.shop.example.in to /etc/nginx/sites-enabled/shop Congratulations! You have successfully enabled HTTPS on https://shop.example.in and https://www.shop.example.in
The first run asks for an email address and agreement to the terms. certbot proves you control the domain by answering a challenge over port 80, which is why DNS and the firewall had to be ready. It then edits your server block to listen on 443 with the new certificate and to redirect HTTP to HTTPS. Let's Encrypt certificates are short-lived (90 days or less), so renewal must be automatic; the snap installs a systemd timer for it. Check both:
systemctl list-timers | grep certbot
sudo certbot renew --dry-runThe dry run simulates a renewal without changing anything. If it succeeds, renewals will work.
Step 8: a one-command deploy script
Typing the rsync command every time invites mistakes. Save this on your laptop as ~/bin/deploy-shop.sh and make it executable with chmod +x:
#!/usr/bin/env bash
# deploy-shop.sh: publish the Sharma Sweets site and check that it is live
set -euo pipefail
readonly SERVER="shop"
readonly SRC="$HOME/projects/shop-site/"
readonly DEST="/var/www/shop/"
readonly URL="https://shop.example.in/"
[[ -f ${SRC}index.html ]] || { echo "Error: no index.html in $SRC" >&2; exit 1; }
echo "Preview of changes:"
rsync -avzn --delete --exclude '.git' --exclude '*.env' "$SRC" "$SERVER:$DEST"
read -r -p "Deploy these changes? [y/N] " answer
[[ $answer == [yY] ]] || { echo "Cancelled."; exit 0; }
rsync -avz --delete --exclude '.git' --exclude '*.env' "$SRC" "$SERVER:$DEST"
code=$(curl -s -o /dev/null -w '%{http_code}' "$URL")
if [[ $code == 200 ]]; then
echo "Deployed. $URL answered with HTTP $code."
else
echo "Warning: $URL answered with HTTP $code" >&2
exit 1
fideploy-shop.shPreview of changes: sending incremental file list index.html sent 251 bytes received 22 bytes 546.00 bytes/sec total size is 44,016 speedup is 161.23 (DRY RUN) Deploy these changes? [y/N] y sending incremental file list index.html sent 1,982 bytes received 41 bytes 1,348.67 bytes/sec total size is 44,016 speedup is 21.76 Deployed. https://shop.example.in/ answered with HTTP 200.
The script refuses to run without an index.html, shows a dry run with --delete so you can spot unexpected deletions, asks before changing anything, and finally checks that the live site answers. Static files need no nginx reload: the next request simply gets the new file.
Step 9: watch, back up and maintain
A live site needs a little ongoing care. Each of these uses a lesson you already know:
- Watch traffic:
sudo tail -f /var/log/nginx/shop.access.log, and count visitors per IP withawkandsort | uniq -c. - Find errors:
sudo tail -n 50 /var/log/nginx/shop.error.logandjournalctl -u nginx -p err. - Back up: run the
backup.shscript from Functions, Arguments & Exit Codes from a systemd timer, archiving/var/www/shopand/etc/nginx, and copy the archives off the server. - Stay updated: unattended-upgrades installs security fixes; reboot when
/var/run/reboot-requiredappears. - Hide the version: add
server_tokens off;inside thehttpblock of/etc/nginx/nginx.confso error pages do not reveal the exact nginx version.
Final checklist
| Check | Command | Expected |
|---|---|---|
| Root and password SSH disabled | sudo sshd -T | grep -E 'permitroot|passwordauth' | both no |
| Firewall active | sudo ufw status | SSH and Nginx Full only |
| nginx healthy and enabled | systemctl is-active nginx; systemctl is-enabled nginx | active, enabled |
| HTTP redirects to HTTPS | curl -sI http://shop.example.in | head -n 1 | HTTP/1.1 301 Moved Permanently |
| HTTPS works | curl -sI https://shop.example.in | head -n 1 | HTTP/2 200 |
| Renewal works | sudo certbot renew --dry-run | success message |
Ideas to extend the project
- Add a second site on the same server with its own server block and certificate.
- Serve a small Python or Node.js API behind nginx as a reverse proxy, running it as a systemd service like the one in Services with systemd & systemctl.
- Store the site in a Git repository and deploy automatically when you push.
Key points
- Harden first: sudo user, key-only SSH, firewall, automatic updates.
- nginx serves each site from a server block in
sites-available, enabled by a link insites-enabled; alwaysnginx -tbefore reloading. - Web files are owned by the deploying user, readable (not writable) by
www-data. - DNS A records must point to the server before certbot can issue a Let's Encrypt certificate; renewal runs automatically from a timer.
- A short deploy script with
set -euo pipefail, an rsync dry run and a live check makes updates safe and repeatable.
Exercise
Extend the deployment: add a custom 404 page. Create 404.html in your site, upload it, and configure nginx so that missing pages show it with the correct 404 status code. Then test with curl that a missing page returns 404 and that the body is your page.
Show solution
Add 404.html to ~/projects/shop-site/, deploy it with deploy-shop.sh, then add one line inside the server block that listens on 443 in /etc/nginx/sites-available/shop:
error_page 404 /404.html;sudo nginx -t && sudo systemctl reload nginx
curl -sI https://shop.example.in/no-such-page | head -n 1
curl -s https://shop.example.in/no-such-page | grep -o '<title>.*</title>'HTTP/2 404 <title>Page not found | Sharma Sweets</title>
error_page tells nginx which file to serve for a given error code, while still sending the 404 status, so browsers and search engines know the page does not exist. Testing with curl -sI checks the status code and the second curl checks the content.
Quick Quiz
1. Why must DNS point to the server before running certbot?
The domain validation challenge reaches your server through the domain name.
2. Which command should you always run before reloading nginx?
nginx -t tests the configuration, so a mistake never takes the site down.
3. How is an nginx site enabled on Ubuntu?
nginx reads the server blocks linked in sites-enabled.
4. Why should /var/www/shop not be writable by www-data?
Least privilege: the web server only needs to read static files.
Learn Linux with a mentor
Free lessons get you started. Our Ethical Hacking & Cyber Security training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
