On this page
Automating Certificate Management for Self-Hosted Cloudflare Tunnel Alternatives with ACME.sh: A Step-by-Step Guide
If you’ve outgrown Cloudflare Tunnel (formerly Argo Tunnel) — or prefer full control over your edge infrastructure — you’re likely building a self-hosted tunnel alternative. Tools like Caddy, Nginx Proxy Manager, or WireGuard (with HTTP(S) tunneling) are popular choices.
But here’s the hard truth: managing TLS certificates manually for these setups quickly becomes a nightmare. Expiry alerts, DNS propagation delays, and renewal failures are not theoretical — they’re daily operational hazards.
Enter ACME.sh — a pure POSIX shell implementation of the ACME protocol. It’s lightweight, dependency-light, and designed for automation. In this guide, we’ll walk through how to use ACME.sh with DNS-01 validation to automatically obtain and renew TLS certificates for your self-hosted tunnel infrastructure — no root access required, and no browser popups.
Note: DNS-01 is the only reliable option for headless or remote VPS/bare-metal setups where HTTP-01 challenges (port 80) may be blocked or inaccessible. It’s also essential if your tunnel frontends don’t expose a public HTTP endpoint (e.g., WireGuard + TLS termination elsewhere).
Why ACME.sh Over Certbot?
Before we dive in, a quick reality check:
| Feature | ACME.sh | Certbot |
|---|---|---|
| Language | Pure shell (bash/dash/sh) | Python |
| Dependencies | Minimal (curl, openssl) | Python, pip, virtualenv |
| DNS API Support | 90+ providers (via --dns flags) |
Limited (via plugins, often outdated) |
| Renewal Scripting | Built-in hooks (--renew-hook) |
Possible, but less flexible |
| Container Friendly | Runs in Alpine, BusyBox, etc. | Heavy for minimal containers |
| Learning Curve | Slightly steeper at first | Easier initial setup |
For infrastructure-as-code, CI/CD, or headless servers — ACME.sh wins. It’s also the only option if you’re using a DNS provider without official Certbot plugins (looking at you, Cloudflare, but also Namecheap, Porkbun, Cloudns, etc.).
Step 1: Install ACME.sh
Assuming you’re on a modern Linux server (Ubuntu, Debian, Rocky, etc.):
# Install via official installer (safe, no sudo needed)
curl -fsSL https://get.acme.sh | sh -s -- --home ~/acme.sh
This installs ACME.sh to ~/acme.sh and creates a cron job for automatic renewal.
Pro tip: Add
source ~/.bashrcorsource ~/.zshrcif you don’t want to log out/in to getacme.shin yourPATH.
Step 2: Set Up DNS API Credentials
DNS-01 validation requires programmatic access to your DNS provider. Let’s use Cloudflare as an example — but the process is nearly identical for others.
For Cloudflare:
- Generate a Global API Key (not API Token — it has insufficient scope for this).
- Export it:
export CF_Key="your_global_api_key_here"
export CF_Email="[email protected]"
⚠️ Never hardcode secrets in scripts. Use environment variables or a
.envfile with strictchmod 600.
For Other Providers
ACME.sh supports 90+ DNS providers out-of-the-box. Run:
acme.sh --list-providers
Examples:
--dns dns_aws--dns dns_cloudns--dns dns_gd--dns dns_route53
Check the full list here.
Step 3: Issue a Certificate (With DNS-01)
Let’s say your tunnel domain is tunnel.example.com.
acme.sh --issue --dns dns_cf \
-d tunnel.example.com \
--key-length 2048 \
--ca-bundle /etc/ssl/certs/ca-certificates.crt
--ca-bundleensures compatibility with older clients (e.g., older Android, legacy corporate proxies).
On first run, ACME.sh will:
- Generate a CSR.
- Create a DNS TXT record
_acme-challenge.tunnel.example.com. - Wait for propagation (it polls until visible).
- Validate and fetch the cert.
Once complete, certificates are stored under ~/.acme.sh/tunnel.example.com/.
Key Files:
tunnel.example.com.key→ Private key (PEM)tunnel.example.com.cer→ Certificate (PEM, includes chain)fullchain.cer→ Full chain (cert + intermediate) — use this for most servers
Step 4: Auto-Renewal (The Real Win)
ACME.sh automatically installs a cron job that checks for renewals daily. But you can also trigger renewals manually with:
acme.sh --renew -d tunnel.example.com
Custom Hooks for Integration
This is where automation shines. Let’s integrate with your tunnel tools.
For Caddy (v2+)
Caddy supports ACME natively — but if you want manual control (e.g., to avoid Caddy writing certs to disk), use a --deploy-hook:
acme.sh --issue --dns dns_cf -d tunnel.example.com \
--deploy-hook caddy
Or manually deploy:
acme.sh --install-cert -d tunnel.example.com \
--cert-file /etc/caddy/certs/tunnel.example.com.crt \
--key-file /etc/caddy/certs/tunnel.example.com.key \
--fullchain-file /etc/caddy/certs/tunnel.example.com.fullchain.crt \
--reloadcmd "sudo systemctl reload caddy"
--reloadcmdruns after cert install. Adjustsudoand service names per your setup.
For Nginx Proxy Manager (NPM)
NPM expects certs in /data/nginx/cert/. You can use a deploy hook script:
# Create hook script
cat > ~/deploy-npm.sh << 'EOF'
#!/bin/sh
cp "$1" /data/nginx/cert/public.crt
cp "$2" /data/nginx/cert/private.key
cp "$3" /data/nginx/cert/ca.crt
# Restart NPM container
docker restart npm_app_1 # or your container name
EOF
chmod +x ~/deploy-npm.sh
Then issue with:
acme.sh --issue --dns dns_cf -d tunnel.example.com \
--deploy-hook "bash ~/deploy-npm.sh"
For WireGuard + TLS Termination (e.g., via stunnel, HAProxy, or nginx)
WireGuard itself doesn’t do TLS — you’ll likely have a reverse proxy in front. Example with stunnel:
# Create a deploy hook
cat > ~/deploy-stunnel.sh << 'EOF'
#!/bin/sh
cat "$1" > /etc/stunnel/tunnel.example.com.crt
cat "$2" >> /etc/stunnel/tunnel.example.com.crt
cp "$4" /etc/stunnel/tunnel.example.com.key # fullchain + privkey
systemctl reload stunnel@tunnel
EOF
chmod +x ~/deploy-stunnel.sh
Then:
acme.sh --issue --dns dns_cf -d tunnel.example.com \
--deploy-hook "bash ~/deploy-stunnel.sh"
Always test hooks in dry-run mode first (
--debug) to avoid breaking production.
Step 5: Security Hardening
- Rotate keys: Use
--key-length 4096(if your clients support it — modern browsers do). - Pin renewal: Ensure your cron runs as a non-root user with least privilege.
- Audit logs: Log
acme.sh --issueoutput to a dedicated file. - Limit permissions: Don’t run ACME.sh as
rootunless absolutely necessary.
Common Pitfalls (And How to Avoid Them)
1. DNS Propagation Delays
ACME.sh auto-waits for DNS propagation — but if your provider is slow (looking at GoDaddy), add:
--dnssleep 60 # wait 60 seconds after DNS update
2. Rate Limits
Let’s Encrypt limits to:
- 50 certs per domain per week
- 300 new orders per 3 hours
Use --staging for testing:
acme.sh --issue --dns dns_cf -d tunnel.example.com --staging
3. TXT Record Conflicts
If you already have _acme-challenge records (e.g., from a previous cert), ACME.sh will overwrite them. This is intentional — but if you run multiple tools, coordinate.
Tip: Use a subdomain like
certs.tunnel.example.comfor DNS-01 challenges to isolate them.
4. Timezone & System Clock
If your server time is off by >15 minutes, Let’s Encrypt will reject the challenge. Run:
timedatectl status
sudo timedatectl set-ntp true
Integration Comparison: Which Tool Fits Best?
| Tool | Cert Integration Method | Best With ACME.sh? | Notes |
|---|---|---|---|
| Caddy | Built-in ACME or manual reload | ✅ Yes | --deploy-hook caddy works out-of-the-box |
| Nginx Proxy Manager | Manual file copy + restart | ✅ Yes | Use --deploy-hook script |
| Traefik | ACME plugin or file-based | ⚠️ Tricky | Prefer Traefik’s native ACME unless headless |
| WireGuard + stunnel/HAPROXY | Manual cert reload | ✅ Yes | Use custom --deploy-hook script |
| Apache | a2enmod ssl, reload |
✅ Yes | --reloadcmd "systemctl reload apache2" |
FAQ
1. Can I use ACME.sh without root access?
Yes. ACME.sh runs as any user. Just ensure the target service (Nginx, Caddy, etc.) can read the certs — either by changing ownership (chown) or using a group (e.g., www-data).
2. Does ACME.sh support wildcard certs?
Yes — but only with DNS-01. Example:
acme.sh --issue --dns dns_cf -d "*.example.com" -d example.com
Note: You must include both *.example.com and example.com if you want the bare domain.
3. How long do certificates last?
Let’s Encrypt issues 90-day certs. ACME.sh renews at ~60 days. You can check expiry with:
openssl x509 -in ~/.acme.sh/tunnel.example.com/tunnel.example.com.cer -noout -dates
4. What if my DNS provider isn’t in the ACME.sh list?
You can write a custom DNS API script. ACME.sh provides a template. Most providers expose REST APIs — so it’s usually 50–100 lines of shell.
5. Should I use DNS-01 or HTTP-01?
- DNS-01: Best for headless, remote, or non-HTTP services (like WireGuard).
- HTTP-01: Faster, but only works if port 80 is open and reachable.
If you’re building a tunnel, DNS-01 is the only sane choice.
Wrapping Up
Self-hosting your tunnel infrastructure gives you control, performance, and cost savings — but only if you automate the boring parts. TLS certificate management is where many projects stall. ACME.sh, combined with DNS-01 validation, turns this from a weekly chore into a set-and-forget process.
Start small: issue one cert manually. Then automate renewal. Then add hooks for your stack. Soon, your certs will renew themselves — while you sleep.
If you run into issues, check the ACME.sh GitHub issues — most edge cases are already documented there.
And if you found this useful, drop by mahbuburriad.com — I share more hands-on sysadmin deep dives like this, no fluff.