End-to-End TLS & Trusted Certificates
Paid plan feature. Needs a claimed account subdomain and nullbore client v0.1.0-beta.24 or newer.
A normal tunnel ends TLS at the NullBore relay: the relay decrypts each request, then forwards it to your machine. An end-to-end tunnel (--tls-passthrough) does not. The relay reads only the hostname from the TLS handshake (SNI) and forwards the encrypted bytes unchanged. Only your local service can decrypt them.
nullbore open --port 8443 --name web --tls-passthrough
# → https://web.heroapp.e2e.nullbore.com → localhost:8443 (TLS served by your service)
End-to-end hostnames live in their own namespace, {tunnel}.{account}.e2e.nullbore.com. Its DNS is not proxied by a CDN, so the TLS handshake reaches the relay intact.
Your local service has to present its own certificate. A self-signed certificate is fine for curl -k. Browsers and iOS apps reject it, though, and many mobile clients can't pin one. For those clients you need a publicly trusted certificate, and NullBore helps you get one without ever seeing your private key.
How it works
NullBore manages the DNS for *.e2e.nullbore.com, so you can't publish the _acme-challenge TXT record that Let's Encrypt's DNS-01 challenge checks. NullBore publishes it for you:
- Your ACME client (lego, acme.sh, certbot) creates the private key and the certificate request on your machine.
- Let's Encrypt gives your client a challenge value. The client runs
nullbore acme present <fqdn> <value>, and NullBore publishes that value as a TXT record. - Let's Encrypt checks the record and issues the certificate to your client.
- The client runs
nullbore acme cleanup <fqdn> <value>, and NullBore removes the record.
Only the challenge value is sent to NullBore. The private key, the certificate and your ACME account never leave your machine, and the relay never holds a certificate for your end-to-end names. The relay still sees the hostname (SNI), timing and traffic volume.
Use a wildcard certificate
Get one wildcard certificate, *.{account}.e2e.nullbore.com, rather than a certificate per tunnel:
- One certificate covers every end-to-end tunnel you open, including ones you add later.
- Let's Encrypt limits how many new certificates are issued per registered domain each week, and every NullBore account shares
nullbore.com. Renewals are exempt from that limit. So issue a wildcard once and renew it, rather than issuing new certificates over and over. - Test your setup against Let's Encrypt staging first (shown below), so mistakes don't count against production limits.
A certificate for a single tunnel (web.{account}.e2e.nullbore.com) also works.
In the examples below, replace heroapp with your account subdomain.
lego
lego's exec DNS provider runs $EXEC_PATH present|cleanup <fqdn> <value>, which is the same argument order nullbore acme uses. EXEC_PATH must point to a single executable, so create a small wrapper script:
sudo tee /usr/local/bin/nullbore-acme-hook >/dev/null <<'EOF'
#!/bin/sh
exec nullbore acme "$@"
EOF
sudo chmod +x /usr/local/bin/nullbore-acme-hook
Issue the certificate (drop --server once staging works):
export NULLBORE_API_KEY=nbk_...
EXEC_PATH=/usr/local/bin/nullbore-acme-hook \
lego run --accept-tos \
--path ~/.lego \
--dns exec \
--domains '*.heroapp.e2e.nullbore.com' \
--server https://acme-staging-v02.api.letsencrypt.org/directory
This is lego 5 syntax: options come after run. On lego 4, put the options first and run last (lego --dns exec --domains ... run).
lego writes ~/.lego/certificates/_.heroapp.e2e.nullbore.com.crt (the certificate) and .key (the private key, generated locally — it never leaves your machine). To renew, re-run the same lego run command (for example daily from cron); lego 5 only re-issues when the certificate is close to expiry. On lego 4 use renew instead of run.
acme.sh
Save this hook as ~/.acme.sh/dnsapi/dns_nullbore.sh:
#!/usr/bin/env sh
# acme.sh DNS API hook for NullBore end-to-end names.
# Needs the nullbore CLI on PATH and NULLBORE_API_KEY (or ~/.config/nullbore/config.toml).
dns_nullbore_add() {
fulldomain="$1"
txtvalue="$2"
_info "NullBore: publishing $fulldomain"
nullbore acme present "$fulldomain" "$txtvalue"
}
dns_nullbore_rm() {
fulldomain="$1"
txtvalue="$2"
_info "NullBore: removing $fulldomain"
nullbore acme cleanup "$fulldomain" "$txtvalue"
}
Then issue the certificate (use --server letsencrypt once it works):
export NULLBORE_API_KEY=nbk_...
acme.sh --issue --dns dns_nullbore -d '*.heroapp.e2e.nullbore.com' --server letsencrypt_test
acme.sh installs a cron job that renews the certificate automatically.
certbot
Use certbot's manual hooks:
certbot certonly --manual --preferred-challenges dns \
--manual-auth-hook 'nullbore acme present "_acme-challenge.$CERTBOT_DOMAIN" "$CERTBOT_VALIDATION"' \
--manual-cleanup-hook 'nullbore acme cleanup "_acme-challenge.$CERTBOT_DOMAIN" "$CERTBOT_VALIDATION"' \
-d '*.heroapp.e2e.nullbore.com' --test-cert
The nullbore acme command
nullbore acme present [--no-wait] [--wait-timeout 120s] <fqdn> <value>
nullbore acme cleanup <fqdn> <value>
presentpublishes the record. By default it then waits until public resolvers (1.1.1.1 and 8.8.8.8) return the record, so the ACME client doesn't ask Let's Encrypt to check too early.cleanupremoves the record. If the record is already gone, it still succeeds.
Rules and limits
| Rule | Value |
|---|---|
| Accepted names | _acme-challenge.{account}.e2e.nullbore.com (wildcard cert) or _acme-challenge.{tunnel}.{account}.e2e.nullbore.com |
| Account | Must be your own account subdomain |
| Challenge value | 1–128 characters from A–Z a–z 0–9 _ - |
| Record lifetime | Removed automatically 1 hour after it was published |
| Live records | At most 10 per user |
| Publish rate | About 30 per hour per user (cleanup is not limited) |
| Plan | Paid plans only |
Names and values are case-insensitive, and one trailing dot is ignored. Several values can share one name at the same time. A certificate for both x and *.x needs two.
API
Your ACME tooling can also call the tunnel server directly, with your API key as a Bearer token. These endpoints are under /v1. See Authentication.
# Publish (idempotent: repeating the call returns the existing record)
curl -X POST https://tunnel.nullbore.com/v1/acme/dns-01 \
-H "Authorization: Bearer $NULLBORE_API_KEY" \
-d '{"fqdn": "_acme-challenge.heroapp.e2e.nullbore.com", "value": "LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0"}'
# → {"fqdn": "_acme-challenge.heroapp.e2e.nullbore.com", "value": "LoqX...", "expires_at": "2026-10-06T15:04:05Z"}
# Remove (safe to retry)
curl -X DELETE https://tunnel.nullbore.com/v1/acme/dns-01 \
-H "Authorization: Bearer $NULLBORE_API_KEY" \
-d '{"fqdn": "_acme-challenge.heroapp.e2e.nullbore.com", "value": "LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0"}'
# → {"deleted": true} ({"deleted": false} if it was already gone)
| Status | Meaning |
|---|---|
400 | Malformed name or value |
403 | Name isn't under your account's end-to-end zone, no account subdomain, or a free plan |
429 | Too many publishes, or 10 live records already |
501 | This server has no DNS delegation configured (for example, self-hosted without a dashboard) |
502 | The DNS provider failed. Retry shortly |
Errors come back as {"error": "..."}.
Self-hosted servers
On a self-hosted relay you control the DNS yourself, so you don't need this API there; it returns 501. Point your ACME client's own DNS provider plugin at your zone instead.