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:

  1. Your ACME client (lego, acme.sh, certbot) creates the private key and the certificate request on your machine.
  2. 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.
  3. Let's Encrypt checks the record and issues the certificate to your client.
  4. 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>
  • present publishes 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.
  • cleanup removes the record. If the record is already gone, it still succeeds.

Rules and limits

RuleValue
Accepted names_acme-challenge.{account}.e2e.nullbore.com (wildcard cert) or _acme-challenge.{tunnel}.{account}.e2e.nullbore.com
AccountMust be your own account subdomain
Challenge value1–128 characters from A–Z a–z 0–9 _ -
Record lifetimeRemoved automatically 1 hour after it was published
Live recordsAt most 10 per user
Publish rateAbout 30 per hour per user (cleanup is not limited)
PlanPaid 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)
StatusMeaning
400Malformed name or value
403Name isn't under your account's end-to-end zone, no account subdomain, or a free plan
429Too many publishes, or 10 live records already
501This server has no DNS delegation configured (for example, self-hosted without a dashboard)
502The 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.