Project: Deploy a Website on a VPS

Linux · Lesson 32 of 32 11 min read Updated

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.html works.

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.

Terminal
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-server

From 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:

Terminal
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 enable

Add a shortcut on your laptop in ~/.ssh/config, so later commands stay short:

Text
Host shop
    HostName 203.0.113.10
    User priya

From 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:

Terminal
sudo apt install nginx
systemctl is-active nginx
sudo ufw allow 'Nginx Full'
curl -sI http://localhost | head -n 1
Output
active
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:

Terminal
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/shop
Output
drwxr-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:

Text
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:

Terminal
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 nginx
Output
nginx: 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:

Terminal
rsync -avz --exclude '.git' --exclude '*.env' ~/projects/shop-site/ shop:/var/www/shop/
Output
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:

Terminal
dig +short shop.example.in
dig +short www.shop.example.in
curl -sI http://shop.example.in | head -n 1
Output
203.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:

Terminal
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.in
Output
Saving 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:

Terminal
systemctl list-timers | grep certbot
sudo certbot renew --dry-run

The 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:

Terminal
#!/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
fi
Terminal
deploy-shop.sh
Output
Preview 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 with awk and sort | uniq -c.
  • Find errors: sudo tail -n 50 /var/log/nginx/shop.error.log and journalctl -u nginx -p err.
  • Back up: run the backup.sh script from Functions, Arguments & Exit Codes from a systemd timer, archiving /var/www/shop and /etc/nginx, and copy the archives off the server.
  • Stay updated: unattended-upgrades installs security fixes; reboot when /var/run/reboot-required appears.
  • Hide the version: add server_tokens off; inside the http block of /etc/nginx/nginx.conf so error pages do not reveal the exact nginx version.

Final checklist

CheckCommandExpected
Root and password SSH disabledsudo sshd -T | grep -E 'permitroot|passwordauth'both no
Firewall activesudo ufw statusSSH and Nginx Full only
nginx healthy and enabledsystemctl is-active nginx; systemctl is-enabled nginxactive, enabled
HTTP redirects to HTTPScurl -sI http://shop.example.in | head -n 1HTTP/1.1 301 Moved Permanently
HTTPS workscurl -sI https://shop.example.in | head -n 1HTTP/2 200
Renewal workssudo certbot renew --dry-runsuccess 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 in sites-enabled; always nginx -t before 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:

Text
error_page 404 /404.html;
Terminal
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>'
Output
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?

2. Which command should you always run before reloading nginx?

3. How is an nginx site enabled on Ubuntu?

4. Why should /var/www/shop not be writable by www-data?

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