Skip to content

Instantly share code, notes, and snippets.

@geersch
Last active June 19, 2025 18:20
Show Gist options
  • Save geersch/e46016c323e5dab055df1cab0ee48295 to your computer and use it in GitHub Desktop.
Save geersch/e46016c323e5dab055df1cab0ee48295 to your computer and use it in GitHub Desktop.

Using Let's Encrypt with Unifi Dream Machine SE

Tested on a Dream Machine SE — your mileage may vary.

I used the acme.sh shell script to issue a certificate and set up automated renewal.

SSH Into Your Console

Start by SSHing into your Dream Machine SE.

Install acme.sh

Install acme.sh in ~/.acme.sh.

wget -O -  https://get.acme.sh | sh -s [email protected]      

Issue the Certificate

Issue a certificate using a DNS challenge. I used Digital Ocean, but acme.sh supports a lot of DNS APIs.

Check the list here: 👉 https://github.com/acmesh-official/acme.sh/wiki/dnsapi

~/.acme.sh/acme.sh --issue --dns dns_dgon -d example.com -d '*.example.com'

💡 The DNS challenge is only needed when initially issuing the certificate. Renewals don’t require it (at least for DigitalOcean).

Upload the Certificate

Once the certificate has been issued, copy it to your local machine.

On your machine execute:

scp -r user@gateway_ip:/your_user/.acme.sh/your_domain_ecc ~/local_folder/

Go to the UniFi Network App (Settings > Control Plane > Console > Certificates), add the new certificate and activate it. The UI will display the expiration date of the certificate, which typically expires in 3 months.

The UniFi OS stores these certificates in the folder /data/unifi-core/config. For each certificate they'll create 2 files.

  • UUID.cer
  • UUID.key

In that folder the settings.yaml file contains a property activeCertId which points to the active certificate.

Symbolic Links

Let's create symlinks to point these two UniFi OS-generated files to the Let's Encrypt-issued certificate. This step might not be strictly necessary, but we'll include it for completeness.

cd /data/unifi-core/config

# Make a backup of the original files. Replace UUID with the correct value!
mv [UUID].cer [UUID]_backup.cer
mv [UUID].key [UUID]_backup.key

# Create the symlinks
ln -s /your_user/.acme.sh/your.domain_ecc/your.domain.cer [UUID].cer
ln -s /your_user/.acme.sh/your.domain_ecc/your.domain.key [UUID].key

Automatic Renewal

When you installed acme.sh it also registered a crontab that runs daily to renew your certificate.

crontab -l

Output:

30 9 * * * "/your_user/.acme.sh"/acme.sh --cron --home "/your_user/.acme.sh" > /dev/null

That'll take care of renewing the certificate.

Automatic Deployment to Unifi

When you uploaded the certificate the UniFi OS also registered it in the Java keystore. It is this certificate that is served!

You can extract the cert from the keystore and inspect it using the keytool CLI. The password is aircontrolenterprise. This is built into the Unifi Controller, it's not a user-set password.

// Extract the certificate
keytool -exportcert -alias unifi -keystore /usr/lib/unifi/data/keystore -file ./my_cert.cer -rfc

// Inspect the certificate
openssl x509 -noout -text -in my_cert.cer

// Cleanup
rm -rf ./my_cert.cer

When the acme.sh crontab job runs and renews the certificate it must also update it into the keystore.

To automatically deploy the renewed certificate, set up the appropriate deploy hook:

~/.acme.sh/acme.sh --deploy -d example.com --deploy-hook unifi

Reference: 👉 https://github.com/acmesh-official/acme.sh/wiki/deployhooks#23-deploy-the-cert-on-a-unifi-controller-or-cloud-key

The script mentions it works on the Dream Machine, but for me it also worked fine on the Dream Machine SE.

Next time the cron job runs, it will:

  • Renew your certificate.
  • Register it in the keystore.
  • Restart the Unifi controller.

Certificate Metadata

You'll notice that when the certificate is renewed, the expiration date in the Unifi Network app is not updated.

The UniFi OS runs a mongodb and postgres database.

  • mongodb: port 27117, no auth
  • postgres: port 5432, user postgres, no password (hint: it's stored in here)

When you upload the certificate, the UniFi OS creates a record in the table user_certificates in the unifi-core postgres database. They extract the metadata from the certificate and snapshot it in this table. This does not get updated when the certificate is renewed. The ID of this record is a UUID, which corresponds to the certificate files in the /data/unifi-core/config folder.

In the UniFi Network UI they'll keep displaying the metadata (expiration data...etc.) of the original certificate as it was when you uploaded it.

Let's just set this expiration date far into the future so we don't get any warnings in the UI.

Create an SSH tunnel to port forward port 5432 on your local machine to the same port on the UDM SE.

ssh -fNT -L 5432:127.0.0.1:5432 user@gateway_ip

Modify the valid_to column and set it as far in the future as you want to.

This is what they've built a REST API on top. The UI fetches this resource.

GET https://your.domain/api/userCertificates 

Automating Certificate Expiration Updates

If you want the UniFI Network UI to display the correct expiration date, you can use the attached user_certificate.sh script I created. It extracts metadata from the the certificate and updates the corresponding database entry in the user_certificates table.

The script uses a package called jc (JSON Convert), to parse the certificate to JSON to extract the metadata. You'll need to install the package.

pip3 install jc

Remark: If you install it using apt it might install an outdated package which cannot parse X.509 certificates.

Create a new script called user_certificate.sh inside of a scripts folder in your user's home folder.

mkdir scripts
cd scripts
touch user_certificate.sh
chmod +x ./user_certificate.sh
chmod 700 ./user_certificate.sh

Edit the file with vim and copy/paste the script. Be sure to update the CERT_PATH and CERT_ID configuration parameters as needed for your environment!

Setup a crontab.

crontab -e

Add the following line.

0 9 * * * /your_user/scripts/user_certificate.sh > /dev/null

This will run the script once per day. Adjust the schedule as desired.

Remarks

  • You can also setup notify hooks. For example, sending a WhatsApp message when the cert is renewed.
  • This approach also works fine on a Synology DSM (DSM 7.2). The acme.sh script has a deploy hook for Synology as well. No need for any custom scripts. After deploying the certificate the DSM UI correctly displays the updated certificate.

Sources

#!/bin/bash
_info() { echo -e "\033[1;32m[INFO]\033[0m $@"; }
_err() { echo -e "\033[1;31m[ERROR]\033[0m $@" >&2; }
sql_escape() {
echo "$1" | sed "s/'/''/g"
}
# --- Configuration ---
DB_NAME="unifi-core"
DB_USER="postgres"
DB_HOST="localhost"
DB_PORT="5432"
CERT_PATH="/your_user/.acme.sh/your.domain_ecc/your.domain.cer" # Path to the certificate file
CERT_ID="" # ID (UUID) of the user certificate to update
KEY_PATH="/your_user/.acme.sh/your.domain_ecc/your.domain.key" # Path to the private key file
# --- Script ---
if [[ ! -f "$CERT_PATH" ]]; then
_err "Certificate file not found at $CERT_PATH"
exit 1
fi
_info "Certificate file found at $CERT_PATH"
# Read the certificate and private key
FILE_CERT="$(< ${CERT_PATH})"
FILE_KEY="$(< ${KEY_PATH})"
# Was the certificate renewed?
FINGERPRINT=$(openssl x509 -in "$CERT_PATH" -noout -fingerprint | cut -d= -f2)
QUERY="SELECT fingerprint FROM user_certificates WHERE id='${CERT_ID}';"
CURR_FINGERPRINT=$(psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" -p "$DB_PORT" -c "${QUERY}" -t -A)
if [[ -z "$CURR_FINGERPRINT" ]]; then
_err "No record found with id '$CERT_ID'"
exit 1
fi
if [[ "$CURR_FINGERPRINT" == "$FINGERPRINT" ]]; then
_info "Fingerprint: $CURR_FINGERPRINT"
_info "Fingerprint unchanged, skipping update."
exit 0
fi
# Extract metadata from the certificate
CERT_AS_JSON=$(openssl x509 -text -in "$CERT_PATH" | jc --x509-cert)
SERIAL_NUMBER=$(echo "$CERT_AS_JSON" | jq -r '.[0].tbs_certificate.serial_number' | tr -d ':' | tr 'a-f' 'A-F')
VERSION=$(echo "$CERT_AS_JSON" | jq -r '.[0].tbs_certificate.version' | tr -d 'v')
SUBJECT=$(echo "$CERT_AS_JSON" | jq '.[0].tbs_certificate.subject | {CN: .common_name}')
SUBJECT_ALT_NAME=$(echo "$CERT_AS_JSON" | jq '.[0].tbs_certificate.subject | {DNS: [.common_name]}')
ISSUER=$(echo "$CERT_AS_JSON" | jq '.[0].tbs_certificate.issuer | {C: .country_name, O: .organization_name, CN: .common_name}')
# Extract and format the start date (notBefore) as an RFC 3339-compliant timestamp.
START_DATE=$(echo "$CERT_AS_JSON" | jq '.[0].tbs_certificate.validity.not_before_iso' | tr -d '"')
VALID_FROM=$(date -u -d "$START_DATE" --rfc-3339=seconds)
# Extract and format the expiration date (notAfter) as an RFC 3339-compliant timestamp.
END_DATE=$(echo "$CERT_AS_JSON" | jq '.[0].tbs_certificate.validity.not_after_iso' | tr -d '"')
VALID_TO=$(date -u -d "$END_DATE" --rfc-3339=seconds)
_info "Serial number: $SERIAL_NUMBER"
_info "Version: $VERSION"
_info "Fingerprint: $FINGERPRINT"
_info "Subject: $SUBJECT"
_info "Subject Alt Name: $SUBJECT_ALT_NAME"
_info "Start date (notBefore): $VALID_FROM"
_info "Expiration date (notAfter): $VALID_TO"
_info "Issuer: $ISSUER"
# Update the user certificate
ESCAPED_ISSUER=$(sql_escape "$ISSUER")
QUERY="WITH updated AS (
UPDATE user_certificates SET
key = '${FILE_KEY}',
cert = '${FILE_CERT}',
version = '${VERSION}',
serial_number = '${SERIAL_NUMBER}',
fingerprint = '${FINGERPRINT}',
valid_from = '${VALID_FROM}',
valid_to = '${VALID_TO}',
subject = '${SUBJECT}',
subject_alt_name = '${SUBJECT_ALT_NAME}',
issuer = '${ESCAPED_ISSUER}',
updated_at = NOW()
WHERE id = '${CERT_ID}'
RETURNING 1
) SELECT count(*) FROM updated;"
RESULT=$(psql -U "$DB_USER" -d "$DB_NAME" -h "$DB_HOST" -p "$DB_PORT" -c "${QUERY}" -t -A)
_info "Rows updated: $RESULT"
if [[ "$RESULT" != "1" ]]; then
_err "Failed to update the certificate with ID '${CERT_ID}'."
exit 1
fi
_info "Updated user certificate with ID '${CERT_ID}'!"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment