Using Let's Encrypt with internal web servers (without DNS challenge)

05 July, 20206 min readWeb Development


  • Use internet facing domain on an internal network, I normally use subdomains for this.
  • Domain must have a DNS A record pointing to a public facing web server so Let's Encrypt can find it for the HTTP-01 challenge. This can be served as an empty site or just as a 404 response.
  • Remote VPS uses certbot to renew SSL certificates as normal.
  • Use a script like to copy the SSL certs from the remote machine to our local private machine. Run this as a cron job.


If you have a web site on an internal network that is not accesible by a public URL, then the most popular HTTP-01 challenge for Let's Encrypt is not going to work. Let's Encrypt needs to access http://<YOUR_DOMAIN>/.well-known/acme-challenge/<TOKEN> which it won't be able to do if your internal or private server is not internet facing.

The alternative is a DNS challenge, which requires a DNS provider with an API interface. It can also be a slow process since you may need to wait for the TTL for your domain.

We'll assume your internal network's web server is not accessible from the internet and that you're running your own DNS server pointing a A record (or CNAME) of to an internet facing server.

Here's an example of how we can get around this and use HTTP-01 challenge.

You'll need your domain name with a web server accessible online, which could be serving a 404 response, or just an empty page. Do this separate to your private server.

Configure certbot to auto renew your SSL certificates as you normally would. I have a separate article about how to use certbot.

Set up a script on your private server to run automatically. The script will:

  • Connect to your remote host via SSH and obtains a tarball of your remote SSL certs. You'l need to make sure you have the correct SSH keys configured so that the SSH commands can run without user interaction. It assumes your certs are located in /etc/letsencrypt/live/$DOMAIN
  • The tarball is copied to the private server using scp and extracted to /etc/letsencrypt/live/$DOMAIN
  • Removes remote tarball
  • Check whether the certs are different (i.e renewed) using sha256sum. If they're different, restart or reload the web server.

We also determine using the script, what the remote IP address is of the domain, by quering for the DNS A record using the domain's SOA DNS server. We do this using:

# Get SOA DNS server
DNS=$(dig soa $DOMAIN | grep -v ^\; | grep SOA | sed -r 's/^(.*)SOA\t//' | cut -d " " -f 1 | sed -r 's/\.$//')

# Get remote public facing web server IP address
IP_REMOTE=$(dig @$DNS +short $DOMAIN A)

Here is a listing of the script overall, however you should use the most updated version on GitHub Gist.

# Copy Let's Encrypt SSL certs from a remote public facing web server to local filesystem
# Look for changes, if any change, restarts the web service
# Useful for using Let's Encrypt with local internal servers, with custom DNS.
# Working "mail" command needed for email alerts

if [ -z $1 ]; then
    echo "Syntax error, use:"
    echo " DOMAIN [EMAIL]"
    exit 1


# send email message here when a renewal occurs, or on error

# Get SOA DNS server
DNS=$(dig soa $DOMAIN | grep -v ^\; | grep SOA | sed -r 's/^(.*)SOA\t//' | cut -d " " -f 1 | sed -r 's/\.$//')

# Get remote public facing web server IP address
IP_REMOTE=$(dig @$DNS +short $DOMAIN A)
if [[ -z $IP_REMOTE ]]; then
    echo "Can not determine remote IP for $DOMAIN"
    exit 1

# .pem certificates will be saved here. You must have write permissions here
# and your Apache or nginx .conf files should refer to this path
# Actual cert files will be in a subdir e.g $CERT_PATH/$DOMAIN/*.pem

# e.g httpd, nginx - if certificates are updated, then will invoke "service $WEB_SERVER restart"

DIR=$(dirname $0)


# this flag is used by this script, leave this alone!

restart_www () {
    if [[ "$WEB_SERVER" == "nginx" ]]; then
        nginx -s reload
        service $WEB_SERVER restart

# return 0 if renewed, 1 if not renewed for whatever reason
refresh_certificates () {

    # create tarball on public server of current letsencrypt certs for desired domain
    echo "Create \"$CERT_TARBALL_REMOTE\" on remote..."
    ssh root@$IP_REMOTE "rm -f \"$CERT_TARBALL_REMOTE\"; tar -zchf \"$CERT_TARBALL_REMOTE\" -C \"$CERT_PATH\" $DOMAIN"

    # copy tarball from public to internal server
    echo "Copy remote \"$CERT_TARBALL_REMOTE\" to local \"$CERT_TARBALL_LOCAL\"..."

    # create cert path if we need to
    [[ -d "$CERT_PATH_DOMAIN" ]] || mkdir -p "$CERT_PATH_DOMAIN"

    # check local tarball is persent
    if [[ ! -f "$CERT_TARBALL_LOCAL" ]]; then
        echo "Error: Local \"$CERT_TARBALL_LOCAL\" is missing. The copy or write operation above has failed."
        exit 1

    echo "Extracting certificates to local \"$CERT_PATH_DOMAIN\"..."
    tar -zxvf "$CERT_TARBALL_REMOTE" -C "$CERT_PATH_DOMAIN" --strip-components=1 > /dev/null

    echo "Delete remote \"$CERT_TARBALL_REMOTE\"..."
    ssh root@$IP_REMOTE "rm -f '$CERT_TARBALL_REMOTE'"

    # Restart web server only if certs changed since last run of this script
    # Although it's probably fine if we just restarted it each time anyway
    # Create a CHECKSUMS file in $CERT_PATH_DOMAIN, verify against this in the future to detect changes
    pushd . &> /dev/null
    if [[ -f CHECKSUMS ]]; then
        echo -n "Checking whether certificates have changed... "
        sha256sum --status -c CHECKSUMS
        if [[ $? -ne 0 ]]; then
            # checksum is different, certificate has changed, restart web server and reclaculate checksums
            echo "Change found. Certificates updated. Restarting or reloading $WEB_SERVER ..."
            sha256sum *.pem > CHECKSUMS
            return 0
            # No update performed, certficates the same as previous
            echo "Certificates not changed."
            return 1
        # no checksum performed yet
        echo "Certificates updated. Restarting or reloading $WEB_SERVER ..."
        sha256sum *.pem > CHECKSUMS
        return 0

    popd > /dev/null


# Script starts here

cert_stats () {
    echo | openssl s_client -showcerts -connect $1:443 2> /dev/null | openssl x509 -noout -dates

# we need to copy those certs to this machine at regular machine

if [[ $? -eq 0 ]]; then
    # updated, both cron and interactive mode
    echo "SSL cert was updated. New certificate validity dates:"
    if [[ ! -z $EMAIL ]]; then
        cert_stats $DOMAIN | tee mail -s "SSL cert updated" $EMAIL
        cert_stats $DOMAIN
elif [[ -t 0 ]]; then
    # not updated - interactive mode
    echo "SSL cert does not need updating. Current certificate validity dates:"
    cert_stats $DOMAIN

exit 0
© Andy Gock 2009−2020