Dehydrated

Posted on 7 2026

The Server TLS article covered certbot for obtaining Let’s Encrypt certificates via HTTP-01 challenge. That approach works well for any service that has a public DNS record and can respond on port 80. For most of what February exposes publicly, certbot is the right tool.

Dehydrated is for the cases certbot cannot handle cleanly:

  • Wildcard certificates (*.yourdomain.net) require dns-01 challenge rather than HTTP-01, because there is no single hostname to verify via a web request. Dehydrated’s hook system makes dns-01 automation straightforward.
  • Services without port 80 access, such as the mail server which does not run a web server at all, or internal services that are reachable only via VPN.
  • Multiple domains on one certificate where managing a single certificate with hooks is cleaner than maintaining several certbot certificates.

Both clients speak ACMEv2, both obtain certificates from Let’s Encrypt, and the certificates they produce are interchangeable. The choice between them is purely about which fits the deployment scenario better.

What dehydrated is

Dehydrated is an ACME client written as a bash script. Its main dependencies are openssl and curl, both of which are already present on Ubuntu 24.04. It is available in the Ubuntu package repositories.

Its design philosophy is minimal: dehydrated handles the ACME protocol conversation and key management, and delegates everything else to hooks. A hook is a shell script that dehydrated calls at defined points in the process: when it needs to deploy a challenge, when it has received a signed certificate, and when an error occurs. This makes it straightforward to automate dns-01 challenges against any DNS API that has a hook script written for it.

Installation

sudo apt install dehydrated

Check the version:

dehydrated --version

Directory structure

The package creates /etc/dehydrated/ with configuration, hooks, and domain lists. Certificates are stored under /etc/dehydrated/certs/. The directory layout looks like this after initial setup:

/etc/dehydrated/
├── config              # Main configuration file
├── domains.txt         # List of certificates to obtain
├── hook.sh             # Hook script (create this)
├── accounts/           # ACME account keys
└── certs/              # Signed certificates (created on first run)
    └── yourdomain.net/
        ├── cert.pem        # The certificate
        ├── chain.pem       # The intermediate CA chain
        ├── fullchain.pem   # cert.pem + chain.pem
        ├── privkey.pem     # The private key
        └── config          # Per-domain overrides (optional)

Main configuration

Edit /etc/dehydrated/config:

sudo tee /etc/dehydrated/config << 'EOF'
#
# dehydrated configuration
# /etc/dehydrated/config
#

# ACME API endpoint
# Let's Encrypt production:
CA="letsencrypt"

# Contact email address for your Let's Encrypt account.
# Used for expiry notifications.
CONTACT_EMAIL="you@yourdomain.net"

# Challenge type: http-01 or dns-01
# Use dns-01 for wildcard certificates and services without port 80.
CHALLENGETYPE="dns-01"

# Hook script path
HOOK=/etc/dehydrated/hook.sh

# Key algorithm: RSA or EC (elliptic curve)
# P-256 is fast, widely supported, and produces smaller certificates.
KEY_ALGO=prime256v1

# Key size (used for RSA keys, ignored for EC keys)
KEYSIZE=4096

# Minimum days remaining before renewal is attempted.
# Let's Encrypt certificates are valid for 90 days.
RENEW_DAYS=30

# Directory for ACME challenges (http-01 only, ignored for dns-01)
# WELLKNOWN=/var/www/dehydrated

# Group to make certificate files readable by (in addition to root)
# Allows nginx and other services to read the private key.
CERTGROUP=www-data
EOF

Registering with Let’s Encrypt

Before requesting any certificates, register an account with Let’s Encrypt:

sudo dehydrated --register --accept-terms

This creates an account key and stores it under /etc/dehydrated/accounts/. The account is associated with the email address in the config. Let’s Encrypt sends expiry notices to this address if automatic renewal fails.

The domains.txt file

/etc/dehydrated/domains.txt defines which certificates dehydrated manages. Each line becomes one certificate. Multiple hostnames on one line are included as Subject Alternative Names (SANs) on a single certificate.

# /etc/dehydrated/domains.txt
#
# Format: primary-hostname [additional-san...]
# A line with multiple names produces one certificate covering all of them.
# A wildcard requires dns-01 challenge.

yourdomain.net www.yourdomain.net
*.yourdomain.net
mail.yourdomain.net

The wildcard *.yourdomain.net covers any single-level subdomain: nextcloud.yourdomain.net, grafana.yourdomain.net, mail.yourdomain.net, and so on. It does not cover the bare domain yourdomain.net itself, which is why that gets its own line. If you want one certificate to cover both, put them on the same line:

yourdomain.net *.yourdomain.net

The hook script

The hook script is where dns-01 challenge automation lives. Dehydrated calls it with different arguments depending on what it needs done. The four functions that matter most are:

  • deploy_challenge — add the DNS TXT record proving domain ownership
  • clean_challenge — remove the DNS TXT record after verification
  • deploy_cert — called when a certificate has been successfully issued or renewed
  • unchanged_cert — called when a certificate did not need renewal

The hook script must be executable and handle these function calls. The implementation of deploy_challenge and clean_challenge depends entirely on how your DNS is managed.

Create a skeleton hook script:

sudo tee /etc/dehydrated/hook.sh << 'HOOK'
#!/usr/bin/env bash
#
# dehydrated hook script
# /etc/dehydrated/hook.sh
#

function deploy_challenge {
    local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"

    # Add the ACME challenge TXT record to DNS.
    # The record name is _acme-challenge.${DOMAIN}
    # The record value is ${TOKEN_VALUE}
    #
    # Implementation depends on your DNS provider or DNS server.
    # See dns-verification.md in the dehydrated documentation for
    # hook scripts for popular DNS providers.
    #
    # Example for PowerDNS API (replace with your implementation):
    # pdnsutil add-record "${DOMAIN}" _acme-challenge TXT "${TOKEN_VALUE}"

    echo "TODO: add TXT record _acme-challenge.${DOMAIN} = ${TOKEN_VALUE}"

    # Allow time for DNS propagation before Let's Encrypt checks.
    sleep 30
}

function clean_challenge {
    local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"

    # Remove the ACME challenge TXT record from DNS.
    echo "TODO: remove TXT record _acme-challenge.${DOMAIN}"
}

function deploy_cert {
    local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" \
          FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"

    # Called when a certificate has been issued or renewed.
    # Reload services that use this certificate.
    echo "Certificate deployed for ${DOMAIN}"
    systemctl reload nginx
}

function unchanged_cert {
    local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" \
          FULLCHAINFILE="${4}" CHAINFILE="${5}"

    # Called when a certificate was checked but did not need renewal.
    : # no action needed
}

function invalid_challenge {
    local DOMAIN="${1}" RESPONSE="${2}"
    echo "Challenge invalid for ${DOMAIN}: ${RESPONSE}" >&2
}

function request_failure {
    local STATUSCODE="${1}" REASON="${2}" REQTYPE="${3}"
    echo "Request failed: ${STATUSCODE} ${REASON}" >&2
}

function startup_hook {
    : # runs before dehydrated starts
}

function exit_hook {
    : # runs after dehydrated exits
}

HANDLER="$1"; shift
if [[ "${HANDLER}" == 'deploy_challenge' || \
      "${HANDLER}" == 'clean_challenge' || \
      "${HANDLER}" == 'deploy_cert' || \
      "${HANDLER}" == 'unchanged_cert' || \
      "${HANDLER}" == 'invalid_challenge' || \
      "${HANDLER}" == 'request_failure' || \
      "${HANDLER}" == 'startup_hook' || \
      "${HANDLER}" == 'exit_hook' ]]; then
    "$HANDLER" "$@"
fi
HOOK

sudo chmod 0750 /etc/dehydrated/hook.sh

The deploy_challenge and clean_challenge functions need real implementations for your DNS setup. If February runs its own authoritative DNS server (covered in the DNS section of this series), you can use pdnsutil or the PowerDNS API directly. If DNS is managed at a registrar or external provider, look for a pre-written hook at github.com/dehydrated-io/dehydrated/wiki/hooks.

Requesting certificates manually

Once the hook script has real dns-01 implementations, test by requesting certificates manually:

sudo dehydrated -c

The -c flag runs the full cycle: check which certificates need renewal, obtain or renew them, and call the hook for each. Watch the output carefully on the first run. If the DNS TXT record is not propagating quickly enough, increase the sleep in deploy_challenge.

To test without actually requesting certificates (useful for verifying config):

sudo dehydrated -c --staging

The --staging flag uses Let’s Encrypt’s staging environment, which issues certificates that are not trusted by browsers but do not count against rate limits. Use staging until the hook script is confirmed working, then switch to production.

List the certificates dehydrated is managing:

sudo dehydrated --list

Using certificates in nginx

Dehydrated stores certificates at /etc/dehydrated/certs/{domain}/. In nginx server blocks, reference them the same way as certbot certificates:

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_name yourdomain.net www.yourdomain.net;

    ssl_certificate     /etc/dehydrated/certs/yourdomain.net/fullchain.pem;
    ssl_certificate_key /etc/dehydrated/certs/yourdomain.net/privkey.pem;

    include /etc/nginx/tls/tls.conf;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

    # ... rest of server block
}

For a wildcard certificate covering *.yourdomain.net, the certificate path uses the wildcard domain name as the directory:

ssl_certificate     /etc/dehydrated/certs/*.yourdomain.net/fullchain.pem;
ssl_certificate_key /etc/dehydrated/certs/*.yourdomain.net/privkey.pem;

Nginx reads the literal path including the asterisk. This is not a glob — it is the directory name that dehydrated created, which matches the first entry in domains.txt.

Automatic renewal via systemd timer

The Ubuntu package includes a systemd timer for automatic renewal. Check its status:

sudo systemctl status dehydrated.timer

Enable and start it if it is not already running:

sudo systemctl enable --now dehydrated.timer

The timer runs dehydrated daily. Certificates are only renewed if they are within RENEW_DAYS of expiry (set to 30 in the config above), so daily runs with no certificates due for renewal are fast and quiet.

Confirm the timer is scheduled correctly:

sudo systemctl list-timers dehydrated

Test that the renewal service runs without error:

sudo systemctl start dehydrated.service
sudo journalctl -u dehydrated --since "5 minutes ago"

Coexisting with certbot

Both certbot and dehydrated can run on February simultaneously. They manage different certificates and do not conflict, provided they are not both trying to obtain certificates for the same domain.

The clean split is: certbot handles single-domain HTTP-01 certificates for services that have a dedicated port 80 endpoint, and dehydrated handles wildcard and multi-domain certificates via dns-01. The deploy_cert hook in dehydrated reloads nginx, as does certbot’s deploy hook from the TLS article. Both reloads are safe to run; nginx handles them gracefully.

If the DNS hook script does not clean up TXT records reliably, challenge records accumulate in DNS. This is harmless but untidy. Some DNS providers rate-limit TXT record creation, which can cause intermittent failures. Make sure clean_challenge runs successfully.