# LetsEncrypt with DNS-01 validation for Unifi

https://blog.cynexia.com/letsencrypt-with-dns-01-for-unifi

Author: [Rob Aleck](https://blog.cynexia.com/pages/about) · https://github.com/mnbf9rca · https://www.linkedin.com/in/robertaleck/ · https://family-foqos.app/

Published: 2017-07-15
Updated: 2026-10-01
Categories: How-to


**Update 2021-01-08: this is now out of date. See my [updated post](<https://blog.cynexia.com/how-to-letsencrypt-in-standalone-mode-for-unifi-on-ubuntu-20-04-lts>) with a much easier method.**

I have a number of [Ubiquiti UAPs](<https://www.ubnt.com/unifi/unifi-ap/>), and I manage them with the [UniFi app](<https://help.ubnt.com/hc/en-us/articles/220066768-UniFi-How-to-Install-Update-via-APT-on-Debian-or-Ubuntu>), installed on a [linode](<https://www.linode.com/?r=dc3ef0fad9439a103af89cf313cc6b5a93b679dd>) server. Like any publicly hosted server, i want to use a trusted SSL certificate, and for that, I chose [LetsEncrypt with DNS-01 validation](<https://letsencrypt.org/>), as i found a [useful helper script](<https://community.ubnt.com/t5/UniFi-Wireless/Helper-Custom-HTTPS-certificate-inc-Lets-Encrypt-for-Web/td-p/1801111>) by thatsamguy on the UniFi forums. I use AWS Route53 to host the DNS zone.

UniFi doesn't have built in support for LetsEncrypt, so I put together a simple solution using the DNS-01 validation method. Here's how i did it:

1. Created a new, unprivileged user on the host:  
   `sudo adduser dehydrated`
2. Created the directory to store the certificates. I chose to use the default /etc/letsencrypt folder  
   `sudo mkdir /etc/letsencrypt`
3. I granted the dehydrated user full access to this folder:  
   `sudo chown dehydrated:dehydrated /etc/letsencrypt`
4. installed the dependencies which are in the repo:  
   `sudo apt-get install --no-install-recommends jq sed findutils s-nail`
5. installed the [cli53 ](<https://github.com/barnybug/cli53>)dependency which is not in the repo by following the instructions on the git readme.md file
6. logged in as the dehydrated user:  
   `su - dehydrated`
7. fetched dehydrated and made it executable:  
   wget https://raw.githubusercontent.com/lukas2511/dehydrated/master/dehydrated  
   chmod +x dehydrated
8. fetched the dehydrated [route53 hook script](<https://github.com/whereisaaron/dehydrated-route53-hook-script>) and made it executable:  
   wget https://raw.githubusercontent.com/whereisaaron/dehydrated-route53-hook-script/master/hook.sh  
   chmod +x hook.sh
9. Created a new [IAM access policy](<http://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_create.html#access_policies_create-editor>) in AWS. I found that the sample policy given with the root 53 hook readme.md didn't work - here's the policy that I added:  
   {  
   &#32;   "Version": "2012-10-17",  
   &#32;   "Statement": [  
   &#32;       {  
   &#32;           "Effect": "Allow",  
   &#32;           "Action": [  
   &#32;               "route53:ListHostedZones",  
   &#32;               "route53:ListHostedZonesByName",  
   &#32;               "route53:ListResourceRecordSets"  
   &#32;           ],  
   &#32;           "Resource": "\*"  
   &#32;       },  
   &#32;       {  
   &#32;           "Effect": "Allow",  
   &#32;           "Action": [  
   &#32;               "route53:ListResourceRecordSets",  
   &#32;               "route53:ChangeResourceRecordSets"  
   &#32;           ],  
   &#32;           "Resource": "arn:aws:route53:::hostedzone/\*"  
   &#32;       },  
   &#32;       {  
   &#32;           "Effect": "Allow",  
   &#32;           "Action": [  
   &#32;               "route53:GetChange"  
   &#32;           ],  
   &#32;           "Resource": "arn:aws:route53:::change/\*"  
   &#32;       }  
   &#32;   ]  
   }
10. [Created a new IAM user](<http://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html>) in AWS and assigned the policy to them (and only this policy), taking a note of the AWS Access Key and AWS Secret Access Key.
11. Created a new file to store the key:  
    mkdir \~/.aws  
    nano \~/.aws/credentialsThe file looks like this (obviously, put your own data in there):[default]  
    aws_access_key_id = AKIAIJFAKGII4MDS3KHA  
    aws_secret_access_key = Q+XoOGa5J3AS39as593Ds1f5F91zRy0btkfW
12. Created a new config file:  
    nano \~/configThe file looks like this (edited from the [sample](<https://github.com/lukas2511/dehydrated/blob/master/docs/examples/config>)):########################################################  
    \# This is the main config file for dehydrated          #  
    \#                                                      #  
    \# This file is looked for in the following locations:  #  
    \# $SCRIPTDIR/config (next to this script)              #  
    \# /usr/local/etc/dehydrated/config                     #  
    \# /etc/dehydrated/config                               #  
    \# ${PWD}/config (in current working-directory)         #  
    \#                                                      #  
    \# Default values of this config are in comments        #  
    ########################################################  
      
    \# Resolve names to addresses of IP version only. (curl)  
    \# supported values: 4, 6  
    \# default:   
    #IP_VERSION=  
      
    \# Path to certificate authority (default: https://acme-v01.api.letsencrypt.org/directory)  
    #CA="https://acme-v01.api.letsencrypt.org/directory"  
    \# CA="https://acme-staging.api.letsencrypt.org/directory"  
      
    \# Path to certificate authority license terms redirect (default: https://acme-v01.api.letsencrypt.org/terms)  
    #CA_TERMS="https://acme-v01.api.letsencrypt.org/terms"  
    \# CA_TERMS="https://acme-staging.api.letsencrypt.org/terms"  
      
    \# Path to license agreement (default: )  
    #LICENSE=""  
      
    \# Which challenge should be used? Currently http-01 and dns-01 are supported  
    CHALLENGETYPE="dns-01"  
      
    \# Path to a directory containing additional config files, allowing to override  
    \# the defaults found in the main configuration file. Additional config files  
    \# in this directory needs to be named with a '.sh' ending.  
    \# default:   
    #CONFIG_D=  
      
    \# Base directory for account key, generated certificates and list of domains (default: $SCRIPTDIR -- uses config directory if undefined)  
    #BASEDIR=$SCRIPTDIR  
      
    \# File containing the list of domains to request certificates for (default: $BASEDIR/domains.txt)  
    #DOMAINS_TXT="${BASEDIR}/domains.txt"  
      
    \# Output directory for generated certificates  
    #CERTDIR="${BASEDIR}/certs"  
      
    \# Directory for account keys and registration information  
    #ACCOUNTDIR="${BASEDIR}/accounts"  
      
    \# Output directory for challenge-tokens to be served by webserver or deployed in HOOK (default: /var/www/dehydrated)  
    #WELLKNOWN="/var/www/dehydrated"  
      
    \# Default keysize for private keys (default: 4096)  
    #KEYSIZE="4096"  
      
    \# Path to openssl config file (default:  - tries to figure out system default)  
    #OPENSSL_CNF=  
      
    \# Extra options passed to the curl binary (default: )  
    #CURL_OPTS=  
      
    \# Program or function called in certain situations  
    \#  
    \# After generating the challenge-response, or after failed challenge (in this case altname is empty)  
    \# Given arguments: clean_challenge|deploy_challenge altname token-filename token-content  
    \#  
    \# After successfully signing certificate  
    \# Given arguments: deploy_cert domain path/to/privkey.pem path/to/cert.pem path/to/fullchain.pem  
    \#  
    \# BASEDIR and WELLKNOWN variables are exported and can be used in an external program  
    \# default:   
    HOOK=${BASEDIR}/hook.sh  
      
    \# Chain clean_challenge|deploy_challenge arguments together into one hook call per certificate (default: no)  
    HOOK_CHAIN="no"  
      
    \# Minimum days before expiration to automatically renew certificate (default: 30)  
    #RENEW_DAYS="30"  
      
    \# Regenerate private keys instead of just signing new certificates on renewal (default: yes)  
    #PRIVATE_KEY_RENEW="yes"  
      
    \# Create an extra private key for rollover (default: no)  
    #PRIVATE_KEY_ROLLOVER="no"  
      
    \# Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1  
    #KEY_ALGO=rsa  
      
    \# E-mail to use during the registration (default: )  
    CONTACT_EMAIL=myemail@mydomain.com  
      
    \# Lockfile location, to prevent concurrent access (default: $BASEDIR/lock)  
    #LOCKFILE="${BASEDIR}/lock"  
      
    \# Option to add CSR-flag indicating OCSP stapling to be mandatory (default: no)  
    #OCSP_MUST_STAPLE="no"  
      
    \# Fetch OCSP responses (default: no)  
    #OCSP_FETCH="no"  
      
    \# Issuer chain cache directory (default: $BASEDIR/chains)  
    #CHAINCACHE="${BASEDIR}/chains"  
      
    \# Automatic cleanup (default: no)  
    AUTO_CLEANUP="yes"
13. Created a new file with the list of domains to register:  
    nano \~/domains.txtThe file looks like this (obviously, put your own data in there):myhost.mydomain.com
14. Checked that the certificate registers correctly - note that if you are having trouble, you should enable the "staging" CA / terms file in config while you troubleshoot to avoid hitting letsencrypt limits:  
    dehydrated@localhost:\~$ ./dehydrated --cron --accept-terms --out /etc/letsencrypt  
    \# INFO: Using main config file /home/dehydrated/config  
    \+ Generating account key...  
    \+ Registering account key with ACME server...  
    Processing myhost.mydomain.com  
     \+ Signing domains...  
     \+ Creating new directory /home/dehydrated/certs/myhost.mydomain.com ...  
     \+ Generating private key...  
     \+ Generating signing request...  
     \+ Requesting challenge for myhost.mydomain.com...  
    Creating challenge record for myhost.mydomain.com in zone cynexia.net  
    Created record: '\_acme-challenge.myhost.mydomain.com. 60 IN TXT "cpE1VF_xshMm1IVY1Y66Kk9Zb_7jA2VFkP65WuNgu3Q"'  
    Waiting for sync...................................  
    Completed  
     \+ Responding to challenge for myhost.mydomain.com...  
    Deleting challenge record for myhost.mydomain.com from zone mydomain.com  
    1 record sets deleted  
     \+ Challenge is valid!  
     \+ Requesting certificate...  
     \+ Checking certificate...  
     \+ Done!  
     \+ Creating fullchain.pem...  
     \+ Using cached chain!  
     \+ Done!  
    \+ Running automatic cleanup
15. Created the helper script, remembering to edit the domain (find/replace) and the certificate path:  
    `nano updateunificert`  
    My file looks like this:  
     !/bin/bash  
    PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games';  
    openssl pkcs12 -export -in /etc/letsencrypt/myhost.mydomain.com/fullchain.pem -inkey /etc/letsencrypt/myhost.mydomain.com/privkey.pem -out /etc/letsencrypt/myhost.mydomain.com/cert_and_key.p12 -name tomcat -CAfile /etc/letsencrypt/myhost.mydomain.com/chain.pem -caname root -password pass:aaa;  
    rm -f /etc/letsencrypt/myhost.mydomain.com/keystore;  
    keytool -importkeystore -srcstorepass aaa -deststorepass aircontrolenterprise -destkeypass aircontrolenterprise -srckeystore /etc/letsencrypt/myhost.mydomain.com/cert_and_key.p12 -srcstoretype PKCS12 -alias tomcat -keystore /etc/letsencrypt/myhost.mydomain.com/keystore;  
    keytool -import -trustcacerts -alias unifi -deststorepass aircontrolenterprise -file /etc/letsencrypt/myhost.mydomain.com/chain.pem -noprompt -keystore /etc/letsencrypt/myhost.mydomain.com/keystore;  
    mv /var/lib/unifi/keystore /var/lib/unifi/keystore-backup;  
    cp /etc/letsencrypt/myhost.mydomain.com/keystore /var/lib/unifi/keystore;  
    service unifi restart;
16. Created cron jobs to trigger the cert update daily:  
    `crontab -e`  
    I added this line:  
     20 5 * * * /home/dehydrated/dehydrated --cron --accept-terms --out /etc/letsencrypt >/dev/null 2>&1
17. returned to my previous shell:  
    `exit`
18. Created cron jobs to install the new cert daily:  
    `sudo crontab -e`  
    I added this line:  
     29 5 * * * /home/dehydrated/updateunificert >/dev/null 2>&1

