for good or ill, @user1072814 inspired me to give it another go, and with a little help form chatgpt (it gets SO much wrong that im not worried about our AI overlords taking over just yet) this is what i ended up with, works on all my devices by using the endpoint given at at end of script
good enough for my meagre needs (ad blocking), but shared here in case anyone benefits - change variables at top of script to suit. This was run and tested on an Oracle free tier Ubuntu minimal setup from bare, remember to check and open any ports you may require in the Oracle pages (as long as you have 80, 443 allowed, youre golden). Edit the toml block for dnscrypt-prozy below
# dnscrypt-proxy config - for server-side DoH TLS
as you see fit for your own upstream server (default is cloudflare, again im only using this for centralised adblocking and not tin foil hat relays and anonymising) and personal dnscrypt options
#!/usr/bin/env bash
# dnscrypt_oneclick_final_doh_direct_b.sh
# One-click installer: dnscrypt-proxy (DoH TLS on 443) + nginx (HTTP only) +
# Let's Encrypt (ECDSA secp256r1) + renewal hook + health monitor + alerts
#
DOMAIN="your domain here"
EMAIL="your email here"
set -euo pipefail
set -x
CERT_DIR="/etc/letsencrypt/live/${DOMAIN}"
WEBROOT="/var/www/html"
WWW_DIR="/var/www/ca"
DNSCRYPT_CONF="/etc/dnscrypt-proxy/dnscrypt-proxy.toml"
DNSCRYPT_USER_FILES="/usr/local/dnscrypt-proxy"
DOH_PORT=443
LOCAL_DOH_PORT=3000
NGINX_HTTP_CONF="/etc/nginx/sites-available/dnscrypt-http.conf"
NGINX_HTTP_ENABLED="/etc/nginx/sites-enabled/dnscrypt-http.conf"
RENEW_HOOK_DIR="/etc/letsencrypt/renewal-hooks/deploy"
HEALTH_SCRIPT="/usr/local/bin/dnscrypt_health.sh"
LOG_FILE="/var/log/dnscrypt_health.log"
if [ "$(id -u)" -ne 0 ]; then
echo "Run as root: sudo $0"
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
echo "=== Installing packages ==="
apt update
apt install -y dnscrypt-proxy nginx openssl curl unzip iptables-persistent netfilter-persistent certbot python3-certbot-nginx mailutils cron
echo "=== Creating webroot & WWW dirs ==="
mkdir -p "${WEBROOT}/.well-known/acme-challenge" "${WWW_DIR}"
chown -R www-data:www-data "${WEBROOT}" "${WWW_DIR}"
# -------------------------
# iptables (safe insert)
# -------------------------
insert_if_missing() {
if iptables -C "$@" 2>/dev/null; then
echo "Rule exists: $*"
else
iptables -I "$@"
echo "Inserted: $*"
fi
}
if ! iptables -C INPUT -j ACCEPT 2>/dev/null; then
iptables -I INPUT -j ACCEPT
fi
insert_if_missing INPUT 6 -m state --state NEW -p tcp --dport 80 -j ACCEPT || true
insert_if_missing INPUT 6 -m state --state NEW -p tcp --dport 443 -j ACCEPT || true
insert_if_missing INPUT 6 -m state --state NEW -p udp --dport 443 -j ACCEPT || true
insert_if_missing INPUT -m state --state NEW -p tcp --dport 22 -j ACCEPT || true
netfilter-persistent save || iptables-save > /etc/iptables/rules.v4
# -------------------------
# dnscrypt-proxy config (minimal + DoH on 0.0.0.0:443)
# -------------------------
echo "=== Backing up and writing dnscrypt-proxy config ==="
[ -f "${DNSCRYPT_CONF}" ] && cp "${DNSCRYPT_CONF}" "${DNSCRYPT_CONF}.bak-$(date +%s)" || true
cat > "${DNSCRYPT_CONF}" <<'TOML'
# dnscrypt-proxy config - for server-side DoH TLS
# local DNS listeners (for local clients and system)
server_names = ['cloudflare']
listen_addresses = ['127.0.0.1:53']
ipv4_servers = true
ipv6_servers = false
dnscrypt_servers = true
doh_servers = true
require_dnssec = true
require_nolog = true
require_nofilter = true
log_file = '/var/log/dnscrypt-proxy/dnscrypt-proxy.log'
log_level = 2
log_file_latest = true
block_ipv6 = true
block_unqualified = true
block_undelegated = true
reject_ttl = 10
cache = true
cache_size = 4096
cache_min_ttl = 2400
cache_max_ttl = 86400
cache_neg_min_ttl = 60
cache_neg_max_ttl = 600
netprobe_address = '1.1.1.1:53'
# External DoH/TLS listener (dnscrypt-proxy will terminate TLS directly on 0.0.0.0:443)
[local_doh]
# listen on all interfaces port 443 for external DoH clients
listen_addresses = ['0.0.0.0:443']
path = '/dns-query'
# cert paths will be populated by installer (letsencrypt files)
cert_file = '/etc/letsencrypt/live/amdnscrypt.ddns.net/fullchain.pem'
cert_key_file = '/etc/letsencrypt/live/amdnscrypt.ddns.net/privkey.pem'
# Optional: also serve a local DoH endpoint (unused by external clients,
# but useful for testing or nginx reverse-proxy if you want)
# Add another local_doh listener if your dnscrypt-proxy supports it (some versions vary)
# local_doh_listen = ['127.0.0.1:3000']
[captive_portals]
map_file = '/usr/local/dnscrypt-proxy/captive-portals.txt'
[blocked_names]
blocked_names_file = '/usr/local/dnscrypt-proxy/blocked-names.txt'
log_file = '/usr/local/dnscrypt-proxy/blocked-names.log'
[blocked_ips]
blocked_ips_file = '/usr/local/dnscrypt-proxy/blocked-ips.txt'
log_file = '/usr/local/dnscrypt-proxy/blocked-ips.log'
[allowed_names]
allowed_names_file = '/usr/local/dnscrypt-proxy/allowed-names.txt'
[allowed_ips]
allowed_ips_file = '/usr/local/dnscrypt-proxy/allowed-ips.txt'
[broken_implementations]
fragments_blocked = [
'cisco',
'cisco-ipv6',
'cisco-familyshield',
'cisco-familyshield-ipv6',
'cisco-sandbox',
'cleanbrowsing-adult',
'cleanbrowsing-adult-ipv6',
'cleanbrowsing-family',
'cleanbrowsing-family-ipv6',
'cleanbrowsing-security',
'cleanbrowsing-security-ipv6',
]
[sources]
[sources.public-resolvers]
urls = [
'https://raw.githubusercontent.com/DNSCrypt/dnscrypt-resolvers/master/v3/public-resolvers.md',
'https://download.dnscrypt.info/resolvers-list/v3/public-resolvers.md'
]
cache_file = 'public-resolvers.md'
minisign_key = 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3'
refresh_delay = 73
prefix = ''
TOML
# stop dnscrypt-proxy so it doesn't try to bind before certs exist
systemctl daemon-reload
systemctl enable dnscrypt-proxy || true
systemctl stop dnscrypt-proxy || true
# -------------------------
# Ensure nginx will not fail loading SSL during bootstrap
# -------------------------
echo "=== moving existing enabled sites aside and disabling SSL confs ==="
mkdir -p /etc/nginx/sites-enabled.bak
if [ -d /etc/nginx/sites-enabled ]; then
for s in /etc/nginx/sites-enabled/*; do
[ -e "$s" ] || continue
mv -f "$s" /etc/nginx/sites-enabled.bak/ || true
done
fi
if [ -d /etc/nginx/conf.d ]; then
for f in /etc/nginx/conf.d/*.conf; do
[ -f "$f" ] || continue
if grep -qi "ssl_certificate" "$f"; then
mv -f "$f" "${f}.disabled-ssl" || true
fi
done
fi
if grep -qi "ssl_certificate" /etc/nginx/nginx.conf 2>/dev/null; then
cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak-$(date +%s)
sed -i '/ssl_certificate/d' /etc/nginx/nginx.conf || true
sed -i '/ssl_certificate_key/d' /etc/nginx/nginx.conf || true
fi
# -------------------------
# Temporary HTTP-only nginx config for ACME
# -------------------------
echo "=== installing temporary HTTP-only nginx config ==="
cat > "${NGINX_HTTP_CONF}" <<NGHTTP
server {
listen 80;
listen [::]:80;
server_name ${DOMAIN};
root ${WWW_DIR};
index index.html;
location /.well-known/acme-challenge/ {
root ${WEBROOT};
}
location / {
return 301 https://\$host\$request_uri;
}
}
NGHTTP
ln -sf "${NGINX_HTTP_CONF}" "${NGINX_HTTP_ENABLED}"
nginx -t
systemctl restart nginx
# -------------------------
# Obtain ECDSA cert with certbot (webroot)
# -------------------------
echo "=== Requesting ECDSA certificate from Let's Encrypt (secp256r1) ==="
certbot certonly --webroot -w "${WEBROOT}" -d "${DOMAIN}" --non-interactive --agree-tos -m "${EMAIL}" --key-type ecdsa --elliptic-curve secp256r1 || {
echo "Certbot issuance failed; inspect /var/log/letsencrypt/letsencrypt.log"
exit 1
}
if [ ! -f "${CERT_DIR}/fullchain.pem" ]; then
echo "Expected certs not found in ${CERT_DIR}; aborting"
exit 1
fi
# fix permissions so dnscrypt-proxy and nginx can read certificate files
chown -R root:root /etc/letsencrypt
chmod 644 "${CERT_DIR}/fullchain.pem" || true
chmod 640 "${CERT_DIR}/privkey.pem" || true
chown root:www-data "${CERT_DIR}/privkey.pem" || true
# -------------------------
# Ensure systemd socket not masked and start dnscrypt-proxy
# -------------------------
echo "=== ensuring dnscrypt-proxy socket is unmasked and starting service ==="
# If systemd socket unit exists and is masked, unmask it
if systemctl list-unit-files | grep -q '^dnscrypt-proxy.socket'; then
sudo systemctl unmask dnscrypt-proxy.socket || true
sudo systemctl enable dnscrypt-proxy.socket || true
sudo systemctl start dnscrypt-proxy.socket || true
fi
# start the dnscrypt-proxy service which will bind 0.0.0.0:443
systemctl restart dnscrypt-proxy || {
# If systemd refuses because socket is masked, try to start service directly after unmasking
systemctl daemon-reload
systemctl unmask dnscrypt-proxy.socket || true
systemctl restart dnscrypt-proxy || true
}
sleep 1
systemctl status dnscrypt-proxy --no-pager || true
# -------------------------
# Finalize nginx (keep HTTP-only)
# -------------------------
echo "=== finalizing nginx (HTTP-only, ACME/static only) ==="
# remove temporary enabled file (site still available in sites-available)
rm -f "${NGINX_HTTP_ENABLED}"
# restore other non-SSL enabled sites from backup if they exist (they were moved aside earlier)
if [ -d /etc/nginx/sites-enabled.bak ]; then
for f in /etc/nginx/sites-enabled.bak/*; do
[ -e "$f" ] || continue
mv -f "$f" /etc/nginx/sites-enabled/ || true
done
fi
nginx -t
systemctl reload nginx
# -------------------------
# Install certbot renewal hook to reload nginx and restart dnscrypt-proxy
# -------------------------
mkdir -p "${RENEW_HOOK_DIR}"
cat > "${RENEW_HOOK_DIR}/reload-dnscrypt-nginx.sh" <<'EOF'
#!/usr/bin/env bash
LOG="/var/log/letsencrypt-renewal-reload.log"
{
echo "[$(date)] deploy hook started: RENEWED_LINEAGE=${RENEWED_LINEAGE}"
systemctl reload nginx || systemctl restart nginx || true
systemctl restart dnscrypt-proxy || true
echo "[$(date)] deploy hook finished"
} >> "$LOG" 2>&1
EOF
chmod +x "${RENEW_HOOK_DIR}/reload-dnscrypt-nginx.sh"
# -------------------------
# Health monitor (self-heal + email) - installed
# -------------------------
cat > "${HEALTH_SCRIPT}" <<'HSH'
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="'"${DOMAIN}"'"
EMAIL="'"${EMAIL}"'"
CERT="/etc/letsencrypt/live/${DOMAIN}/fullchain.pem"
LOG="/var/log/dnscrypt_health.log"
TMPDIR="/var/tmp/dnscrypt_health"
mkdir -p "${TMPDIR}"
touch "${LOG}"
STATE_CERT="${TMPDIR}/cert_alert_sent"
STATE_NGINX="${TMPDIR}/nginx_alert_sent"
STATE_DC="${TMPDIR}/dnscrypt_alert_sent"
send_mail() {
local subject="$1"
local body="$2"
echo -e "${body}" | mail -s "${subject}" "${EMAIL}"
echo "[$(date)] Sent alert: ${subject}" >> "${LOG}"
}
DRY_RUN=0
if [ "${1:-}" = "--dry-run" ]; then
DRY_RUN=1
fi
# Certificate expiry
if [ -f "${CERT}" ]; then
enddate=$(openssl x509 -in "${CERT}" -noout -enddate 2>/dev/null | cut -d= -f2 || echo "")
if [ -n "${enddate}" ]; then
endsec=$(date -d "${enddate}" +%s)
now=$(date +%s)
days_left=$(( (endsec - now) / 86400 ))
else
days_left=0
fi
else
days_left=0
fi
if [ "${days_left}" -lt 10 ]; then
SUBJECT="[ALERT] Certificate for ${DOMAIN} expires in ${days_left} days"
BODY="Certificate for ${DOMAIN} expires in ${days_left} days.\n\nCheck: sudo openssl x509 -in ${CERT} -noout -text\n\nThis is an automated alert."
if [ "${DRY_RUN}" -eq 1 ]; then
echo "DRY RUN: ${SUBJECT}"
echo -e "${BODY}"
else
today=$(date +%F)
if [ ! -f "${STATE_CERT}" ] || [ "$(cat "${STATE_CERT}")" != "${today}" ]; then
send_mail "${SUBJECT}" "${BODY}"
echo "${today}" > "${STATE_CERT}"
fi
fi
fi
attempt_restart_and_check() {
local svc="$1"
local statefile="$2"
echo "[$(date)] Attempting restart: ${svc}" >> "${LOG}"
systemctl restart "${svc}" || true
sleep 5
if systemctl is-active --quiet "${svc}"; then
echo "[$(date)] ${svc} active after restart" >> "${LOG}"
[ -f "${statefile}" ] && rm -f "${statefile}"
return 0
else
echo "[$(date)] ${svc} still down after restart" >> "${LOG}"
return 1
fi
}
# nginx
if ! systemctl is-active --quiet nginx; then
if [ "${DRY_RUN}" -eq 1 ]; then
echo "DRY RUN: nginx inactive"
else
if ! attempt_restart_and_check "nginx" "${STATE_NGINX}"; then
SUBJECT="[ALERT] nginx is not running on ${DOMAIN}"
BODY="nginx is not active on $(hostname) as of $(date). Restart attempts failed.\n\nJournalctl (last 50):\n$(journalctl -u nginx -n 50 --no-pager)\n"
today=$(date +%F)
if [ ! -f "${STATE_NGINX}" ] || [ "$(cat "${STATE_NGINX}")" != "${today}" ]; then
send_mail "${SUBJECT}" "${BODY}"
echo "${today}" > "${STATE_NGINX}"
fi
fi
fi
fi
# dnscrypt-proxy
if ! systemctl is-active --quiet dnscrypt-proxy; then
if [ "${DRY_RUN}" -eq 1 ]; then
echo "DRY RUN: dnscrypt-proxy inactive"
else
if ! attempt_restart_and_check "dnscrypt-proxy" "${STATE_DC}"; then
SUBJECT="[ALERT] dnscrypt-proxy is not running on $(hostname)"
BODY="dnscrypt-proxy is not active on $(hostname) as of $(date). Restart attempts failed.\n\nJournalctl (last 50):\n$(journalctl -u dnscrypt-proxy -n 50 --no-pager)\n"
today=$(date +%F)
if [ ! -f "${STATE_DC}" ] || [ "$(cat "${STATE_DC}")" != "${today}" ]; then
send_mail "${SUBJECT}" "${BODY}"
echo "${today}" > "${STATE_DC}"
fi
fi
fi
fi
exit 0
HSH
chmod +x "${HEALTH_SCRIPT}"
# Cron job every 6 hours
cat > /etc/cron.d/dnscrypt_health <<'CRON'
0 */6 * * * root /usr/local/bin/dnscrypt_health.sh >> /var/log/dnscrypt_health.log 2>&1
CRON
touch "${LOG_FILE}"
chown root:root "${LOG_FILE}"
chmod 644 "${LOG_FILE}"
# Dry-run renewal test
certbot renew --dry-run || echo "certbot dry-run failed - check /var/log/letsencrypt/letsencrypt.log"
# Create directory for extra dnscrypt files (blocked-names.txt. allowed-names.txt etc) and populate with basic files live form dnscrypt github - file paths are set in dnscrypt-proxy.toml
if [ ! -d "$DNSCRYPT_USER_FILES" ]; then
echo "Creating $DNSCRYPT_USER_FILES for block and allowed lists..."
mkdir -p "$DNSCRYPT_USER_FILES"
echo "Downloading basic block and allow lists for domains and ips + captive portal info to $DNSCRYPT_USER_FILES..."
curl -o "$DNSCRYPT_USER_FILES/blocked-names.txt" https://raw.githubusercontent.com/DNSCrypt/dnscrypt-proxy/refs/heads/master/dnscrypt-proxy/example-blocked-names.txt
curl -o "$DNSCRYPT_USER_FILES/blocked-ips.txt" https://raw.githubusercontent.com/DNSCrypt/dnscrypt-proxy/refs/heads/master/dnscrypt-proxy/example-blocked-ips.txt
curl -o "$DNSCRYPT_USER_FILES/allowed-names.txt" https://raw.githubusercontent.com/DNSCrypt/dnscrypt-proxy/refs/heads/master/dnscrypt-proxy/example-allowed-names.txt
curl -o "$DNSCRYPT_USER_FILES/allowed-ips.txt" https://raw.githubusercontent.com/DNSCrypt/dnscrypt-proxy/refs/heads/master/dnscrypt-proxy/example-blocked-names.txt
curl -o "$DNSCRYPT_USER_FILES/captive-portals.txt" https://github.com/DNSCrypt/dnscrypt-proxy/blob/master/dnscrypt-proxy/example-captive-portals.txt
fi
# Final client instructions
cat <<'EOF'
INSTALL COMPLETE
Domain: '"${DOMAIN}"'
DoH endpoint: https://${DOMAIN}/dns-query (dnscrypt-proxy terminates TLS on 443)
Nginx: HTTP-only for ACME & static files (port 80)
Alerts to: ${EMAIL}
Browser instructions:
1) Firefox Desktop:
Preferences → Settings → Network Settings → Enable DNS over HTTPS → Custom: https://${DOMAIN}/dns-query
2) Firefox Android:
Settings → General → Network Settings → Use custom DoH: https://${DOMAIN}/dns-query
3) Chrome Desktop:
Settings → Privacy and security → Security → Use secure DNS → Custom: https://${DOMAIN}/dns-query
4) Chrome Android:
Settings → Privacy and security → Use secure DNS → Custom provider: https://${DOMAIN}/dns-query
EOF
echo "=== DONE ==="