$linuxjunkies
>

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.

IntermediateUbuntuDebianFedoraArch9 min readUpdated June 7, 2026

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 deploy and run the hook commands manually to see the real error. Check that your branch name matches BRANCH in the hook.
  • Permission denied on push: Verify the deploy user's authorized_keys has the correct public key and the .ssh directory permissions are exactly 700/600.
  • Caddy returns 403: The caddy user cannot read /var/www/mysite. Re-run the chmod o+rx step, or check that the deploy hook copied files with world-readable permissions (rsync -a preserves 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 your nftables/firewalld rules) 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 set PATH explicitly at the top of the hook script.
tested on:Ubuntu 24.04Debian 12Fedora 41Arch rolling

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