What stays the same as Let’s Encrypt

If you have ever obtained a Let’s Encrypt certificate, you can already use the internal CA. The protocol is identical — ACME RFC 8555. The clients you already know work without modification.

Same:

  • HTTP-01 and DNS-01 challenges.
  • Account registration and key management.
  • 90-day default certificate lifetime.
  • Automatic renewal at 60 days.
  • Wildcard certificates via DNS-01.

What is different

Two small differences, both one-time setup steps:

  1. The ACME directory URL points to the internal CA, not https://acme-v02.api.letsencrypt.org/directory.
  2. The root certificate of the internal CA must be trusted by the requesting client. This is normally arranged via configuration management (Ansible, Puppet, MDM) at host provisioning.

That is the entire difference. Everything else is the same Let’s Encrypt workflow you already know.

Pointing your client at the internal CA

The internal ACME endpoint is published at a stable internal URL (typically https://acme.example.internal/acme/acme/directory, but the exact path depends on your deployment). Below are common-client recipes.

certbot

certbot --server https://acme.example.internal/acme/acme/directory \
        --email ops@example.internal \
        --no-eff-email \
        --agree-tos \
        certonly --standalone -d service.example.internal

For renewal, the --server flag is persisted in renewal/*.conf, so the periodic certbot renew cron will pick it up automatically.

acme.sh

acme.sh --register-account \
        --server https://acme.example.internal/acme/acme/directory \
        -m ops@example.internal

acme.sh --issue \
        --server https://acme.example.internal/acme/acme/directory \
        --standalone \
        -d service.example.internal

lego

lego --server=https://acme.example.internal/acme/acme/directory \
     --email=ops@example.internal \
     --domains=service.example.internal \
     --http run

Caddy

In your Caddyfile:

{
  acme_ca https://acme.example.internal/acme/acme/directory
  email ops@example.internal
}

service.example.internal {
  reverse_proxy localhost:8080
}

Caddy will automatically obtain and renew the certificate.

Traefik

In your dynamic configuration:

certificatesResolvers:
  internal:
    acme:
      caServer: "https://acme.example.internal/acme/acme/directory"
      email: ops@example.internal
      storage: /etc/traefik/acme.json
      httpChallenge:
        entryPoint: web

Reference certResolver: internal from your router definitions.

Installing the root certificate on clients

For the certificates the CA issues to be trusted without warnings, the CA’s root must be in the system trust store of every host that will see the certificates.

Linux (Debian/Devuan family)

sudo cp internal-ca-root.crt /usr/local/share/ca-certificates/internal-ca-root.crt
sudo update-ca-certificates

Linux (RHEL family)

sudo cp internal-ca-root.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust extract

OpenBSD / FreeBSD

# OpenBSD
sudo install -m 0644 internal-ca-root.crt /etc/ssl/internal-ca-root.crt
sudo cat /etc/ssl/cert.pem internal-ca-root.crt > /etc/ssl/cert.pem.new
sudo mv /etc/ssl/cert.pem.new /etc/ssl/cert.pem

macOS

sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain internal-ca-root.crt

Windows

certutil.exe -addstore -f "ROOT" internal-ca-root.crt

For browsers that maintain their own trust stores (Firefox), the root must additionally be imported via Settings → Privacy → Certificates → Authorities.

This installation is normally handled by configuration management. Doing it by hand is fine for a one-off but does not scale to a fleet.

Wildcard certificates

Wildcards are issued via the DNS-01 challenge — the client proves control of a domain by creating a TXT record. This works for internal-only domains because the internal CA can validate TXT records on any DNS server you control.

Example with acme.sh using the BIND nsupdate provider:

acme.sh --issue \
        --server https://acme.example.internal/acme/acme/directory \
        --dns dns_nsupdate \
        -d '*.example.internal' \
        -d 'example.internal'

You will need the nsupdate provider to be configured against your internal DNS — see the acme.sh documentation for the specific environment variables.

Renewal

Renewal works the same way as with Let’s Encrypt: the client checks remaining validity and renews when below the threshold (60 days for a 90-day cert).

For most deployments, the existing renewal cron (certbot renew, acme.sh --cron, Caddy’s automatic renewal) just works.

If you see renewal failures:

  1. Is the directory URL still reachable? A network change may have broken the path from the client to the CA.
  2. Has the root certificate expired or been rotated? Roots are typically valid for 10 years; intermediates for shorter periods. A rotation that did not propagate to all clients will manifest as renewal failures.
  3. Is the challenge type still appropriate? A service that used to be reachable for HTTP-01 may now sit behind a load balancer that breaks the challenge response.

Limits and policies

The internal CA enforces per-account policies — what domains each account may request certificates for, what maximum lifetime is allowed, what challenge types are accepted.

If a request that worked yesterday suddenly fails with a policy denied error, the policy has changed (or your account binding has). Contact your CA operator. The Operations service is the escalation path if your CA operator is us.