A Kubernetes operator that watches cert-manager TLS Secrets and automatically syncs certificates to MikroTik RouterOS devices via SSH/SFTP.
When cert-manager renews a certificate, mikrotik-cert-controller uploads the new cert (including the full chain) to your routers and assigns it to configured services — www-ssl, api-ssl, or IPsec identities/peers.
- Watches Kubernetes Secrets labeled
cert-controller.mikrotik.io/enabled=true - Namespace-scoped deployment (secure Role/RoleBinding and cache-scoping by default)
- Uploads leaf certificate, private key, and intermediate chain certs separately
- Handles MikroTik's certificate deduplication (looks up chain certs by common name)
- Assigns certificates to RouterOS services:
www-ssl,api-ssl,ipsec - SSH host key verification via
known_hosts - Per-router SSH key overrides
- Prometheus metrics (sync duration, cert expiry, error counts)
- Health/readiness endpoints
- Finalizer-based cleanup on Secret deletion (configurable)
- Multi-arch container images (amd64, arm64)
- Kubernetes cluster with cert-manager installed
- MikroTik router(s) accessible via SSH from the cluster
- SSH key pair for router authentication
kubectl create namespace mikrotik-cert-controller
kubectl create secret generic mikrotik-cert-controller-ssh-key \
--namespace=mikrotik-cert-controller \
--from-file=ssh-privatekey=/path/to/id_ed25519ssh-keyscan -p 22 192.168.88.1 2>/dev/nullAdd the output to k8s/known_hosts.
Edit k8s/config.yaml:
routers:
- name: my-router
address: 192.168.88.1
username: admin
services:
- type: www-ssl
- type: api-ssl
- type: ipsec
identity_name: ike2-rw-id# Using kustomize
kubectl apply -k k8s/
# Or using the kustomize overlay
kubectl apply -k deploy/kustomize/overlays/production/kubectl label secret my-tls-cert cert-controller.mikrotik.io/enabled=trueThe operator will detect the labeled Secret and sync the certificate to all configured routers.
| Field | Default | Description |
|---|---|---|
log_level |
info |
Log level: debug, info, warn, error |
label_selector |
cert-controller.mikrotik.io/enabled=true |
Label selector for Secrets to watch |
watch_namespace |
Restricts the operator to watching Secrets in a single namespace (e.g. mikrotik-certs). If empty, watches all namespaces (cluster-scoped). |
|
sync_period |
1h |
How often to re-check all Secrets |
delete_policy |
retain |
retain or remove — what to do with router certs when the Secret is deleted |
metrics_bind_address |
:8080 |
Address the Prometheus metrics server listens on. 0 disables it |
health_probe_bind_address |
:8081 |
Address the health/readiness probe server listens on. 0 disables it |
ssh_port |
22 |
Default SSH port for all routers |
ssh_key |
Global SSH key Secret reference | |
known_hosts_file |
Path to known_hosts file for SSH host key verification | |
insecure_ignore_host_key |
false |
Skip SSH host key verification (not recommended) |
routers |
[] |
List of MikroTik router targets |
| Field | Required | Description |
|---|---|---|
name |
yes | Unique name for this router (used in metrics and logs) |
address |
yes | Router IP or hostname |
username |
yes | SSH username |
ssh_port |
no | Override the global SSH port |
ssh_key |
no | Override the global SSH key reference |
services |
no | List of services to assign the certificate to |
| Type | Extra fields | Description |
|---|---|---|
www-ssl |
none | RouterOS web management HTTPS |
api-ssl |
none | RouterOS API SSL |
ipsec |
peer_name and/or identity_name |
IPsec peer/identity certificate |
All config fields can be overridden with environment variables prefixed with CERTCTL_:
CERTCTL_LOG_LEVEL=debug
CERTCTL_SSH_PORT=2222
CERTCTL_METRICS_BIND_ADDRESS=:9080
CERTCTL_HEALTH_PROBE_BIND_ADDRESS=:9081To adhere to the principle of least privilege, mikrotik-cert-controller can be deployed in either Namespace-scoped mode or Cluster-scoped mode:
In this mode, the operator has access to read Secrets and manage leader election resources only within the local deployment namespace (e.g. mikrotik-certs).
- Uses
rbac-namespace.yaml(which defines localRoleandRoleBinding). - Configure
watch_namespace(or env variableCERTCTL_WATCH_NAMESPACE) to the target namespace to optimize operator runtime cache.
In this mode, the operator has access to watch and synchronize Secrets across the entire cluster.
- Uses
rbac-cluster.yaml(which defines aClusterRoleandClusterRoleBinding). - Leave
watch_namespaceempty.
To choose which RBAC version is applied during deployment, see k8s/kustomization.yaml or deploy/kustomize/base/kustomization.yaml and adjust the active resource.
CI publishes two kinds of image tags:
| Tag | Published on | Example |
|---|---|---|
<branch>-<short sha>-<unix ts> |
every commit pushed to a branch | main-63dcb58-1787761986 |
v<version>, v<major>.<minor>, v<major>, latest |
every v* release tag |
v0.0.1, v0.0, v0, latest |
The per-commit tag is unique per build, so a re-run never overwrites an existing image, and its trailing timestamp gives Flux something to order on.
deploy/flux/image-automation.yaml is a ready-to-adapt example. As written it
tracks the newest commit on main:
filterTags:
pattern: '^main-[a-f0-9]+-(?P<ts>\d+)$'
extract: '$ts'
policy:
numerical:
order: ascThe ^main- prefix is what keeps release tags and feature branches out of it.
The same file carries a commented-out semver policy for tracking releases instead — accepting new minor and patch versions but never a new major:
policy:
semver:
range: ">=0.0.1 <1.0.0"The range is written without the v, even though the tags carry one — the
semver parser strips a leading v from the tag before comparing.
Either way, the tag Flux rewrites is the one carrying the marker comment in
deploy/kustomize/base/deployment.yaml:
image: ghcr.io/yannick/mikrotik-cert-controller:latest # {"$imagepolicy": "flux-system:mikrotik-cert-controller"}Both the ImageRepository and the ImagePolicy must live in the same namespace
as the ImageUpdateAutomation that writes the change back to git — flux-system
in the example — because an automation only selects policies in its own namespace.
- The operator watches Kubernetes Secrets matching the label selector
- When a Secret changes, it computes a SHA-256 hash of
tls.crt+tls.key - If the hash differs from the annotation
mikrotik-cert-controller.io/last-synced-hash, a sync is triggered - For each configured router:
- Connect via SSH
- Split the PEM bundle into leaf and chain certificates
- Upload each file via SFTP
- Remove old certificates matching the domain
- Import chain certs first, then leaf cert + key
- Look up chain certs by common name (handles MikroTik deduplication)
- Assign the full chain to configured services
- Clean up uploaded files
- On success, update the Secret annotations with the new hash and timestamp
Served on :8080/metrics by default. Change the address with metrics_bind_address
(or CERTCTL_METRICS_BIND_ADDRESS) — needed when running with hostNetwork: true on a
node where port 8080 is already taken — or set it to 0 to disable the metrics server.
| Metric | Type | Labels | Description |
|---|---|---|---|
certsync_syncs_total |
counter | router, status |
Total sync operations |
certsync_errors_total |
counter | router, phase |
Errors by phase (connect, upload, import, verify, assign) |
certsync_sync_duration_seconds |
histogram | router |
Sync duration per router |
certsync_cert_expiry_timestamp |
gauge | domain, router |
Certificate expiry as Unix timestamp |
make build # Build binary
make test # Run tests with race detector
make docker-build # Build container imageMIT — see LICENSE.