<!doctype html><htmllang=enclass=no-js><head><metacharset=utf-8><metaname=viewportcontent="width=device-width,initial-scale=1"><metaname=descriptioncontent="An open source, self-hosted implementation of the Tailscale control server."><metaname=authorcontent="Headscale authors"><linkhref=https://juanfont.github.io/headscale/development/ref/tls/rel=canonical><linkhref=../exit-node/rel=prev><linkhref=../acls/rel=next><linkrel=iconhref=../../assets/favicon.png><metaname=generatorcontent="mkdocs-1.6.1, mkdocs-material-9.5.48"><title>TLS - Headscale</title><linkrel=stylesheethref=../../assets/stylesheets/main.6f8fc17f.min.css><linkrel=stylesheethref=../../assets/stylesheets/palette.06af60db.min.css><linkrel=preconnecthref=https://fonts.gstatic.comcrossorigin><linkrel=stylesheethref="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i%7CRoboto+Mono:400,400i,700,700i&display=fallback"><style>:root{--md-text-font:"Roboto";--md-code-font:"Roboto Mono"}</style><script>__md_scope=newURL("../..",location),__md_hash=e=>[...e].reduce(((e,_)=>(e<<5)-e+_.charCodeAt(0)),0),__md_get=(e,_=localStorage,t=__md_scope)=>JSON.parse(_.getItem(t.pathname+"."+e)),__md_set=(e,_,t=localStorage,a=__md_scope)=>{try{t.setItem(a.pathname+"."+e,JSON.stringify(_))}catch(e){}}</script><metaproperty=og:typecontent=website><metaproperty=og:titlecontent="TLS - Headscale"><metaproperty=og:descriptioncontent="An open source, self-hosted implementation of the Tailscale control server."><metaproperty=og:imagecontent=https://juanfont.github.io/headscale/development/assets/images/social/ref/tls.png><metaproperty=og:image:typecontent=image/png><metaproperty=og:image:widthcontent=1200><metaproperty=og:image:heightcontent=630><metacontent=https://juanfont.github.io/headscale/development/ref/tls/property=og:url><metaname=twitter:cardcontent=summary_large_image><metaname=twitter:titlecontent="TLS - Headscale"><metaname=twitter:descriptioncontent="An open source, self-hosted implementation of the Tailscale control server."><metaname=twitter:imagecontent=https://juanfont.github.io/headscale/development/assets/images/social/ref/tls.png></head><bodydir=ltrdata-md-color-scheme=defaultdata-md-color-primary=whitedata-md-color-accent=indigo><inputclass=md-toggledata-md-toggle=drawertype=checkboxid=__drawerautocomplete=off><inputclass=md-toggledata-md-toggle=searchtype=checkboxid=__searchautocomplete=off><labelclass=md-overlayfor=__drawer></label><divdata-md-component=skip><ahref=#running-the-service-via-tls-optionalclass=md-skip> Skip to content </a></div><divdata-md-component=announce></div><divdata-md-color-scheme=defaultdata-md-component=outdatedhidden></div><headerclass=md-headerdata-md-component=header><navclass="md-header__inner md-grid"aria-label=Header><ahref=../..title=Headscaleclass="md-header__button md-logo"aria-label=Headscaledata-md-component=logo><imgsrc=../../logo/headscale3-dots.svgalt=logo></a><labelclass="md-header__button md-icon"for=__drawer><svgxmlns=http://www.w3.org/2000/svgviewbox="0 0 24 24"><pathd="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg></label><divclass=md-header__titledata-md-component=header-title><divclass=md-header__ellipsis><divclass=md-header__topic><spanclass=md-ellipsis> Headscale </span></div><divclass=md-header__topicdata-md-component=header-topic><spanclass=md-ellipsis> TLS </span></div></div></div><formclass=md-header__optiondata-md-component=palette><inputclass=md-optiondata-md-color-mediadata-md-color-scheme=defaultdata-md-color-primary=whitedata-md-color-accent=indigoaria-label="Switch to dark mode"type=radioname=__paletteid=__palette_0><labelclass="md-header__button md-icon"title="Switch to dark mode"for=__palette_1hidden><svgxmlns=http://www.w3.org/2000/svgviewbox="0 0 24 24"><pathd="M128a44000-444400044440004-444000-4-4m010a66001-6-6660016-6660016666001-66m8-9.31V4h-4.69L12.698.694H4v4.69L.6912415.31V20
</span></code></pre></div><p>The certificate should contain the full chain, else some clients, like the Tailscale Android client, will reject it.</p><h2id=lets-encrypt-acme>Let's Encrypt / ACME<aclass=headerlinkhref=#lets-encrypt-acmetitle="Permanent link">¶</a></h2><p>To get a certificate automatically via <ahref=https://letsencrypt.org/>Let's Encrypt</a>, set <code>tls_letsencrypt_hostname</code> to the desired certificate hostname. This name must resolve to the IP address(es) headscale is reachable on (i.e., it must correspond to the <code>server_url</code> configuration parameter). The certificate and Let's Encrypt account credentials will be stored in the directory configured in <code>tls_letsencrypt_cache_dir</code>. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from.</p><divclass="language-yaml highlight"><pre><span></span><code><spanid=__span-1-1><aid=__codelineno-1-1name=__codelineno-1-1href=#__codelineno-1-1></a><spanclass=nt>tls_letsencrypt_hostname</span><spanclass=p>:</span><spanclass=w></span><spanclass=s>""</span>
</span></code></pre></div><h3id=challenge-types>Challenge types<aclass=headerlinkhref=#challenge-typestitle="Permanent link">¶</a></h3><p>Headscale only supports two values for <code>tls_letsencrypt_challenge_type</code>: <code>HTTP-01</code> (default) and <code>TLS-ALPN-01</code>.</p><h4id=http-01>HTTP-01<aclass=headerlinkhref=#http-01title="Permanent link">¶</a></h4><p>For <code>HTTP-01</code>, headscale must be reachable on port 80 for the Let's Encrypt automated validation, in addition to whatever port is configured in <code>listen_addr</code>. By default, headscale listens on port 80 on all local IPs for Let's Encrypt automated validation.</p><p>If you need to change the ip and/or port used by headscale for the Let's Encrypt validation process, set <code>tls_letsencrypt_listen</code> to the appropriate value. This can be handy if you are running headscale as a non-root user (or can't run <code>setcap</code>). Keep in mind, however, that Let's Encrypt will <em>only</em> connect to port 80 for the validation callback, so if you change <code>tls_letsencrypt_listen</code> you will also need to configure something else (e.g. a firewall rule) to forward the traffic from port 80 to the ip:port combination specified in <code>tls_letsencrypt_listen</code>.</p><h4id=tls-alpn-01>TLS-ALPN-01<aclass=headerlinkhref=#tls-alpn-01title="Permanent link">¶</a></h4><p>For <code>TLS-ALPN-01</code>, headscale listens on the ip:port combination defined in <code>listen_addr</code>. Let's Encrypt will <em>only</em> connect to port 443 for the validation callback, so if <code>listen_addr</code> is not set to port 443, something else (e.g. a firewall rule) will be required to forward the traffic from port 443 to the ip:port combination specified in <code>listen_addr</code>.</p><h3id=technical-description>Technical description<aclass=headerlinkhref=#technical-descriptiontitle="Permanent link">¶</a></h3><p>Headscale uses <ahref=https://pkg.go.dev/golang.org/x/crypto/acme/autocert>autocert</a>, a Golang library providing <ahref=https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment>ACME protocol</a> verification, to facilitate certificate renewals via <ahref=https://letsencrypt.org/about/>Let's Encrypt</a>. Certificates will be renewed automatically, and the following can be expected:</p><ul><li>Certificates provided from Let's Encrypt have a validity of 3 months from date issued.</li><li>Renewals are only attempted by headscale when 30 days or less remains until certificate expiry.</li><li>Renewal attempts by autocert are triggered at a random interval of 30-60 minutes.</li><li>No log output is generated when renewals are skipped, or successful.</li></ul><h4id=checking-certificate-expiry>Checking certificate expiry<aclass=headerlinkhref=#checking-certificate-expirytitle="Permanent link">¶</a></h4><p>If you want to validate that certificate renewal completed successfully, this can be done either manually, or through external monitoring software. Two examples of doing this manually:</p><ol><li>Open the URL for your headscale server in your browser of choice, and manually inspecting the expiry date of the certificate you receive.</li><li>Or, check remotely from CLI using <code>openssl</code>:</li></ol><divclass="language-bash highlight"><pre><span></span><code><spanid=__span-2-1><aid=__codelineno-2-1name=__codelineno-2-1href=#__codelineno-2-1></a>$<spanclass=w></span>openssl<spanclass=w></span>s_client<spanclass=w></span>-servername<spanclass=w></span><spanclass=o>[</span>hostname<spanclass=o>]</span><spanclass=w></span>-connect<spanclass=w></span><spanclass=o>[</span>hostname<spanclass=o>]</span>:443<spanclass=w></span><spanclass=p>|</span><spanclass=w></span>openssl<spanclass=w></span>x509<spanclass=w></span>-noout<spanclass=w></span>-dates
</span></code></pre></div><h4id=log-output-from-the-autocert-library>Log output from the autocert library<aclass=headerlinkhref=#log-output-from-the-autocert-librarytitle="Permanent link">¶</a></h4><p>As these log lines are from the autocert library, they are not strictly generated by headscale itself.</p><divclass="language-text highlight"><pre><span></span><code><spanid=__span-3-1><aid=__codelineno-3-1name=__codelineno-3-1href=#__codelineno-3-1></a>acme/autocert: missing server name
</span></code></pre></div><p>Likely caused by an incoming connection that does not specify a hostname, for example a <code>curl</code> request directly against the IP of the server, or an unexpected hostname.</p><divclass="language-text highlight"><pre><span></span><code><spanid=__span-4-1><aid=__codelineno-4-1name=__codelineno-4-1href=#__codelineno-4-1></a>acme/autocert: host "[foo]" not configured in HostWhitelist