Auto-Deploy a Static Site with Caddy + Git Hooks
Set up a zero-CI deploy pipeline: push to a bare Git repo, run a build hook, and let Caddy serve your static site with automatic TLS.
Before you start
- ▸A Linux server with SSH access and a public IP address
- ▸A domain name with an A record pointing to the server
- ▸Git installed on both your local machine and the server
- ▸sudo privileges on the server
A bare Git repository and a post-receive hook give you a one-command deploy pipeline without CI servers, container runtimes, or external services. Push your code, the server builds it, and Caddy serves the result — all in under a second for most static sites. This guide walks through the complete setup: bare repo, build hook, and a production-ready Caddy configuration.
Prerequisites and Assumptions
You need a Linux server you can SSH into, a domain pointed at it, and a static site with an optional build step (Hugo, Zola, Eleventy, plain HTML — all work). Caddy must be installed. The guide uses a dedicated deploy user to keep permissions clean.
Install Caddy
Debian / Ubuntu
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
Fedora / RHEL / Rocky
sudo dnf install -y 'dnf-command(copr)'
sudo dnf copr enable -y @caddy/caddy
sudo dnf install -y caddy
Arch
sudo pacman -S caddy
After installation, enable and start the service:
sudo systemctl enable --now caddy
Create a Dedicated Deploy User
Running hooks as root is unnecessary and dangerous. Create a deploy user with a home directory but no login shell:
sudo useradd -m -s /bin/bash deploy
Add your SSH public key so you can push without a password:
sudo mkdir -p /home/deploy/.ssh
sudo cp ~/.ssh/authorized_keys /home/deploy/.ssh/ # if already on server, or:
# paste your key directly:
sudo tee /home/deploy/.ssh/authorized_keys <<< "ssh-ed25519 AAAA... you@local"
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
Create the Bare Repository
A bare repository stores Git objects without a working tree. It is the push target on the server.
sudo mkdir -p /var/repo/mysite.git
sudo chown deploy:deploy /var/repo/mysite.git
sudo -u deploy git init --bare /var/repo/mysite.git
Also create the directory that will hold the built site:
sudo mkdir -p /var/www/mysite
sudo chown deploy:deploy /var/www/mysite
Write the post-receive Hook
The hook runs on the server every time you push. It checks out the latest commit into a work tree, runs your build tool, then copies the output to the web root.
sudo -u deploy tee /var/repo/mysite.git/hooks/post-receive << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
REPO="/var/repo/mysite.git"
WORK="/tmp/mysite-build"
WWW="/var/www/mysite"
BRANCH="refs/heads/main"
while read -r oldrev newrev ref; do
[[ "$ref" != "$BRANCH" ]] && continue
echo "--- Deploying ${newrev:0:8} from $ref ---"
# Clean checkout into temp work tree
rm -rf "$WORK"
git --work-tree="$WORK" --git-dir="$REPO" checkout -f main
cd "$WORK"
# --- Build step: adjust for your tool ---
# Hugo:
# hugo --minify --destination "$WWW"
# Zola:
# zola build --output-dir "$WWW"
# Eleventy:
# npm ci --prefer-offline && npx @11ty/eleventy --output="$WWW"
# Plain HTML (no build):
rsync -a --delete "$WORK/" "$WWW/"
# -----------------------------------------
echo "--- Deploy complete ---"
done
EOF
sudo chmod +x /var/repo/mysite.git/hooks/post-receive
Important: uncomment only the build command that matches your toolchain and comment out or remove the others. The set -euo pipefail line ensures the hook aborts on any error and you see it in your push output.
Install Your Build Tool (if needed)
For Hugo, download the appropriate binary from the Hugo releases page and place it in /usr/local/bin. For Node-based tools, install Node via your package manager and make sure the deploy user can run npm. The hook runs as deploy, so test with sudo -u deploy hugo version before your first push.
Configure Caddy
Caddy automatically provisions TLS certificates via Let's Encrypt when you serve a real domain. Replace mysite.example.com with your actual domain.
sudo tee /etc/caddy/Caddyfile << 'EOF'
mysite.example.com {
root * /var/www/mysite
file_server
encode gzip zstd
# Optional: clean URLs for .html files
try_files {path} {path}.html {path}/index.html
header {
# Basic security headers
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
}
}
EOF
sudo systemctl reload caddy
If you are testing locally without a domain, replace the hostname with :8080 and Caddy will serve over plain HTTP on port 8080.
Allow Caddy to Read the Web Root
Caddy runs as the caddy user. The deploy user owns /var/www/mysite. Grant read access:
sudo chmod o+rx /var/www/mysite
# Or add caddy to the deploy group:
sudo usermod -aG deploy caddy
sudo systemctl restart caddy
Add the Remote and Push
On your local machine, add the server as a Git remote:
git remote add production deploy@your-server-ip:/var/repo/mysite.git
Deploy with a normal push:
git push production main
You will see the hook output inline in your terminal — build logs, errors, and the final --- Deploy complete --- message stream back over SSH in real time.
Verification
After a successful push, confirm the files landed and Caddy is serving them:
# On the server:
ls -lh /var/www/mysite/
curl -I https://mysite.example.com
The curl output should show HTTP/2 200 and the Strict-Transport-Security header you configured. Check Caddy's log if anything looks wrong:
sudo journalctl -u caddy -n 50 --no-pager
Troubleshooting
- Push succeeds but site does not update: The hook may have exited silently. SSH in as
deployand run the hook commands manually to see the real error. Check that your branch name matchesBRANCHin the hook. - Permission denied on push: Verify the
deployuser'sauthorized_keyshas the correct public key and the.sshdirectory permissions are exactly700/600. - Caddy returns 403: The
caddyuser cannot read/var/www/mysite. Re-run thechmod o+rxstep, or check that thedeployhook copied files with world-readable permissions (rsync -apreserves source permissions; your build tool may vary). - TLS certificate not issuing: Confirm port 80 and 443 are open in your firewall (
sudo ufw allow 'Caddy Full'on Ubuntu, or adjust yournftables/firewalldrules) and that your DNS A record has propagated. - Build tool not found: The hook inherits a minimal environment. Use absolute paths (
/usr/local/bin/hugo) or setPATHexplicitly at the top of the hook script.
Frequently asked questions
- Can I deploy multiple sites from the same server?
- Yes. Create a separate bare repo and web root directory for each site (e.g. /var/repo/site2.git and /var/www/site2), write a post-receive hook for each, and add a new block in your Caddyfile for the second domain.
- What happens if the build step fails?
- Because the hook starts with 'set -euo pipefail', any non-zero exit code aborts the script immediately. The old files in the web root remain untouched and you see the error output directly in your 'git push' terminal session.
- Can I roll back a bad deploy?
- Push an earlier commit: 'git push production <commit-hash>:main --force'. The hook will check out that commit and rebuild, restoring the previous version.
- Does this work if my build tool needs environment variables or secrets?
- Add them to /home/deploy/.profile or a dedicated /etc/deploy-env file and source it at the top of the hook script. Never commit secrets into the repository itself.
- Why a bare repo and not a regular clone on the server?
- A bare repo has no working tree, so it cannot get into a detached-HEAD or merge-conflict state when you push. It is the standard server-side Git pattern and exactly what git clone --bare and git init --bare are designed for.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.