Skip to content

// Engineering Log

HTTPS and Web Server in Practice: Part 6 — HTTPS on an Internal Network: mkcert, the private CA step-ca, and Let's Encrypt via DNS

Published on 2026-11-11 · Author:

// Fast route

This article belongs to the topic Security and protection.

Internal services — Grafana, Proxmox, NAS, router web UI, test bench — often run over HTTP or use a self-signed certificate, and users get used to clicking “Proceed anyway”. That’s a bad habit: the admin password goes over the network in clear text, and nobody will notice a real certificate spoofing warning. Also, browsers are increasingly strict about HTTP: some features (Service Workers, access to the clipboard, camera, Secure cookie) work only in a secure context, i.e. over HTTPS or on localhost. Below are three ways to get proper certificates for an internal network and how to choose between them.

Choosing a domain zone

First — how to name internal hosts. This determines which certificates you can obtain.

  • A subdomain of your public domain (grafana.int.example.ru) — the best option. You can get a Let’s Encrypt certificate for it, and you can serve internal names only to internal clients using DNS split (split DNS).
  • .internal — a zone ICANN reserved in 2024 for private networks: it will never appear on the public internet. Public CAs do not issue certificates for it — you need your own CA.
  • .home.arpa — the zone for home networks (RFC 8375), also requires your own CA.
  • Do not use .local (used by mDNS/Bonjour — you will have name resolution conflicts), made-up zones like .lan, .corp, .office (any of them can be delegated on the internet, as happened with .dev) or other people’s domains.

Method 1. mkcert — for development on your own computer

mkcert creates a local certificate authority, adds it to system and browser stores and issues certificates for any names.

bash
brew install mkcert nss          # macOS; nss is needed for Firefox
mkcert -install                  # create a CA and add it to trust
mkcert app.test localhost 127.0.0.1 ::1
# will create app.test+3.pem and app.test+3-key.pem

Attach the certificate to your development server (Vite: server.https, Nginx: ssl_certificate), and the browser will open https://app.test without warnings.

Limitations:

  • only for your computer: other devices do not have this CA;
  • the CA private key is stored in $(mkcert -CAROOT) — do not share it with anyone and do not use mkcert for shared servers;
  • to open the site from your phone, you’ll need to install the CA on the phone — method 2 is better for that.

Method 2. Your own CA using step-ca

step-ca (Smallstep) is an open-source certificate authority that issues certificates using the same ACME protocol as Let’s Encrypt. For internal services you get the same automation — certbot, Caddy, Traefik, cert-manager — but from your own CA.

Installation and initialization:

bash
step ca init --name "Example Internal CA" --dns ca.example.internal \
  --address :9000 --provisioner admin@example.ru
step ca provisioner add acme --type ACME
step-ca $(step path)/config/ca.json      # start; for persistent operation — systemd service

Initialization creates a root and an intermediate certificate. You can keep the root offline, and the CA will sign certificates with the intermediate.

Issuing a certificate via ACME:

bash
# certbot must trust your CA
export REQUESTS_CA_BUNDLE=/usr/local/share/ca-certificates/example-internal-root.crt

certbot certonly --standalone -d grafana.example.internal \
  --server https://ca.example.internal:9000/acme/acme/directory

In Caddy it’s enough to point the CA in the global settings:

{
    acme_ca https://ca.example.internal:9000/acme/acme/directory
    acme_ca_root /etc/ssl/example-internal-root.crt
}
grafana.example.internal {
    reverse_proxy 127.0.0.1:3000
}

Short lifetimes. By default step-ca issues certificates for 24 hours. That’s fine: renewal is automatic (certbot renew, built-in ACME or step ca renew --daemon), and the short lifetime removes revocation problems. If some device only supports manual certificate upload (switch web UI, UPS), increase the lifetime in the CA settings or issue a certificate separately for that device.

Name restrictions. Be sure to restrict the CA to your zones — then even if its key is leaked it cannot sign a certificate for sberbank.ru or google.com. In step-ca this is done by a policy in ca.json:

json
"policy": {
  "x509": {
    "allow": { "dns": ["*.example.internal", "*.int.example.ru"] }
  }
}

For full protection, add name constraints to the intermediate certificate itself when issuing it — then clients will validate them, not only the CA.

Distributing the root certificate. The root must be added to trust stores on all devices:

  • Windows — Group Policy (Computer Configuration → Policies → Security Settings → Public Key Policies → Trusted Root Certification Authorities);
  • macOS and iOS — MDM profiles;
  • Linux servers — via Ansible or image: place the file in /usr/local/share/ca-certificates/ and run update-ca-certificates;
  • Docker images — add it in the Dockerfile the same way; otherwise services inside containers will not be able to talk to each other over HTTPS;
  • Firefox — policy Certificates.ImportEnterpriseRoots or a separate import;
  • Java — keytool -importcert -cacerts.

How to install root certificates on different systems is covered in detail using the Ministry of Digital Development certificate in the article HTTPS and the Web Server in Practice: Part 5 — What Is the Ministry of Digital Development’s Certificate (Russian Trusted Root CA) and How to Install It.

Method 3. Let’s Encrypt via DNS-01 for internal names

If internal hosts are subdomains of a public domain, you don’t need your own CA at all. DNS-01 validation does not require the server to be accessible from the internet — only the ability to create a TXT record in the public zone (HTTP, HTTPS and TLS: Part 11 — Let’s Encrypt Certificates: ACME, domain validation, CAA and Certificate Transparency).

bash
certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/.cf.ini \
  -d grafana.int.example.ru

Advantages: certificates are trusted by all devices without installing a root, including guests’ phones, TVs, and other people’s laptops.

Disadvantages:

  • names are logged in Certificate Transparency. Every certificate for grafana.int.example.ru is visible to everyone via crt.sh. If you don’t want to disclose the list of internal services, issue a single wildcard *.int.example.ru and deploy it on an internal proxy;
  • you need an API for your DNS provider with a key stored inside the internal network; restrict it to a single zone or delegate _acme-challenge to a separate zone;
  • A records for internal names must not point to internal addresses in the public DNS — use an internal DNS server (split DNS) or serve them only from an internal resolver.

A convenient setup: a single reverse proxy inside the internal network (Caddy, Traefik, Nginx) with a wildcard certificate, and all services behind it over HTTP. The certificate is renewed in one place, and services don’t need to implement TLS.

What to choose

TaskSolution
Development on your own computermkcert
Home network or small office, you have a public domainLet’s Encrypt via DNS-01, wildcard on a reverse proxy
Company, many services, names must remain private, mTLS neededyour own CA on step-ca with ACME and name restrictions
Names in .internal or .home.arpaonly your own CA
A device that doesn’t support ACME or certificate uploadput a reverse proxy in front of it

Common mistakes

  • A self-signed certificate on every service and the habit of confirming warnings — you lose protection against spoofing.
  • Running your own CA without name restrictions — its key can be used to sign certificates for any site on the internet for all company devices.
  • Root installed on workstations but not in Docker images or Java — services cannot talk to each other.
  • Using .local or a made-up zone for internal names — conflicts with mDNS and the risk the zone will be delegated on the internet.
  • A Let’s Encrypt certificate for every internal name — the full list of internal services is publicly available via CT logs.
  • The CA key for mkcert or step-ca stored in a shared folder or in the repository.

// Similar task

If you are dealing with something similar

This article belongs to one of the main working topics. You can keep reading on the topic, go to the homepage to understand what I do, or open the service pages directly.

Article topic

Security and protection

SSL, hardening, access control, service protection, and secure configurations.

Typical tasks behind this topic

  • Set up SSL, certificates, and secure connections
  • Restrict access and close unnecessary entry points
  • Harden server and service configuration

// Next step

If you need help with this topic, not just another article, it is better to go straight to the service page. The homepage and topic collection stay available as secondary routes.

Open services

// Contact

Need help?

Get in touch with me and I'll help solve the problem

I reply within one business day (03:00-13:00 GMT)

Or leave a request here:

Confirm that you are not a bot.

Write and get a quick reply