It is appropriate to continue using
ssl_certificate /etc/kvmd/nginx/ssl/server.crt;
ssl_certificate_key /etc/kvmd/nginx/ssl/server.key;
in /etc/kvmd/nginx/ssl.conf, with a systemd timer checking the certificate expiration and updating these two files only when necessary.
The official PiKVM documentation also describes placing the Tailscale certificate in /etc/kvmd/nginx/ssl/server.{crt,key}, setting the group to kvmd-nginx, and then restarting kvmd-nginx. ([Pikvm][1]) Also, certificates obtained as files using tailscale cert are not automatically renewed, so users need to implement their own renewal process. --min-validity is also officially available in the current CLI. ([Tailscale][2])
Configuration
Normally, the setup looks like this.
Tailscale
│
│ 100.x / MagicDNS
▼
PiKVM nginx :443
│
├─ /etc/kvmd/nginx/ssl/server.crt
└─ /etc/kvmd/nginx/ssl/server.key
Do not use tailscale serve.
tailscale serve --https=443 off
The certificate renewal process will be:
Timer runs once a day
│
▼
Check current server.crt
│
├─ FQDN is correct
│ and at least 30 days remain
│ → Do nothing
│
└─ Less than 30 days / no certificate / hostname mismatch
│
▼
rw
│
▼
tailscale cert
│
▼
Validate cert/key
│
▼
Replace nginx files
│
▼
nginx -t
│
▼
restart kvmd-nginx
│
▼
ro
Let’s Encrypt certificates are valid for 90 days, so attempting renewal starting 30 days before expiration provides plenty of margin. ([Tailscale][3])
1. Renewal Script
Create /usr/local/libexec/pikvm-tailscale-cert-renew.
#!/usr/bin/env bash
set -Eeuo pipefail
export PATH=/usr/local/bin:/usr/bin
CERT="/etc/kvmd/nginx/ssl/server.crt"
KEY="/etc/kvmd/nginx/ssl/server.key"
# 30 days
MIN_VALIDITY_SECONDS=$((30 * 24 * 60 * 60))
TS_MIN_VALIDITY="720h"
TMP=""
MADE_RW=0
log() {
echo "pikvm-tailscale-cert-renew: $*"
}
cleanup() {
rc=$?
trap - EXIT INT TERM
rm -f "${CERT}.new" "${KEY}.new" 2>/dev/null || true
if [[ -n "${TMP:-}" ]]; then
rm -rf "$TMP"
fi
if (( MADE_RW )); then
sync
if ! ro; then
log "ERROR: failed to restore read-only filesystem"
rc=1
fi
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
#
# Get the Tailscale FQDN
#
DOMAIN="$(
tailscale status --json |
jq -er '.Self.DNSName | rtrimstr(".") | select(length > 0)'
)"
log "Tailscale DNS name: ${DOMAIN}"
#
# Check the certificate currently used by nginx
#
cert_is_current() {
[[ -s "$CERT" ]] || return 1
[[ -s "$KEY" ]] || return 1
# Check whether the hostname matches
openssl x509 \
-in "$CERT" \
-noout \
-checkhost "$DOMAIN" \
>/dev/null 2>&1 || return 1
# Check whether at least 30 days remain
openssl x509 \
-in "$CERT" \
-noout \
-checkend "$MIN_VALIDITY_SECONDS" \
>/dev/null 2>&1 || return 1
return 0
}
if cert_is_current; then
log "certificate is valid for more than 30 days; nothing to do"
exit 0
fi
log "certificate renewal is required"
#
# Use /tmp for the temporary directory.
# The root filesystem is still RO at this point.
#
TMP="$(mktemp -d /tmp/pikvm-tailscale-cert.XXXXXX)"
#
# Switch the PiKVM root filesystem to RW only when necessary.
#
ROOT_OPTS="$(findmnt -no OPTIONS /)"
case ",${ROOT_OPTS}," in
*,rw,*)
log "root filesystem is already read-write"
;;
*)
log "switching root filesystem to read-write"
rw
MADE_RW=1
;;
esac
#
# Obtain the certificate from Tailscale.
#
# --min-validity=720h requests a certificate
# that is valid for at least 30 days.
#
log "requesting certificate for ${DOMAIN}"
tailscale cert \
--min-validity="$TS_MIN_VALIDITY" \
--cert-file="$TMP/server.crt" \
--key-file="$TMP/server.key" \
"$DOMAIN"
#
# Validate the obtained certificate
#
# hostname
openssl x509 \
-in "$TMP/server.crt" \
-noout \
-checkhost "$DOMAIN"
# expiration
openssl x509 \
-in "$TMP/server.crt" \
-noout \
-checkend "$MIN_VALIDITY_SECONDS"
# Verify that the certificate and private key have the same public key
if ! cmp -s \
<(
openssl x509 \
-in "$TMP/server.crt" \
-pubkey \
-noout |
openssl pkey \
-pubin \
-outform DER 2>/dev/null
) \
<(
openssl pkey \
-in "$TMP/server.key" \
-pubout \
-outform DER 2>/dev/null
)
then
log "ERROR: certificate and private key do not match"
exit 1
fi
#
# Back up the current certificate
#
if [[ -e "$CERT" ]]; then
cp -a "$CERT" "$TMP/old.crt"
fi
if [[ -e "$KEY" ]]; then
cp -a "$KEY" "$TMP/old.key"
fi
rollback() {
log "rolling back certificate"
if [[ -e "$TMP/old.crt" ]]; then
cp -a "$TMP/old.crt" "$CERT"
else
rm -f "$CERT"
fi
if [[ -e "$TMP/old.key" ]]; then
cp -a "$TMP/old.key" "$KEY"
else
rm -f "$KEY"
fi
}
#
# Prepare the files for nginx, then rename them.
#
# nginx itself continues holding the old certificate until it is
# reloaded/restarted, so even if the crt/key files briefly do not match
# between the two renames, this does not affect the running nginx process.
#
install \
-o root \
-g kvmd-nginx \
-m 0644 \
"$TMP/server.crt" \
"${CERT}.new"
install \
-o root \
-g kvmd-nginx \
-m 0640 \
"$TMP/server.key" \
"${KEY}.new"
mv -f "${KEY}.new" "$KEY"
mv -f "${CERT}.new" "$CERT"
#
# Validate using the actual nginx configuration generated by PiKVM
#
if ! nginx -t -c /run/kvmd/nginx.conf; then
log "ERROR: nginx configuration test failed"
rollback
exit 1
fi
#
# Restart according to the official PiKVM documentation.
#
if ! systemctl restart kvmd-nginx; then
log "ERROR: kvmd-nginx restart failed"
rollback
# Attempt recovery after restoring the old certificate
nginx -t -c /run/kvmd/nginx.conf || true
systemctl restart kvmd-nginx || true
exit 1
fi
log "certificate successfully installed"
openssl x509 \
-in "$CERT" \
-noout \
-subject \
-issuer \
-dates
exit 0
With this method, the normal daily operation consists only of:
openssl x509 -checkhost ...
openssl x509 -checkend ...
so the root filesystem remains RO.
It switches to rw only when fewer than 30 days remain.
Additionally, because tailscale cert --min-validity=720h is used, Tailscale is also instructed to “return a certificate that is valid for at least 30 days.” This flag is part of the current Tailscale CLI specification. ([Tailscale][2])
2. systemd Service
/etc/systemd/system/pikvm-tailscale-cert-renew.service
[Unit]
Description=Renew Tailscale TLS certificate for PiKVM nginx
Wants=network-online.target
After=network-online.target tailscaled.service
Requires=tailscaled.service
[Service]
Type=oneshot
ExecStart=/usr/local/libexec/pikvm-tailscale-cert-renew
TimeoutStartSec=5min
There is no need to add kvmd-nginx.service to Requires=.
The reason is that even if kvmd-nginx has stopped because of a broken certificate, this unit should still be able to repair the certificate independently and then run systemctl restart kvmd-nginx.
3. systemd Timer
/etc/systemd/system/pikvm-tailscale-cert-renew.timer
[Unit]
Description=Periodic Tailscale TLS certificate check for PiKVM
[Timer]
OnBootSec=15min
OnUnitActiveSec=1d
RandomizedDelaySec=30min
AccuracySec=1min
Unit=pikvm-tailscale-cert-renew.service
[Install]
WantedBy=timers.target
Persistent=true is intentionally omitted here.
Since this configuration starts renewing a 90-day certificate 30 days before expiration, missing a single check while the device is powered off is not a problem. The certificate will be checked roughly 15–45 minutes after boot, and then approximately once per day thereafter.
4. Installation
Copy and paste the entire block below into a root shell on PiKVM. It installs jq first, creates the renewal script and both systemd unit files with the exact contents shown above, disables Tailscale Serve, enables and starts the timer, runs the renewal service once immediately, and finally restores the root filesystem to RO.
(
set -Eeuo pipefail
trap 'ro >/dev/null 2>&1 || true' EXIT
rw
# Install jq before installing/enabling the renewal service.
pacman -S --needed jq
install -d -m 0755 /usr/local/libexec
cat > /usr/local/libexec/pikvm-tailscale-cert-renew <<'PIKVM_RENEW_EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
export PATH=/usr/local/bin:/usr/bin
CERT="/etc/kvmd/nginx/ssl/server.crt"
KEY="/etc/kvmd/nginx/ssl/server.key"
# 30 days
MIN_VALIDITY_SECONDS=$((30 * 24 * 60 * 60))
TS_MIN_VALIDITY="720h"
TMP=""
MADE_RW=0
log() {
echo "pikvm-tailscale-cert-renew: $*"
}
cleanup() {
rc=$?
trap - EXIT INT TERM
rm -f "${CERT}.new" "${KEY}.new" 2>/dev/null || true
if [[ -n "${TMP:-}" ]]; then
rm -rf "$TMP"
fi
if (( MADE_RW )); then
sync
if ! ro; then
log "ERROR: failed to restore read-only filesystem"
rc=1
fi
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
#
# Get the Tailscale FQDN
#
DOMAIN="$(
tailscale status --json |
jq -er '.Self.DNSName | rtrimstr(".") | select(length > 0)'
)"
log "Tailscale DNS name: ${DOMAIN}"
#
# Check the certificate currently used by nginx
#
cert_is_current() {
[[ -s "$CERT" ]] || return 1
[[ -s "$KEY" ]] || return 1
# Check whether the hostname matches
openssl x509 \
-in "$CERT" \
-noout \
-checkhost "$DOMAIN" \
>/dev/null 2>&1 || return 1
# Check whether at least 30 days remain
openssl x509 \
-in "$CERT" \
-noout \
-checkend "$MIN_VALIDITY_SECONDS" \
>/dev/null 2>&1 || return 1
return 0
}
if cert_is_current; then
log "certificate is valid for more than 30 days; nothing to do"
exit 0
fi
log "certificate renewal is required"
#
# Use /tmp for the temporary directory.
# The root filesystem is still RO at this point.
#
TMP="$(mktemp -d /tmp/pikvm-tailscale-cert.XXXXXX)"
#
# Switch the PiKVM root filesystem to RW only when necessary.
#
ROOT_OPTS="$(findmnt -no OPTIONS /)"
case ",${ROOT_OPTS}," in
*,rw,*)
log "root filesystem is already read-write"
;;
*)
log "switching root filesystem to read-write"
rw
MADE_RW=1
;;
esac
#
# Obtain the certificate from Tailscale.
#
# --min-validity=720h requests a certificate
# that is valid for at least 30 days.
#
log "requesting certificate for ${DOMAIN}"
tailscale cert \
--min-validity="$TS_MIN_VALIDITY" \
--cert-file="$TMP/server.crt" \
--key-file="$TMP/server.key" \
"$DOMAIN"
#
# Validate the obtained certificate
#
# hostname
openssl x509 \
-in "$TMP/server.crt" \
-noout \
-checkhost "$DOMAIN"
# expiration
openssl x509 \
-in "$TMP/server.crt" \
-noout \
-checkend "$MIN_VALIDITY_SECONDS"
# Verify that the certificate and private key have the same public key
if ! cmp -s \
<(
openssl x509 \
-in "$TMP/server.crt" \
-pubkey \
-noout |
openssl pkey \
-pubin \
-outform DER 2>/dev/null
) \
<(
openssl pkey \
-in "$TMP/server.key" \
-pubout \
-outform DER 2>/dev/null
)
then
log "ERROR: certificate and private key do not match"
exit 1
fi
#
# Back up the current certificate
#
if [[ -e "$CERT" ]]; then
cp -a "$CERT" "$TMP/old.crt"
fi
if [[ -e "$KEY" ]]; then
cp -a "$KEY" "$TMP/old.key"
fi
rollback() {
log "rolling back certificate"
if [[ -e "$TMP/old.crt" ]]; then
cp -a "$TMP/old.crt" "$CERT"
else
rm -f "$CERT"
fi
if [[ -e "$TMP/old.key" ]]; then
cp -a "$TMP/old.key" "$KEY"
else
rm -f "$KEY"
fi
}
#
# Prepare the files for nginx, then rename them.
#
# nginx itself continues holding the old certificate until it is
# reloaded/restarted, so even if the crt/key files briefly do not match
# between the two renames, this does not affect the running nginx process.
#
install \
-o root \
-g kvmd-nginx \
-m 0644 \
"$TMP/server.crt" \
"${CERT}.new"
install \
-o root \
-g kvmd-nginx \
-m 0640 \
"$TMP/server.key" \
"${KEY}.new"
mv -f "${KEY}.new" "$KEY"
mv -f "${CERT}.new" "$CERT"
#
# Validate using the actual nginx configuration generated by PiKVM
#
if ! nginx -t -c /run/kvmd/nginx.conf; then
log "ERROR: nginx configuration test failed"
rollback
exit 1
fi
#
# Restart according to the official PiKVM documentation.
#
if ! systemctl restart kvmd-nginx; then
log "ERROR: kvmd-nginx restart failed"
rollback
# Attempt recovery after restoring the old certificate
nginx -t -c /run/kvmd/nginx.conf || true
systemctl restart kvmd-nginx || true
exit 1
fi
log "certificate successfully installed"
openssl x509 \
-in "$CERT" \
-noout \
-subject \
-issuer \
-dates
exit 0
PIKVM_RENEW_EOF
chmod 0755 /usr/local/libexec/pikvm-tailscale-cert-renew
cat > /etc/systemd/system/pikvm-tailscale-cert-renew.service <<'PIKVM_SERVICE_EOF'
[Unit]
Description=Renew Tailscale TLS certificate for PiKVM nginx
Wants=network-online.target
After=network-online.target tailscaled.service
Requires=tailscaled.service
[Service]
Type=oneshot
ExecStart=/usr/local/libexec/pikvm-tailscale-cert-renew
TimeoutStartSec=5min
PIKVM_SERVICE_EOF
cat > /etc/systemd/system/pikvm-tailscale-cert-renew.timer <<'PIKVM_TIMER_EOF'
[Unit]
Description=Periodic Tailscale TLS certificate check for PiKVM
[Timer]
OnBootSec=15min
OnUnitActiveSec=1d
RandomizedDelaySec=30min
AccuracySec=1min
Unit=pikvm-tailscale-cert-renew.service
[Install]
WantedBy=timers.target
PIKVM_TIMER_EOF
systemctl daemon-reload
# PiKVM nginx owns HTTPS port 443; Tailscale Serve must be disabled.
tailscale serve --https=443 off
# Enable and immediately start the periodic timer.
systemctl enable --now pikvm-tailscale-cert-renew.timer
# Run one certificate check/renewal immediately as part of installation.
systemctl start pikvm-tailscale-cert-renew.service
ro
trap - EXIT
)
After the block finishes, PiKVM’s own nginx will listen on port 443. The timer is already enabled and running because the installer uses systemctl enable --now.
The official PiKVM documentation also uses the approach of updating server.crt/server.key and running systemctl restart kvmd-nginx when installing a Tailscale certificate directly into nginx. ([Pikvm][1])
5. Starting and Checking Status
systemctl start pikvm-tailscale-cert-renew.service
Check:
systemctl status pikvm-tailscale-cert-renew.service
journalctl \
-u pikvm-tailscale-cert-renew.service \
-n 100 \
--no-pager
Certificate:
openssl x509 \
-in /etc/kvmd/nginx/ssl/server.crt \
-noout \
-subject \
-issuer \
-dates \
-ext subjectAltName
If it succeeds and contains:
DNS:{hostname}.{tsnet}.ts.net
then everything is OK.
The timer was already enabled and started by the installation block.
Check:
systemctl list-timers pikvm-tailscale-cert-renew.timer
Access URL
With this configuration, the certificate name is:
{hostname}.{tsnet}.ts.net
so in the browser, always use:
https://{hostname}.{tsnet}.ts.net/
For https://{hostname}/ or [https://100.x.x.x/](https://100.x.x.x/), the connection itself may reach nginx, but the certificate name will not match. Tailscale also explicitly states that HTTPS certificates are for fully qualified *.ts.net names, not HTTPS certificates for bare hostnames. ([Tailscale][3])
In other words, this approach completely eliminates Serve, handles port 443 using only PiKVM’s standard nginx, keeps the filesystem RO during normal operation, and switches it to RW only when the certificate actually needs to be renewed. It also does not conflict with the automatic generation of /run/kvmd/nginx.conf.