In the Let’s Encrypt howto we got a public certificate by opening port 80 to the world. But for the services you don’t want (or can’t) expose to the Internet — Webfig, the API, an internal VPN, a server that lives only on the LAN — there is a much cleaner way. Since ACME is a standard, the client built into RouterOS isn’t tied to Let’s Encrypt: you can point it at a CA of your own, inside your network. The router obtains and renews on its own the certificates of your internal PKI, with the same automation, without opening anything outward. We build it for real in the lab.
Background reference: Certificates, CAs and the chain of trust — root, intermediate, leaf, trust store, ACME and its challenges.
Step 1: when an internal CA makes sense
A public CA like Let’s Encrypt is perfect for what is exposed on the Internet with a public name. But it has two requirements that become awkward on the LAN: a public DNS name and a challenge reachable from outside (port 80, or a public DNS record). For a router’s management panel, or for a service on a name like router.company.internal, none of this exists nor should it.
The solution is an internal CA: your own Certificate Authority that issues certificates for your internal names. The key to everything is a single parameter of the ACME command, the directory-url: you point it at your CA instead of Let’s Encrypt, and the rest of the mechanism (issuance, automatic renewal) is identical.
Step 2: the internal CA on Debian/Ubuntu, step by step
We need an ACME server on a small Linux box on the LAN. We use step-ca by smallstep, which speaks ACME natively (alternatives: the CA built into Caddy, or Boulder, which is literally the software Let’s Encrypt runs on).
1. Install the two binaries: the step CLI and the step-ca server. From the official releases (replace the versions with the latest):
CA=0.30.2 ; CLI=0.31.0 ; ARCH=amd64
curl -LO https://github.com/smallstep/certificates/releases/download/v$CA/step-ca_linux_${CA}_${ARCH}.tar.gz
curl -LO https://github.com/smallstep/cli/releases/download/v$CLI/step_linux_${CLI}_${ARCH}.tar.gz
tar xzf step-ca_linux_*.tar.gz ; tar xzf step_linux_*.tar.gz
sudo install step-ca_*/bin/step-ca step_*/bin/step /usr/local/bin/
step version # check
(Alternatively, smallstep also publishes .deb packages.)
2. Initialize the CA: it creates the hierarchy (root + intermediate) and enables an ACME provisioner. You’ll be asked for a password to protect the keys:
step ca init \
--deployment-type standalone \
--name "Company Internal CA" \
--dns ca.company.internal \
--address :9000 \
--provisioner admin@company.internal \
--acme
3. Start the server. For a quick test, in the foreground:
step-ca $(step path)/config/ca.json
For a real install, as a systemd service (so it restarts on its own; the key password in a file read at startup):
[Unit]
Description=step-ca
After=network.target
[Service]
ExecStart=/usr/local/bin/step-ca /etc/step-ca/config/ca.json --password-file /etc/step-ca/pw
Restart=on-failure
[Install]
WantedBy=multi-user.target
From here the CA is listening and its ACME directory answers at https://ca.company.internal:9000/acme/acme/directory. The root just created is your trust anchor: the file that will make every certificate you issue trusted on your devices (that’s the next step).
Step 3: teaching the router to trust your CA
This is the conceptually most important step, and it’s what distinguishes a legitimate private PKI from a deception. The router — like any device — will trust your CA only if its root is in its trust store. And you put it there yourself, because the router is yours. You upload the root onto the router and import it:
/certificate import file-name=root_ca.crt passphrase=""
/certificate print where common-name~"Internal CA"
# NAME TRUST-STORE COMMON-NAME
0 T root_ca.crt_0 all Company Internal CA Root CA
The T flag says the router now trusts that CA. (It’s needed also because the ACME directory is over HTTPS: without the root, the router wouldn’t even trust the CA’s server.) Finally, since we’ll use the CA’s name, let’s make the router resolve it:
/ip dns static add name=ca.company.internal address=192.0.2.10
This is the difference from Howto 3 (forgery): trust wasn’t “stolen”, it was granted, by whoever owns the device, explicitly and knowingly. On a device you don’t control, you couldn’t take this step — and that’s exactly why a MITM fails.
Step 4: issuance, from your CA
Identical to the public one, only the directory-url changes:
/certificate/add-acme \
directory-url=https://ca.company.internal:9000/acme/acme/directory \
domain-names=router.company.internal
The router creates the account on your CA, receives the token, serves it on port 80, your CA verifies, and signs. In a few seconds the log confirms:
certificate,info new acme cert 'acme_cert_router.company.internal' created
certificate,info acme cert 'acme_cert_router.company.internal' imported,
next update scheduled at ...
Step 5: under the microscope — how much simpler it is
We put the sniffer on and compare with the public howto. There the challenge came from many nodes scattered around the world (Let’s Encrypt’s MPIC), amid the noise of scanners. Here the scene is bare and clean: a single validator, your CA, knocking just once.
And since the http-01 challenge is plain HTTP, you can see everything — headers and body. A single validator, your CA (192.0.2.10), asks the router (192.0.2.20) for the envelope:
# 1) the CA asks for the challenge
192.0.2.10 > 192.0.2.20:80
GET /.well-known/acme-challenge/1GIDHB0NYfmbrnugcQe3taApSwFesbS4 HTTP/1.1
Host: router.company.internal
User-Agent: Go-http-client/1.1 # step-ca is written in Go
Accept-Encoding: gzip
# 2) the router delivers the token — and here's the response BODY:
192.0.2.20:80 > 192.0.2.10
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Length: 76
1GIDHB0NYfmbrnugcQe3taApSwFesbS4.E0WNGhlk4xi7sixV7prc-WktIWdAFqzU6UJ1OVBHmZ4
\________ token ________________/ \________ account key thumbprint ________/
The body is exactly token + “.” + key thumbprint: proof that the responder holds the account’s private key. The actual ACME dialogue, on the other hand, travels in TLS to the CA’s port 9000 — there the content is encrypted, you only see the handshake:
192.0.2.20.35386 > 192.0.2.10.9000: Flags [S] # SYN \
192.0.2.10.9000 > 192.0.2.20.35386: Flags [S.] # SYN-ACK } TLS handshake,
192.0.2.20.35386 > 192.0.2.10.9000: Flags [P.] # data / ENCRYPTED content
Two channels, two natures: the challenge in the clear on port 80 (the token is public, not a secret), the protocol encrypted on port 9000 (that’s where accounts and orders travel).
No MPIC (not needed: the CA and the router are on the same trusted network, there’s no BGP hijack to fear). No scanners, because we opened nothing on the Internet. The surface exposed to the world is zero.
Step 6: the certificate you hold
Let’s look at what your CA signed:
issuer O=Company Internal CA, CN=Company Internal CA Intermediate CA
subject CN = router.company.internal
SAN DNS:router.company.internal
key RSA 2048 · tls-server
validity 24 hours <-- very short life (step-ca default)
acme-status next update scheduled ... <-- automatic renewal
Note the 24-hour validity. On a public CA it would be a management nightmare; here it’s a luxury you can afford, because renewal is automatic and the CA is in your own house. The shorter the life, the less revocation matters: if an internal certificate were compromised, it would expire within a day anyway. It’s the same “short life instead of revocation” philosophy of public CAs, taken to the extreme because here it costs nothing.
The issuer is your organization, not Let’s Encrypt. The name is an internal name. All of this exists only inside your network, for your devices that trust your root.
Step 7: putting it to use, and forgetting about it
You assign the certificate to the service, as usual:
/ip service set www-ssl certificate=acme_cert_router.company.internal disabled=no
And here’s the beauty: you don’t have to remember anything. The router renews on its own against your CA, which is always reachable on the LAN — no port to open at renewal time, no window to plan, no public name to maintain. Once set up, your internal services have valid, always-fresh certificates, forever, without you thinking about it again.
Frequently asked questions
Is it legal/safe to “make your own CA”?
Yes: it’s private PKI, the standard tool companies use for internal services. Your CA is trusted only by the devices on which you install your root. It issues nothing valid for the rest of the world, and it doesn’t touch the public CAs. It’s the opposite of an attack: trust is granted by the owner, not stolen.
Do I have to install the root on every device?
Yes: every device that must trust the internal certificates needs your root in its trust store. In a company you distribute it with management tools (MDM, group policy); on the router we did it by hand with /certificate import.
Can I avoid even the internal port 80?
Yes: your CA can use the dns-01 challenge (a TXT record) or other modes. The http-01 here is convenient because everything is on the LAN and the risk is nil, but you aren’t forced to use port 80 even internally.
What’s the difference from the factory self-signed one?
The RouterOS self-signed certificate isn’t signed by any CA: every device sees it as “unknown” and complains. With an internal CA, instead, one root (yours) makes all the certificates of all your internal services trusted — and renews them on its own.
Tested in the lab on CHR RouterOS 7.25 (stable). Real internal CA with step-ca (smallstep, binaries step-ca 0.30.2 + step 0.31.0 installed on a Debian/Ubuntu box) and an ACME provisioner, on the same segment as the router. The root was imported on the CHR (/certificate import → T TRUSTED flag), the CA name’s static DNS was set, and a certificate was issued via /certificate/add-acme with directory-url pointing to the internal CA and the http-01 challenge: issuance and import confirmed in the log, with automatic renewal scheduled. Resulting certificate: issuer Company Internal CA (intermediate), CN/SAN router.company.internal, RSA 2048, 24-hour validity. On-the-wire capture: a single internal validator (the CA) with GET /.well-known/acme-challenge/… → HTTP/1.1 200 OK, and the ACME dialogue in TLS to the CA’s port. No exposure to the Internet. Example addresses in the documentation range; the lab’s real names and IPs are omitted.



