Skip to content

Installing Caddy on Ubuntu the right way

A production-grade Caddy install on Ubuntu — official repo, systemd unit, firewall, Caddyfile validation, and the operational checks an MSP makes before declaring the box ready.

A fresh Ubuntu box, a domain pointed at it, and a directive to “put Caddy on it because HTTPS is too painful with Nginx.” This article is the install procedure we run in production: get the package on from the upstream repository, get the unit running under systemd, open the firewall narrowly, validate a starter Caddyfile, and leave the box in a documented state. The goal is not “Caddy is running” — it is “Caddy is running, observable, certificates are in place, and the box is ready to host a real site.”

How to verify

Before touching the package manager, check what is already there. The box may have an older Caddy v1 binary in /usr/local/bin, or Nginx may be on port 80 and you are about to fight it for the socket.

ss -ltnp | grep -E ':(80|443|2019)\b'
which caddy && caddy version
dpkg -l | grep -E 'caddy|nginx|apache2'
systemctl list-unit-files | grep -E 'caddy|nginx|apache2'

If port 80 is bound, identify the process before installing. Caddy v1 (Go modules era) and Caddy v2 share the binary name but have incompatible configs — confirm the major version if anything is already installed.

What’s happening

Caddy on Ubuntu ships from the Cloudsmith-hosted official repository, not the Ubuntu archive. The archive copy lags by several minor releases and lacks the caddy-api variant. The package installs the caddy binary to /usr/bin/caddy, drops a stub Caddyfile at /etc/caddy/Caddyfile, registers a caddy system user (UID picked from the next free range), creates /var/lib/caddy for the certificate cache, and installs a systemd unit at /lib/systemd/system/caddy.service.

The unit runs as the caddy user. That detail matters: certificates land under /var/lib/caddy/.local/share/caddy/, not /etc/letsencrypt/ like certbot. The process can bind ports under 1024 because the systemd unit grants CAP_NET_BIND_SERVICE, not because it runs as root. Reload on config change is via systemctl reload caddy, which sends SIGUSR1 — Caddy reloads the config in-process with zero dropped connections.

Two binaries exist in the upstream repo:

  • caddy — the standard binary with the static modules baked in at release time.
  • caddy-api — an alternate systemd unit that boots from /etc/caddy/caddy.json (JSON config) instead of /etc/caddy/Caddyfile and exposes the admin API on 127.0.0.1:2019.

For most hosts you want the standard caddy package. For dynamic, API-driven setups (containers writing config at runtime), switch to caddy-api.

The procedure

  1. Add the official repository. The upstream signing key and apt source come from Cloudsmith:

    sudo apt update
    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
  2. Install the binary. Standard package for static config; caddy-api for runtime-driven config:

    sudo apt install -y caddy
    caddy version
  3. Confirm the service is up under systemd. The package starts and enables the unit automatically; verify both:

    systemctl is-active caddy
    systemctl is-enabled caddy
    sudo systemctl status caddy --no-pager
  4. Open the firewall narrowly. Caddy needs 80 (HTTP redirect and ACME HTTP-01 challenge) and 443 (HTTPS plus HTTP/3 over UDP/443). If you use UFW:

    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw allow 443/udp
    sudo ufw reload
    sudo ufw status verbose

    The UDP rule is mandatory for HTTP/3 (QUIC). Skip it and clients silently fall back to HTTP/2 over TCP — slower, but not broken.

  5. Replace the stub Caddyfile. The default file at /etc/caddy/Caddyfile serves the “Caddy works!” splash page on :80. Replace it with a real site block. Minimum production starter:

    {
        email [email protected]
        servers {
            metrics
        }
    }
    
    example.com {
        root * /var/www/example.com
        file_server
        encode zstd gzip
        log {
            output file /var/log/caddy/access.log {
                roll_size 100MiB
                roll_keep 14
            }
            format json
        }
    }

    The global email directive is the ACME account email — without it, Let’s Encrypt registrations fall back to a placeholder address and you lose expiry notifications.

  6. Validate before reload. caddy validate parses the Caddyfile, expands snippets and imports, and returns non-zero on syntax errors:

    sudo caddy validate --config /etc/caddy/Caddyfile
    sudo systemctl reload caddy

    A reload that fails leaves the previous config running — no downtime — but journalctl -u caddy is your only signal. Watch it.

  7. Confirm HTTPS issued. From outside the box (cert provisioning must reach the public network):

    curl -I https://example.com/
    openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null | openssl x509 -noout -issuer -dates

    Issuer should read Let's Encrypt. Notice the cert is in /var/lib/caddy/.local/share/caddy/certificates/ — back that directory up, or be prepared to re-issue on a host rebuild.

Common pitfalls

  • The Ubuntu archive caddy package is too old; never install via apt install caddy without first adding the Cloudsmith repo.
  • Running caddy run interactively as root works for testing but writes certificates to /root/.local/share/caddy/ — the systemd-managed location is /var/lib/caddy/.local/share/caddy/; the two installs do not share state.
  • If port 443 is blocked at the security group, Caddy retries the ACME challenge forever; journalctl -u caddy | grep -i acme shows the rate-limit warnings before Let’s Encrypt blackholes you.
  • The caddy reload subcommand differs from systemctl reload caddy — both work, but only the systemd path is captured in the audit log of journalctl.
  • IPv6: if AAAA records exist and the box has no v6 routing, ACME picks one address per attempt and retries — half your challenges silently fail. Either remove the AAAA or fix v6 egress.

Stack Harbor runs Caddy as the default edge for clients who do not need Nginx’s module ecosystem — install scripted, Caddyfiles under config management, certificate state backed up off-box, and the access logs streaming into the same pipeline as the rest of the stack. If you have a Caddy fleet that grew organically and you want it consolidated under one operating playbook, that is the work covered by our Managed Operations practice.