From a bare Ubuntu 22 server to a live HTTPS website — beginner friendly, copy-paste ready.
Fill in your values — every code block on this page updates live as you type
SSH into your server as root, install all available updates, then create a non-root user for everyday use.
ssh root@203.0.113.10
# 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:
ssh yourname@203.0.113.10
UFW is Ubuntu's built-in firewall. We block everything by default and only open the ports we actually need.
allow OpenSSH rule before enabling UFW. Skipping this step locks you out of your server permanently over SSH.
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
Docker lets us run Nginx and Certbot in isolated containers without interfering with the rest of the system.
# 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
# Install the Compose plugin and verify both tools work
sudo apt install -y docker-compose-plugin
docker --version
docker compose version
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:
| Type | Name / Host | Value / Points to | TTL |
|---|---|---|---|
| A | mydomain.com | 203.0.113.10 | 300 |
| A | www.mydomain.com | 203.0.113.10 | 300 |
ping mydomain.com and ping www.mydomain.com — when both returnit returns your server IP, you're ready to continue.
All site files and configs live under ~/webserver. Nginx mounts these folders directly so changes are live instantly — no container restart needed.
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:
echo '<h1>My site is live</h1>' > ~/webserver/mysite/index.html
'...' 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:
We start with HTTP only. Adding SSL blocks before the certificates exist will prevent Nginx from starting at all.
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
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.
This defines two containers: Nginx (web server) and Certbot (SSL tool). They share volumes so Certbot can place verification files where Nginx serves them.
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 and confirm your domains serve serves the placeholder page before requesting SSL. Always run Docker Compose commands from inside the ~/webserver folder.
cd ~/webserver
docker compose up -d nginx
docker ps # nginx should show status "Up"
curl http://mydomain.com curl http://www.mydomain.com
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.
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
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
-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:
sudo ls ~/webserver/certbot/conf/live/
# Should show a folder named: mydomain.com
sudo when listing or deleting them. To start over: sudo rm -rf ~/webserver/certbot
Now that the certificate exists, replace the HTTP-only config with the full 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
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
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.
cd ~/webserver && docker compose exec nginx nginx -s reload
Let's Encrypt certificates expire every 90 days. This cron job checks daily at 3am and renews automatically when needed.
crontab -e
Add this line at the bottom of the file, then save and exit:
0 3 * * * cd /home/yourname/webserver && docker compose run --rm certbot renew --quiet && docker compose exec nginx nginx -s reload
Files are served directly from the mounted folder. Changes go live instantly — no container restart needed.
Option A — Edit directly on the server:
nano ~/webserver/mysite/index.html # Ctrl+O then Enter to save, Ctrl+X to exit
Option B — Upload from your local machine:
# 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/