Skip to content

Instantly share code, notes, and snippets.

@jaredcat
Created February 20, 2026 21:26
Show Gist options
  • Select an option

  • Save jaredcat/f3bfbe40f148e898d51b7d6dd193df01 to your computer and use it in GitHub Desktop.

Select an option

Save jaredcat/f3bfbe40f148e898d51b7d6dd193df01 to your computer and use it in GitHub Desktop.

AdGuard cert sync

Syncs a TLS certificate (fullchain + private key) from your certificate source to AdGuard Home via its control API. Use this when your certs are issued elsewhere—e.g. by a reverse proxy, Certbot, or another container—and you want AdGuard’s admin UI and DNS-over-TLS/HTTPS to use the same cert.

Requirements

  • AdGuard Home with the web interface (and control API) reachable from the machine that runs the script.
  • A certificate source: PEM files (fullchain + privkey) from Let’s Encrypt, your reverse proxy, or any other provider. The script can:
    • Read local paths (e.g. Certbot live dir, or a container’s mounted cert volume), or
    • SSH to another host and run a command (e.g. docker exec) to read the certs.
  • curl (and ideally jq, so existing AdGuard TLS settings are preserved when updating the cert).

Setup

  1. Clone or copy this repo to a machine that can reach your cert source and AdGuard.

  2. Copy the example env and edit:

    cp env.example .env
    # Edit .env: AdGuard host/port, credentials, and cert source (local paths or SSH).
  3. Make the script executable:

    chmod +x sync-adguard-cert.sh
  4. If using SSH to fetch certs: ensure key-based login works so the script can run without a password (e.g. ssh-copy-id user@cert-host).

  5. AdGuard credentials: Use the same username and password as the AdGuard web UI. Set ADGUARD_USE_HTTPS=false and ADGUARD_PORT=3000 to connect over HTTP so the script does not depend on AdGuard’s TLS being up.

Usage

./sync-adguard-cert.sh
  • Dry run (fetch cert and show what would be sent, without updating AdGuard):

    ./sync-adguard-cert.sh --dry-run

Run the script after your certificate is renewed (e.g. via cron on the same schedule as your cert renewals), and once after initial setup so AdGuard gets the current cert.

Automation (cron)

0 3 * * * LOG_FILE=/opt/adguard-cert-sync/sync.log LOG_MAX_LINES=500 /opt/adguard-cert-sync/sync-adguard-cert.sh >> /opt/adguard-cert-sync/sync.log 2>&1

Local cert files: If the script runs on the same host as your cert source, set in .env:

LOCAL_FULLCHAIN=/path/to/fullchain.pem
LOCAL_PRIVKEY=/path/to/privkey.pem

No container or SSH is required—the script just reads these two PEM files. Works with any source (Certbot, Caddy, nginx, Traefik, etc.). Leave CERT_SOURCE_SSH_USER empty when using local paths.

Encryption page settings

The script GETs the current TLS config from AdGuard and overwrites only the certificate, private key, and enable_https: true. Other fields (server name, HTTPS port, force HTTPS) come from whatever that GET returns. If the API returns a minimal config (e.g. when encryption was off or after a reset), re-applying it can make those other settings look cleared.

Recommended: Set these in .env so every run restores them:

  • ADGUARD_TLS_SERVER_NAME – hostname your clients use (e.g. for Private DNS or DoH)
  • ADGUARD_PORT_HTTPS – e.g. 443
  • ADGUARD_FORCE_HTTPStrue or false

Leave them unset only if you are okay with whatever the API currently returns (which may be minimal).

Certificate source (paths / SSH)

  • Local paths (no container required): Set LOCAL_FULLCHAIN and LOCAL_PRIVKEY to your fullchain and privkey PEM paths. Works with any PEM source—Certbot, Caddy, nginx, Traefik, or any tool that writes those files.
  • SSH + container: Set CERT_SOURCE_SSH_USER, CERT_SOURCE_HOST / CERT_SOURCE_IP, and CERT_SOURCE_CONTAINER. The script runs docker exec <container> cat <path> for fullchain and privkey. Set CERT_SOURCE_DIR to the path inside the container.
  • SSH + host files: Same vars; the script’s fetch step is geared toward a container; for host-only paths you may need to customize the fetch command.

Troubleshooting

  • “Could not reach AdGuard”: Check ADGUARD_HOST/ADGUARD_IP, ADGUARD_PORT, and that the AdGuard web UI is reachable. Use ADGUARD_INSECURE=true if the AdGuard UI uses a self-signed or internal CA. Prefer HTTP (ADGUARD_USE_HTTPS=false, port 3000) to avoid TLS issues.
  • “Could not read certificate”: If using SSH, test the same ssh and docker exec (or remote cat) commands by hand. If using local paths, ensure the fullchain and privkey files exist and are readable.
  • AdGuard rejects config: Ensure the PEM files are valid (e.g. openssl x509 -in fullchain.pem -noout -text). Install jq so the script sends a full TLS config; without jq only cert and key are sent, which can work but may reset other TLS options.
Adgaurd Home Cert Sync
# Copy to .env and fill in.
# Certificate source (for SSH + container fetch). Can be omitted if using LOCAL_FULLCHAIN/LOCAL_PRIVKEY instead.
CERT_SOURCE_HOST=cert-host
CERT_SOURCE_IP=192.168.1.2
CERT_SOURCE_SSH_USER=
CERT_SOURCE_CONTAINER=my-container
CERT_SOURCE_DIR=/config/keys/letsencrypt
DOMAIN=yourdomain.com
# AdGuard (certificate target)
ADGUARD_HOST=adguard
ADGUARD_IP=192.168.1.1
ADGUARD_PORT=3000
ADGUARD_USER=admin
ADGUARD_PASS=your-adguard-password
ADGUARD_USE_HTTPS=false
# Skip TLS verification when connecting to AdGuard (e.g. self-signed or internal CA). Set false if using a public CA.
ADGUARD_INSECURE=true
# Optional: pin encryption-page settings
# ADGUARD_TLS_SERVER_NAME=dns.yourdomain.com
# ADGUARD_PORT_HTTPS=443
# ADGUARD_FORCE_HTTPS=true
# Optional: local cert paths (no container or SSH required). Works with any PEM source.
# LOCAL_FULLCHAIN=/path/to/fullchain.pem
# LOCAL_PRIVKEY=/path/to/privkey.pem
# Optional: log cleanup when using >> sync.log. Truncate to last N lines at start of each run.
# LOG_FILE=/opt/adguard-cert-sync/sync.log
# LOG_MAX_LINES=500
#!/usr/bin/env bash
#
# Sync a TLS certificate (fullchain + privkey) to AdGuard Home via its control API.
# Cert source: local PEM paths (LOCAL_FULLCHAIN/LOCAL_PRIVKEY) or SSH + container (CERT_SOURCE_*).
#
# Usage:
# ./sync-adguard-cert.sh [--dry-run]
#
# Configure via environment or .env (see .env.example).
set -euo pipefail
# --- Configuration (override with env or .env) ---
# Certificate source (for SSH + container fetch)
CERT_SOURCE_HOST="${CERT_SOURCE_HOST:-}"
CERT_SOURCE_IP="${CERT_SOURCE_IP:-}"
CERT_SOURCE_SSH_USER="${CERT_SOURCE_SSH_USER:-}"
CERT_SOURCE_CONTAINER="${CERT_SOURCE_CONTAINER:-}"
CERT_SOURCE_DIR="${CERT_SOURCE_DIR:-/config/keys/letsencrypt}"
DOMAIN="${DOMAIN:-}"
ADGUARD_HOST="${ADGUARD_HOST:-adguard}"
ADGUARD_IP="${ADGUARD_IP:-}"
ADGUARD_PORT="${ADGUARD_PORT:-443}"
ADGUARD_USER="${ADGUARD_USER:-}"
ADGUARD_PASS="${ADGUARD_PASS:-}"
ADGUARD_USE_HTTPS="${ADGUARD_USE_HTTPS:-true}"
# Skip TLS verification when talking to AdGuard (e.g. self-signed or internal CA)
ADGUARD_INSECURE="${ADGUARD_INSECURE:-true}"
# Optional: pin encryption-page settings so the script never clears them. Leave unset to keep whatever AdGuard has.
ADGUARD_TLS_SERVER_NAME="${ADGUARD_TLS_SERVER_NAME:-}"
ADGUARD_PORT_HTTPS="${ADGUARD_PORT_HTTPS:-}"
ADGUARD_FORCE_HTTPS="${ADGUARD_FORCE_HTTPS:-}"
# Optional: log cleanup. If LOG_FILE and LOG_MAX_LINES are set, truncate the log to the last LOG_MAX_LINES lines at the start of each run.
LOG_FILE="${LOG_FILE:-}"
LOG_MAX_LINES="${LOG_MAX_LINES:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DRY_RUN=false
# Load .env if present (optional)
for f in "${SCRIPT_DIR}/.env" "${SCRIPT_DIR}/.env.local"; do
if [[ -f "$f" ]]; then
set -a
# shellcheck source=/dev/null
source "$f"
set +a
break
fi
done
log() { echo "[$(date -Iseconds)] $*"; }
err() { echo "[$(date -Iseconds)] ERROR: $*" >&2; }
usage() {
echo "Usage: $0 [--dry-run]"
echo "Syncs TLS cert for ${DOMAIN} to AdGuard (${ADGUARD_HOST})."
echo "Set CERT_SOURCE_SSH_USER (or LOCAL_FULLCHAIN/LOCAL_PRIVKEY), ADGUARD_USER, ADGUARD_PASS (or use .env)."
exit 0
}
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
-h|--help) usage ;;
esac
done
# Optional: truncate log file to last N lines at start of each run
if [[ -n "${LOG_FILE}" && -n "${LOG_MAX_LINES}" && -f "${LOG_FILE}" ]]; then
if [[ "${LOG_MAX_LINES}" =~ ^[0-9]+$ ]] && [[ "${LOG_MAX_LINES}" -gt 0 ]]; then
tail -n "${LOG_MAX_LINES}" "${LOG_FILE}" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "${LOG_FILE}"
fi
fi
# Visual separator between runs when output is appended to a log file
echo ""
log "---------- Sync run $(date -Iseconds) ----------"
# Resolve host to IP if needed (use ADGUARD_IP / CERT_SOURCE_IP for API/SSH)
resolve_host() {
local host="$1"
local fallback_ip="$2"
if [[ -n "$fallback_ip" ]]; then
echo "$fallback_ip"
return
fi
if command -v getent &>/dev/null; then
getent ahosts "$host" 2>/dev/null | awk 'NR==1{print $1; exit}' || echo "$host"
else
echo "$host"
fi
}
CERT_SOURCE_TARGET=$(resolve_host "$CERT_SOURCE_HOST" "$CERT_SOURCE_IP")
ADGUARD_TARGET=$(resolve_host "$ADGUARD_HOST" "$ADGUARD_IP")
# --- Fetch cert from your certificate source ---
# Option A: Local paths (no container or SSH required). Any PEM source works (Certbot, Caddy, nginx, etc.).
# Set LOCAL_FULLCHAIN and LOCAL_PRIVKEY to fullchain.pem and privkey.pem paths.
# Option B: SSH to a host and run a command (e.g. docker exec) to read certs from a container.
# Option C: Local directory: script tries CERT_SOURCE_DIR then Certbot-style live/DOMAIN (only if A and B unused).
CERT_FULLCHAIN_PATH="${LOCAL_FULLCHAIN:-}"
CERT_PRIVKEY_PATH="${LOCAL_PRIVKEY:-}"
fetch_certs_via_ssh() {
local ssh_spec="${CERT_SOURCE_SSH_USER:+${CERT_SOURCE_SSH_USER}@}${CERT_SOURCE_TARGET}"
local fullchain_path="/config/etc/letsencrypt/live/${DOMAIN}/fullchain.pem"
local privkey_path="/config/etc/letsencrypt/live/${DOMAIN}/privkey.pem"
local fallback_full="/config/keys/letsencrypt/fullchain.pem"
local fallback_priv="/config/keys/letsencrypt/privkey.pem"
if [[ -n "${CERT_SOURCE_DIR}" && "${CERT_SOURCE_DIR}" != "/config/keys/letsencrypt" ]]; then
fullchain_path="${CERT_SOURCE_DIR}/fullchain.pem"
privkey_path="${CERT_SOURCE_DIR}/privkey.pem"
fi
fullchain=$(ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${ssh_spec}" \
"docker exec ${CERT_SOURCE_CONTAINER} cat ${fullchain_path} 2>/dev/null || docker exec ${CERT_SOURCE_CONTAINER} cat ${fallback_full}")
privkey=$(ssh -o ConnectTimeout=10 "${ssh_spec}" \
"docker exec ${CERT_SOURCE_CONTAINER} cat ${privkey_path} 2>/dev/null || docker exec ${CERT_SOURCE_CONTAINER} cat ${fallback_priv}")
echo "$fullchain" && echo "---KEY---" && echo "$privkey"
}
fetch_certs_local() {
local dir="${CERT_SOURCE_DIR}"
if [[ ! -f "${dir}/fullchain.pem" ]]; then
dir="/config/etc/letsencrypt/live/${DOMAIN}"
fi
if [[ ! -f "${dir}/fullchain.pem" ]]; then
err "Local cert not found in ${CERT_SOURCE_DIR} or ${dir}"
return 1
fi
cat "${dir}/fullchain.pem" && echo "---KEY---" && cat "${dir}/privkey.pem"
}
if [[ -n "${CERT_FULLCHAIN_PATH}" && -n "${CERT_PRIVKEY_PATH}" ]]; then
if [[ ! -f "${CERT_FULLCHAIN_PATH}" || ! -f "${CERT_PRIVKEY_PATH}" ]]; then
err "LOCAL_FULLCHAIN/LOCAL_PRIVKEY set but files missing."
exit 1
fi
FULLCHAIN_PEM=$(cat "${CERT_FULLCHAIN_PATH}")
PRIVKEY_PEM=$(cat "${CERT_PRIVKEY_PATH}")
log "Using local cert files: ${CERT_FULLCHAIN_PATH}, ${CERT_PRIVKEY_PATH}"
elif [[ -n "${CERT_SOURCE_SSH_USER}" ]]; then
log "Fetching cert from ${CERT_SOURCE_HOST} (${CERT_SOURCE_TARGET}) via SSH..."
out=$(fetch_certs_via_ssh)
FULLCHAIN_PEM=$(echo "$out" | sed -n '1,/---KEY---/p' | sed '/---KEY---/d')
PRIVKEY_PEM=$(echo "$out" | sed -n '/---KEY---/,$p' | sed '1d')
else
# Try local directory (CERT_SOURCE_DIR or Certbot-style path)
log "Fetching cert from local cert paths..."
out=$(fetch_certs_local)
FULLCHAIN_PEM=$(echo "$out" | sed -n '1,/---KEY---/p' | sed '/---KEY---/d')
PRIVKEY_PEM=$(echo "$out" | sed -n '/---KEY---/,$p' | sed '1d')
fi
if [[ -z "${FULLCHAIN_PEM}" || -z "${PRIVKEY_PEM}" ]]; then
err "Could not read certificate or private key."
exit 1
fi
# --- AdGuard API: get current TLS config, then set with new cert ---
ADGUARD_PROTO="${ADGUARD_USE_HTTPS,,}"
[[ "$ADGUARD_PROTO" = "true" ]] && ADGUARD_PROTO="https" || ADGUARD_PROTO="http"
ADGUARD_BASE="${ADGUARD_PROTO}://${ADGUARD_TARGET}:${ADGUARD_PORT}"
CURL_OPTS=(-s -S --connect-timeout 10 --max-time 30)
[[ "${ADGUARD_INSECURE}" = "true" ]] && CURL_OPTS+=(--insecure)
[[ -n "${ADGUARD_USER}" ]] && CURL_OPTS+=(-u "${ADGUARD_USER}:${ADGUARD_PASS}")
get_tls_status() {
curl "${CURL_OPTS[@]}" "${ADGUARD_BASE}/control/tls/status"
}
post_tls_configure() {
local body="$1"
curl "${CURL_OPTS[@]}" -X POST -H "Content-Type: application/json" -d "$body" "${ADGUARD_BASE}/control/tls/configure"
}
# Build JSON: AdGuard expects certificate_chain and private_key base64-encoded (decodes on receipt).
# We need to preserve other TLS settings (enable_https, server_name, port_https, etc.) from current config.
log "Fetching current AdGuard TLS config from ${ADGUARD_BASE}..."
CURRENT=$(get_tls_status) || true
if [[ -z "${CURRENT}" ]]; then
err "Could not reach AdGuard at ${ADGUARD_BASE}. Check host, port, and credentials."
exit 1
fi
# Base64-encode PEM for API (AdGuard returns "failed to base64-decode" if given raw PEM)
CHAIN_B64=$(echo "$FULLCHAIN_PEM" | base64 -w 0 2>/dev/null || echo "$FULLCHAIN_PEM" | base64 | tr -d '\n')
KEY_B64=$(echo "$PRIVKEY_PEM" | base64 -w 0 2>/dev/null || echo "$PRIVKEY_PEM" | base64 | tr -d '\n')
# Build new config: start from current JSON, overlay cert + key + enable_https, and optionally pin TLS settings from env.
# Only certificate_chain, private_key, and enable_https are always overwritten; other fields come from GET (which can be minimal).
# Set ADGUARD_TLS_SERVER_NAME / ADGUARD_PORT_HTTPS / ADGUARD_FORCE_HTTPS in .env to pin those values so they are never lost.
if command -v jq &>/dev/null; then
NEW_CONFIG=$(echo "$CURRENT" | jq --arg chain "$CHAIN_B64" --arg key "$KEY_B64" \
--arg sn "${ADGUARD_TLS_SERVER_NAME}" --arg port_ht "${ADGUARD_PORT_HTTPS}" --arg force_ht "${ADGUARD_FORCE_HTTPS}" '
. + {certificate_chain: $chain, private_key: $key, enable_https: true}
| if $sn != "" then . + {server_name: $sn} else . end
| if $port_ht != "" then . + {port_https: ($port_ht | tonumber)} else . end
| if $force_ht == "true" then . + {force_https: true} elif $force_ht == "false" then . + {force_https: false} else . end')
else
# Minimal JSON: base64 strings + explicitly enable HTTPS (no env overrides without jq)
chain_escaped=$(echo "$CHAIN_B64" | sed 's/\\/\\\\/g; s/"/\\"/g')
key_escaped=$(echo "$KEY_B64" | sed 's/\\/\\\\/g; s/"/\\"/g')
NEW_CONFIG="{\"certificate_chain\":\"${chain_escaped}\",\"private_key\":\"${key_escaped}\",\"enable_https\":true}"
log "jq not found; consider installing jq so current TLS settings are preserved."
fi
if [[ "$DRY_RUN" = true ]]; then
log "Dry run: would POST new TLS config to AdGuard (cert length ${#FULLCHAIN_PEM}, key length ${#PRIVKEY_PEM})."
exit 0
fi
log "Applying new TLS certificate to AdGuard..."
RESPONSE=$(post_tls_configure "$NEW_CONFIG") || true
if echo "$RESPONSE" | grep -q '"certificate_chain"'; then
log "AdGuard TLS config updated successfully."
else
err "AdGuard may have rejected the config. Response: ${RESPONSE}"
exit 1
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment