// Step-by-Step Guide

Host a Static Site on a VPS

From a bare Ubuntu 22 server to a live HTTPS website — beginner friendly, copy-paste ready.

Ubuntu 22 LTS Docker Nginx:alpine Let's Encrypt SSL UFW Firewall Docker Compose

// Configuration

Fill in your values — every code block on this page updates live as you type

// Values also update automatically as you type — tick "+ www" to include www throughout
Login & update the system start here

SSH into your server as root, install all available updates, then create a non-root user for everyday use.

🔒 Never run your server as root long-term. Create a regular user and use that for everything after this step.
bash — from your local machine
ssh root@203.0.113.10
bash — on the server
# Update all system packages
apt update && apt upgrade -y
apt install -y curl git ufw

# Create a non-root user and give it sudo access
adduser yourname
usermod -aG sudo yourname

Log out and reconnect as your new user:

bash — from your local machine
ssh yourname@203.0.113.10
Configure UFW firewall critical order

UFW is Ubuntu's built-in firewall. We block everything by default and only open the ports we actually need.

⚠️ Run the allow OpenSSH rule before enabling UFW. Skipping this step locks you out of your server permanently over SSH.
bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH    # port 22 — add this FIRST
sudo ufw allow 80         # HTTP
sudo ufw allow 443        # HTTPS
sudo ufw enable           # type 'y' when prompted
sudo ufw status           # confirm all rules are listed
Install Docker & Docker Compose

Docker lets us run Nginx and Certbot in isolated containers without interfering with the rest of the system.

bash
# Use Docker's official install script
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# Allow your user to run docker without sudo each time
sudo usermod -aG docker $USER
Log out and back in after the usermod command, otherwise Docker will still require sudo and the next steps will fail.
bash — after logging back in
# Install the Compose plugin and verify both tools work
sudo apt install -y docker-compose-plugin
docker --version
docker compose version
Point your DNS to the server

Before SSL can be issued, your domain must resolve to your server's IP. Log into your domain registrar and add the following A records:

TypeName / HostValue / Points toTTL
A mydomain.com 203.0.113.10 300
A www.mydomain.com 203.0.113.10 300
DNS propagation can take a few minutes to a few hours. Verify with ping mydomain.com and ping www.mydomain.com — when both returnit returns your server IP, you're ready to continue.
Build the folder structure

All site files and configs live under ~/webserver. Nginx mounts these folders directly so changes are live instantly — no container restart needed.

bash
mkdir -p ~/webserver/mysite
mkdir -p ~/webserver/nginx/conf.d
mkdir -p ~/webserver/certbot/conf
mkdir -p ~/webserver/certbot/www

Create a placeholder page to confirm Nginx is working before adding your real content:

bash — single quotes required here
echo '<h1>My site is live</h1>' > ~/webserver/mysite/index.html
⚠️ Use single quotes '...' around the HTML string, not double quotes "...". Bash treats ! inside double quotes as a special history character and will throw an error.

Your final folder structure:

~/webserver/
├── mysite/ ← your HTML files go here
├── nginx/conf.d/ ← nginx server config
├── certbot/conf/ ← SSL certificates (auto-managed)
├── certbot/www/ ← certbot domain verification
└── docker-compose.yml
Create the Nginx config HTTP only for now

We start with HTTP only. Adding SSL blocks before the certificates exist will prevent Nginx from starting at all.

bash — writes the config file directly
cat > ~/webserver/nginx/conf.d/sites.conf << 'EOF'
server {
    listen 80;
    server_name mydomain.com www.mydomain.com;

    # Required path for Let's Encrypt domain verification
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        root /usr/share/nginx/mysite;
        index index.html;
    }
}
EOF
Listing both names in server_name means Nginx handles the .well-known verification path for both domains in one block — required for Certbot to issue a certificate covering both.
Create the Docker Compose file

This defines two containers: Nginx (web server) and Certbot (SSL tool). They share volumes so Certbot can place verification files where Nginx serves them.

bash — writes the file directly
cat > ~/webserver/docker-compose.yml << 'EOF'
services:

  nginx:
    image: nginx:alpine
    container_name: nginx
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./mysite:/usr/share/nginx/mysite
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot

  certbot:
    image: certbot/certbot
    container_name: certbot
    volumes:
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
EOF
Start Nginx & verify HTTP checkpoint

Start Nginx and confirm your domains serve serves the placeholder page before requesting SSL. Always run Docker Compose commands from inside the ~/webserver folder.

bash
cd ~/webserver
docker compose up -d nginx
docker ps   # nginx should show status "Up"
bash — must return HTML before continuing
curl http://mydomain.com
curl http://www.mydomain.com
🛑 Do not continue until the curl commands return your placeholder HTML. If eitherit fails, DNS has not fully propagated yet — wait a few minutes and try again.
Get a free SSL certificate

Certbot contacts Let's Encrypt, places a temporary verification file via the .well-known path Nginx is already serving, and issues your certificate if the check passes.

bash
cd ~/webserver
docker compose run --rm certbot certonly \
  --webroot \
  --webroot-path=/var/www/certbot \
  --email [email protected] \
  --agree-tos \
  --no-eff-email \
  -d mydomain.com
bash — single command, cert covers both names
cd ~/webserver
docker compose run --rm certbot certonly \
  --webroot \
  --webroot-path=/var/www/certbot \
  --email [email protected] \
  --agree-tos \
  --no-eff-email \
  -d mydomain.com \
  -d www.mydomain.com
Both -d flags in a single Certbot command produce one certificate that covers both names. The cert files are stored under the first domain's folder (mydomain.com). This is what makes the www HTTPS redirect in Step 10 work without any SSL errors.

You should see a Congratulations message. Verify the certificate folder was created:

bash
sudo ls ~/webserver/certbot/conf/live/
# Should show a folder named: mydomain.com
Certbot runs inside Docker as root, so its files are owned by root. Always use sudo when listing or deleting them. To start over: sudo rm -rf ~/webserver/certbot
Enable HTTPS in Nginx

Now that the certificate exists, replace the HTTP-only config with the full SSL version.

bash — overwrites sites.conf with SSL version
cat > ~/webserver/nginx/conf.d/sites.conf << 'EOF'
# Redirect all HTTP traffic to HTTPS
server {
    listen 80;
    server_name mydomain.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS — serves your static site
server {
    listen 443 ssl;
    server_name mydomain.com;

    ssl_certificate     /etc/letsencrypt/live/mydomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mydomain.com/privkey.pem;

    location / {
        root /usr/share/nginx/mysite;
        index index.html;
    }
}
EOF
bash — www redirects to bare domain (canonical)
cat > ~/webserver/nginx/conf.d/sites.conf << 'EOF'
# Redirect HTTP → HTTPS for both bare and www
server {
    listen 80;
    server_name mydomain.com www.mydomain.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://mydomain.com$request_uri;
    }
}

# Redirect www HTTPS → bare domain HTTPS
# Safe because the cert from Step 9 covers both names
server {
    listen 443 ssl;
    server_name www.mydomain.com;

    ssl_certificate     /etc/letsencrypt/live/mydomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mydomain.com/privkey.pem;

    return 301 https://mydomain.com$request_uri;
}

# HTTPS — serves your static site (canonical bare domain)
server {
    listen 443 ssl;
    server_name mydomain.com;

    ssl_certificate     /etc/letsencrypt/live/mydomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mydomain.com/privkey.pem;

    location / {
        root /usr/share/nginx/mysite;
        index index.html;
    }
}
EOF
The www HTTPS server block does not cause an SSL error because the certificate issued in Step 9 already covers both mydomain.com and www.mydomain.com. Both blocks reference the same cert file stored under the bare domain's folder. The www block redirects to the bare domain (the canonical URL), which is the standard SEO-correct approach.
bash — reload nginx to apply
cd ~/webserver && docker compose exec nginx nginx -s reload
Your site is now live at https://mydomain.com — and https://www.mydomain.com redirects there automatically.
Auto-renew SSL certificates set and forget

Let's Encrypt certificates expire every 90 days. This cron job checks daily at 3am and renews automatically when needed.

bash — open the cron editor
crontab -e

Add this line at the bottom of the file, then save and exit:

cron
0 3 * * * cd /home/yourname/webserver && docker compose run --rm certbot renew --quiet && docker compose exec nginx nginx -s reload
Setup complete. Your site is live, secured with HTTPS, and certificates will renew automatically.
Uploading & editing your site files
How to update your HTML files

Files are served directly from the mounted folder. Changes go live instantly — no container restart needed.

Option A — Edit directly on the server:

bash
nano ~/webserver/mysite/index.html
# Ctrl+O then Enter to save, Ctrl+X to exit

Option B — Upload from your local machine:

bash — run on your local machine, not the server
# Upload a single file
scp /path/to/index.html yourname@203.0.113.10:~/webserver/mysite/

# Upload an entire folder of files
scp -r /path/to/your/site/* yourname@203.0.113.10:~/webserver/mysite/

Option C — GUI file manager: Use WinSCP (Windows) or Cyberduck (Mac/Windows). Connect via SFTP to 203.0.113.10 as user yourname, then drag and drop into /home/yourname/webserver/mysite/

Quick reference
View running containers
docker ps
View Nginx logs
docker compose logs nginx
Reload Nginx config
cd ~/webserver && docker compose exec nginx nginx -s reload
Restart all containers
cd ~/webserver && docker compose restart
Stop all containers
cd ~/webserver && docker compose down
Start all containers
cd ~/webserver && docker compose up -d
Manually renew SSL cert
cd ~/webserver && docker compose run --rm certbot renew
Delete certbot files (sudo required)
sudo rm -rf ~/webserver/certbot
Check firewall rules
sudo ufw status
Check SSL cert expiry
sudo certbot certificates