Deploy a static site: from folder to a URL
Take a folder of HTML, CSS and JavaScript and publish it at your own domain with HTTPS on a small Linux server using Caddy (with the Nginx equivalent), then see how GitHub Pages and Cloudflare Pages do the same for free.
Drafted with AI from official documentation. Scripts were run in a test environment and repository links verified. Awaiting a human check. Did it work for you? Last edited Outdated?
A static site is just files: no database, no server-side code, nothing to crash at three in the morning. That makes it the best first thing to deploy. In this tutorial you put a folder of files on a cheap virtual server, give it a domain and HTTPS, and set up a one-command way to publish changes. At the end you compare that with the hosted alternatives.
What you will build
- A site at
https://www.example.com(andexample.comredirecting to it) served by Caddy on an Ubuntu server, with an automatically renewed certificate. - A
deploy.shscript that uploads your localsite/folder withrsyncin a few seconds. - Compression and cache headers so the site is fast.
- The equivalent Nginx configuration, and notes on GitHub Pages and Cloudflare Pages for when you do not want a server at all.
Before you start
- A folder with at least an
index.html. Any static site generator’s output (dist/,public/,_site/) works too. - A domain name you control, at any registrar.
- A small Linux VPS (Ubuntu 22.04 or 24.04) with a public IP address and SSH access as a user with
sudo. The smallest plan of any provider is more than enough; a static site uses almost no resources. - On your computer:
sshandrsync(preinstalled on macOS and Linux; on Windows use WSL). - Example values used below: server IP
203.0.113.10, SSH userana, domainexample.com. Replace them with yours.
Step 1: Point the domain at the server
In your registrar’s DNS settings, create two records:
- A record, name
@(the bare domain), value203.0.113.10. - A record, name
www, value203.0.113.10. - If the server has IPv6, add matching AAAA records.
DNS changes take from a minute to a few hours to spread. Check from your computer:
dig +short www.example.com
dig +short example.com
Both should print the server’s address before you continue; Caddy cannot get a certificate until they do.
Step 2: Prepare the server
Check before you run this
- Runs with administrator rights. Make sure you understand every line.
ssh [email protected]
sudo apt update && sudo apt upgrade -y
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable
sudo ufw status
Careful: ufw enable turns on the firewall. Allowing OpenSSH first is what keeps your current session and future logins working; sudo ufw disable turns it off again if needed. Most cloud providers also have a firewall or security group in their control panel where ports 80 and 443 must be open.
Step 3: Install Caddy
Check before you run this
- Runs with administrator rights. Make sure you understand every line.
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
systemctl status caddy --no-pager
The package sets up a systemd service that starts on boot and runs as the caddy user. Visit http://203.0.113.10 in a browser: you should see Caddy’s placeholder page.
Step 4: Create the web root
Check before you run this
- Runs with administrator rights. Make sure you understand every line.
sudo mkdir -p /var/www/example
sudo chown ana:ana /var/www/example
echo '<h1>Hello from example.com</h1>' > /var/www/example/index.html
The folder is owned by your user so you can upload to it without sudo. Files are world-readable by default, which is what Caddy (running as caddy) needs. If you later see 403 errors, check ls -la /var/www/example and make sure files are at least 644 and folders 755.
Step 5: Configure Caddy
Replace the contents of /etc/caddy/Caddyfile (for example with sudo nano /etc/caddy/Caddyfile):
example.com {
redir https://www.example.com{uri} permanent
}
www.example.com {
root * /var/www/example
encode zstd gzip
file_server
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
-Server
}
@static path *.css *.js *.woff2 *.png *.jpg *.svg *.webp
header @static Cache-Control "public, max-age=31536000, immutable"
handle_errors {
rewrite * /404.html
file_server
}
}
rootandfile_serverserve the folder;index.htmlis used for directory requests automatically.encodecompresses text responses.- The
@staticmatcher gives long cache lifetimes to assets. That is safe only if file names change when content changes (bundlers add hashes for this). If you editstyle.cssin place, shortenmax-ageor remove that block. handle_errorsserves your own404.htmlfor missing pages; create that file or remove the block.
Check before you run this
- Runs with administrator rights. Make sure you understand every line.
caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
journalctl -u caddy --since '2 min ago' --no-pager
The log shows certificates being obtained for both names. Open https://www.example.com: you should see the hello page with a padlock.
Step 6: Upload the site with rsync
Back on your own computer, in the folder that contains site/:
rsync -avz --delete site/ [email protected]:/var/www/example/
-a keeps permissions and times, -v shows what is copied, -z compresses, and --delete removes files on the server that no longer exist locally, so the server mirrors your folder exactly. The trailing slash on site/ means “the contents of”; without it you would get /var/www/example/site/. Careful: --delete removes server files not present locally. Run once with --dry-run first to see what would happen.
Step 7: Make deploying one command
Save this as deploy.sh next to your site folder:
#!/usr/bin/env bash
set -euo pipefail
SRC="site/"
DEST="[email protected]:/var/www/example/"
if [[ ! -f "$SRC/index.html" ]]; then
echo "No index.html in $SRC, refusing to deploy" >&2
exit 1
fi
rsync -avz --delete --exclude '.DS_Store' "$SRC" "$DEST"
echo "Deployed to https://www.example.com"
chmod +x deploy.sh
./deploy.sh
If your site is built by a generator, put the build command (npm run build, hugo, jekyll build) before the rsync line and set SRC to the output folder.
Step 8: The same with Nginx (optional)
If you prefer Nginx or already have it on the server, the equivalent is a server block in /etc/nginx/sites-available/example.com, enabled with a symlink, plus Certbot for the certificate:
server {
listen 80;
listen [::]:80;
server_name example.com;
return 301 https://www.example.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name www.example.com;
root /var/www/example;
index index.html;
gzip on;
gzip_types text/css application/javascript image/svg+xml;
location / {
try_files $uri $uri/ =404;
}
location ~* \.(css|js|woff2|png|jpg|svg|webp)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
error_page 404 /404.html;
}
Check before you run this
- Runs with administrator rights. Make sure you understand every line.
sudo apt install -y nginx certbot python3-certbot-nginx
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot rewrites the server blocks to listen on 443 with the certificate and adds the HTTP to HTTPS redirect. Nginx and Caddy cannot both listen on port 80, so run one or the other.
Check that it works
curl -I https://www.example.com
curl -I http://example.com # expect 301 to https://www.example.com
curl -sI https://www.example.com/style.css | grep -i cache-control
curl -sI https://www.example.com/missing # expect 404
echo | openssl s_client -servername www.example.com -connect www.example.com:443 2>/dev/null | openssl x509 -noout -dates
Edit index.html locally, run ./deploy.sh and reload the browser: the change is live. Test on a phone over mobile data to make sure it is not just reachable from your network.
Clean up
To remove the site but keep the server: delete the www.example.com and example.com blocks from the Caddyfile, reload Caddy, and sudo rm -r /var/www/example. To remove everything, destroy the VPS in your provider’s panel and delete the DNS records. Certificates do not need revoking; they simply expire.
Next steps
- GitHub Pages: push the site to a GitHub repository, enable Pages in the repository settings, and it is served at
username.github.io/repo; add aCNAMErecord at your DNS provider to use your own domain, with HTTPS included. Free for public repositories, no server to maintain, deploys on every push. - Cloudflare Pages: connect the repository, tell it the build command and output folder, and it builds and deploys on every push to a global CDN, also free for most sites, with custom domains and preview deployments for branches. Netlify and Vercel offer the same model.
- The VPS route is still worth knowing: it is the same skill you need for anything dynamic, it has no vendor rules about what you serve, and one small server can host many sites.
- Add a
robots.txtand asitemap.xml, and run the site through a security headers checker (see the headers reference in this topic). - Automate: a GitHub Actions workflow can run
rsyncto your server on push, using an SSH key stored as a secret.
Official documentation
Did this work for you?
No votes yet. Be the first.
If it did not work, the report form below tells the editors what went wrong.
See also
- 0279 What is a reverse proxy? A reverse proxy is a server that sits in front of your apps, receives all requests and forwards them to the right… Beginner · Explains an idea · Any system Article Web servers & reverse proxies 15 Oct 2026
- 0408 What a reverse proxy is and when you need one A reverse proxy is a server that sits in front of your application, accepts requests from the internet and forwards them to… Beginner · Explains an idea · Any system Article Web servers & reverse proxies 15 Oct 2026
- 0409 Nginx as a reverse proxy with HTTPS Put Nginx in front of an app on localhost: a server block that proxies to the port, forwards the client headers, supports… Intermediate · Step by step · Linux Article Deploying sites · Web servers & reverse proxies 15 Oct 2026
- 0410 Caddy: HTTPS reverse proxy in five lines Caddy gets and renews TLS certificates on its own. Install it, write a Caddyfile with your domain and the port your app… Beginner · Step by step · Linux Article Deploying sites · Web servers & reverse proxies 15 Oct 2026
- 0411 CORS explained, and how to fix the browser error The browser blocks a request from one origin to another unless the server allows it with Access-Control headers. Fix it in the… Intermediate · Step by step · Any system, Browser Article Making requests · Web servers & reverse proxies 15 Oct 2026
- 0413 Fix: 502 Bad Gateway from Nginx Nginx is running but could not get a valid response from your application. Read the error log, check that the app is… Intermediate · Fixes an error · Linux Article Web servers & reverse proxies 15 Oct 2026
Repos in the wild
- Caddy: the web server with automatic HTTPS caddyserver/caddy Caddy is an open-source web server and reverse proxy written in Go that obtains and renews TLS certificates on its own, with a configuration file short enough to memorize and HTTP/1, 2 and 3 support out of the box. GitHub
- Traefik: a reverse proxy that configures itself from Docker and Kubernetes traefik/traefik Traefik is an open-source edge router that discovers services from Docker labels, Kubernetes resources and other providers and routes traffic to them, with automatic Let's Encrypt certificates and a dashboard. GitHub
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- Dominik +9,496 chars