diff --git a/cmd/radiod/radiod.go b/cmd/radiod/radiod.go
index f93484a0e2b146a8068656232ae90cd5431d8e9f..325fdaccce643db111c67dd120cd555987cb8f97 100644
--- a/cmd/radiod/radiod.go
+++ b/cmd/radiod/radiod.go
@@ -27,6 +27,7 @@ var (
 	publicIPs     = util.IPListFlag("public-ip", "Public IP for this machine (may be specified more than once). If unset, the program will try to resolve the local hostname, or it will fall back to inspecting network devices.")
 	peerIP        = util.IPFlag("peer-ip", "Internal IP for this machine (within the cluster), if different from --ip")
 	httpPort      = flag.Int("http-port", 80, "HTTP port")
+	httpsPort     = flag.Int("https-port", 443, "HTTPS port (if 0, disable HTTPS)")
 	dnsPort       = flag.Int("dns-port", 53, "DNS port")
 	gossipPort    = flag.Int("gossip-port", 2323, "Gossip GRPC port")
 	metricsPort   = flag.Int("monitoring-port", 2424, "HTTP port for monitoring")
@@ -37,6 +38,8 @@ var (
 	domain      = flag.String("domain", "", "public DNS domain")
 	lbSpec      = flag.String("lb-policy", "listeners_available,listeners_score,weighted", "Load balancing rules specification (see godoc documentation for details)")
 	nameservers = flag.String("nameservers", "", "Comma-separated list of name servers (not IPs) for the zone specified in --domain")
+	acmeEmail   = flag.String("acme-email", "", "Email address for Letsencrypt account")
+	acmeNames   = flag.String("acme-cert-names", "", "Names to put on the SSL certificate (comma-separated list)")
 
 	icecastConfigPath  = flag.String("icecast-config", "/var/lib/autoradio/icecast.xml", "Icecast configuration file")
 	icecastAdminPwPath = flag.String("icecast-pwfile", "/var/lib/autoradio/.admin_pw", "Path to file with Icecast admin password")
@@ -163,7 +166,24 @@ func main() {
 	// Start all the network services. DNS will listen on all
 	// non-loopback addresses on all interfaces, to let people run
 	// a loopback cache if necessary.
-	srv := node.NewServer(ctx, n, *domain, strings.Split(*nameservers, ","), nonLocalAddrs(), *peerIP, *httpPort, *dnsPort, *gossipPort, autoradio.IcecastPort, *metricsPort)
+	config := node.Config{
+		Domain:      *domain,
+		Nameservers: strings.Split(*nameservers, ","),
+		DNSAddrs:    nonLocalAddrs(),
+		PeerAddr:    *peerIP,
+		HTTPPort:    *httpPort,
+		HTTPSPort:   *httpsPort,
+		DNSPort:     *dnsPort,
+		GossipPort:  *gossipPort,
+		IcecastPort: autoradio.IcecastPort,
+		MetricsPort: *metricsPort,
+		ACMEEmail:   *acmeEmail,
+		CertNames:   strings.Split(*acmeNames, ","),
+	}
+	srv, err := node.NewServer(ctx, etcd, n, &config)
+	if err != nil {
+		log.Fatalf("could not initialize server: %v", err)
+	}
 
 	// Wait until the Node and the Server terminate. A failure in
 	// either the network services or the Node itself should cause
diff --git a/node/acme/acme.go b/node/acme/acme.go
new file mode 100644
index 0000000000000000000000000000000000000000..0533fa645d9be9aa60ee288224c34e5264e33444
--- /dev/null
+++ b/node/acme/acme.go
@@ -0,0 +1,297 @@
+package acme
+
+import (
+	"context"
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"errors"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"sync"
+	"time"
+
+	"golang.org/x/crypto/acme"
+)
+
+const http01Challenge = "http-01"
+
+var acmeRPCTimeout = 30 * time.Second
+
+type keyBackend interface {
+	Put(context.Context, []byte) error
+	Get(context.Context) ([]byte, error)
+}
+
+type tokenBackend interface {
+	Put(context.Context, string, string) error
+	Get(context.Context, string) (string, error)
+	Delete(context.Context, string)
+}
+
+// The ACME object implements the ACME protocol machinery, and can be
+// used to create and renew certificates. It also serves as an HTTP
+// handler to satisfy http-01 validation requests.
+type ACME struct {
+	email        string
+	directoryURL string
+
+	mx       sync.Mutex
+	client   *acme.Client
+	keystore keyBackend
+	tokens   tokenBackend
+}
+
+func NewACME(email, directoryURL string, keystore keyBackend, tokens tokenBackend) *ACME {
+	if directoryURL == "" {
+		directoryURL = acme.LetsEncryptURL
+	}
+	return &ACME{
+		email:        email,
+		directoryURL: directoryURL,
+		keystore:     keystore,
+		tokens:       tokens,
+	}
+}
+
+// Load the account key from the state file and return it, possibly
+// initializing it if it does not exist.
+func (a *ACME) accountKey(ctx context.Context) (crypto.Signer, error) {
+	var keyData []byte
+	var err error
+
+	// Run a get/put loop a few times to resolve eventual
+	// conflicts when servers have synchronized startups.
+	for i := 0; i < 10; i++ {
+		cctx, cancel := context.WithTimeout(ctx, acmeRPCTimeout)
+		keyData, err = a.keystore.Get(cctx)
+		cancel()
+
+		if err != nil {
+			log.Printf("acme: generating new account key")
+			eckey, kerr := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+			if kerr != nil {
+				return nil, kerr
+			}
+			keyData, kerr = x509.MarshalECPrivateKey(eckey)
+			if kerr != nil {
+				return nil, kerr
+			}
+
+			cctx, cancel = context.WithTimeout(ctx, acmeRPCTimeout)
+			err = a.keystore.Put(cctx, keyData)
+			cancel()
+		}
+
+		if err == nil {
+			break
+		}
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	return parsePrivateKey(keyData)
+}
+
+// Return a new acme.Client, possibly initializing the account key if
+// it does not exist yet. The Client is created on the first call and
+// cached thereafter.
+func (a *ACME) acmeClient(ctx context.Context) (*acme.Client, error) {
+	a.mx.Lock()
+	defer a.mx.Unlock()
+
+	if a.client != nil {
+		return a.client, nil
+	}
+
+	client := &acme.Client{
+		DirectoryURL: a.directoryURL,
+	}
+	key, err := a.accountKey(ctx)
+	if err != nil {
+		return nil, err
+	}
+	client.Key = key
+	ac := &acme.Account{
+		Contact: []string{"mailto:" + a.email},
+	}
+
+	// Register the account (accept TOS) if necessary. If the
+	// account is already registered we get a StatusConflict,
+	// which we can ignore.
+	_, err = client.Register(ctx, ac, func(_ string) bool { return true })
+	if ae, ok := err.(*acme.Error); err == nil || err == acme.ErrAccountAlreadyExists || (ok && ae.StatusCode == http.StatusConflict) {
+		a.client = client
+		err = nil
+	}
+	return a.client, err
+}
+
+// fulfill the validation request by storing the token.
+func (a *ACME) fulfill(ctx context.Context, client *acme.Client, _ string, chal *acme.Challenge) (func(), error) {
+	resp, err := client.HTTP01ChallengeResponse(chal.Token)
+	if err != nil {
+		return nil, err
+	}
+	path := client.HTTP01ChallengePath(chal.Token)
+
+	if err := a.tokens.Put(ctx, path, resp); err != nil {
+		return nil, err
+	}
+
+	return func() {
+		go func() {
+			a.tokens.Delete(ctx, path)
+		}()
+	}, nil
+}
+
+// getCertificate returns a certificate chain (and the parsed leaf
+// cert), given a private key and a list of domains. The first domain
+// will be the subject CN, the others will be added as subjectAltNames.
+func (a *ACME) getCertificate(ctx context.Context, key crypto.Signer, names []string) (der [][]byte, leaf *x509.Certificate, err error) {
+	client, err := a.acmeClient(ctx)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	o, err := a.verifyAll(ctx, client, names)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	csr, err := certRequest(key, names)
+	if err != nil {
+		return nil, nil, err
+	}
+	der, _, err = client.CreateOrderCert(ctx, o.FinalizeURL, csr, true)
+	if err != nil {
+		return nil, nil, err
+	}
+	leaf, err = validCert(names, der, key)
+	if err != nil {
+		return nil, nil, err
+	}
+	return der, leaf, nil
+}
+
+func (a *ACME) verifyAll(ctx context.Context, client *acme.Client, names []string) (*acme.Order, error) {
+	// Make an authorization request to the ACME server, and
+	// verify that it returns a valid response with challenges.
+	o, err := client.AuthorizeOrder(ctx, acme.DomainIDs(names...))
+	if err != nil {
+		return nil, fmt.Errorf("AuthorizeOrder failed: %v", err)
+	}
+
+	switch o.Status {
+	case acme.StatusReady:
+		return o, nil // already authorized
+	case acme.StatusPending:
+	default:
+		return nil, fmt.Errorf("invalid new order status %q", o.Status)
+	}
+
+	for _, zurl := range o.AuthzURLs {
+		z, err := client.GetAuthorization(ctx, zurl)
+		if err != nil {
+			return nil, fmt.Errorf("GetAuthorization(%s) failed: %v", zurl, err)
+		}
+		if z.Status != acme.StatusPending {
+			continue
+		}
+		// Pick a challenge that matches our preferences and the
+		// available validators. The validator fulfills the challenge,
+		// and returns a cleanup function that we're going to call
+		// before we return. All steps are sequential and idempotent.
+		chal := pickChallenge(z.Challenges)
+		if chal == nil {
+			return nil, fmt.Errorf("unable to authorize %q", names)
+		}
+
+		// We only support http-01 challenges.
+		if chal.Type != http01Challenge {
+			return nil, fmt.Errorf("challenge type '%s' is not available", chal.Type)
+		}
+
+		for _, domain := range names {
+			cleanup, err := a.fulfill(ctx, client, domain, chal)
+			if err != nil {
+				return nil, fmt.Errorf("fulfillment failed: %v", err)
+			}
+			defer cleanup()
+		}
+
+		if _, err := client.Accept(ctx, chal); err != nil {
+			return nil, fmt.Errorf("challenge accept failed: %v", err)
+		}
+		if _, err := client.WaitAuthorization(ctx, z.URI); err != nil {
+			return nil, fmt.Errorf("WaitAuthorization(%s) failed: %v", z.URI, err)
+		}
+	}
+
+	// Authorizations are satisfied, wait for the CA
+	// to update the order status.
+	if _, err = client.WaitOrder(ctx, o.URI); err != nil {
+		return nil, err
+	}
+	return o, nil
+}
+
+func (a *ACME) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	token, err := a.tokens.Get(req.Context(), req.URL.Path)
+	if err != nil {
+		http.NotFound(w, req)
+		return
+	}
+	io.WriteString(w, token) // nolint: errcheck
+}
+
+// Pick a challenge with the right type from the Challenge response
+// returned by the ACME server. We only support http-01, so we just
+// search for that one.
+func pickChallenge(chal []*acme.Challenge) *acme.Challenge {
+	for _, ch := range chal {
+		if ch.Type == http01Challenge {
+			return ch
+		}
+	}
+	return nil
+}
+
+func certRequest(key crypto.Signer, domains []string) ([]byte, error) {
+	req := &x509.CertificateRequest{
+		Subject: pkix.Name{CommonName: domains[0]},
+	}
+	if len(domains) > 1 {
+		req.DNSNames = domains[1:]
+	}
+	return x509.CreateCertificateRequest(rand.Reader, req, key)
+}
+
+func parsePrivateKey(der []byte) (crypto.Signer, error) {
+	if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
+		return key, nil
+	}
+	if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
+		switch key := key.(type) {
+		case *rsa.PrivateKey:
+			return key, nil
+		case *ecdsa.PrivateKey:
+			return key, nil
+		default:
+			return nil, errors.New("unknown private key type in PKCS#8 wrapping")
+		}
+	}
+	if key, err := x509.ParseECPrivateKey(der); err == nil {
+		return key, nil
+	}
+
+	return nil, errors.New("failed to parse private key")
+}
diff --git a/node/acme/manager.go b/node/acme/manager.go
new file mode 100644
index 0000000000000000000000000000000000000000..c04cdee9ee703e1eb354d391de03fab899474a87
--- /dev/null
+++ b/node/acme/manager.go
@@ -0,0 +1,301 @@
+package acme
+
+import (
+	"context"
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/tls"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/json"
+	"fmt"
+	"log"
+	"math/big"
+	mrand "math/rand"
+	"sync"
+	"time"
+
+	"go.etcd.io/etcd/clientv3"
+)
+
+const (
+	certPath = "acme/cert"
+	keyPath  = "acme/key"
+)
+
+var (
+	checkIntervalSeconds = 9600
+	renewalTimeout       = 1800 * time.Second
+	renewalDays          = 7
+)
+
+type Cert struct {
+	Names []string
+	Priv  []byte
+	Pub   [][]byte
+}
+
+func (c *Cert) TLSCertificate() (*tls.Certificate, error) {
+	pkey, err := parsePrivateKey(c.Priv)
+	if err != nil {
+		return nil, err
+	}
+	return &tls.Certificate{
+		Certificate: c.Pub,
+		PrivateKey:  pkey,
+	}, nil
+}
+
+func (c *Cert) NotAfter() time.Time {
+	cert, err := x509.ParseCertificate(c.Pub[0])
+	if err != nil {
+		return time.Time{}
+	}
+	return cert.NotAfter
+}
+
+// A Manager is responsible for a single SSL certificate (which may
+// have multiple names). It will store the certificate itself, and the
+// ACME state, on etcd, so that it is replicated to all HTTPS servers.
+//
+// Renewal is handled via (internal) cron jobs, with random schedules
+// to avoid having to implement leader-election for such a simple task.
+//
+type Manager struct {
+	*ACME
+
+	names []string
+	cli   *clientv3.Client
+
+	// Keep the Certificate and the parsed version in sync.
+	mx              sync.RWMutex
+	cert            *Cert
+	tlsCert         *tls.Certificate
+	renewalDeadline time.Time
+}
+
+func NewManager(ctx context.Context, cli *clientv3.Client, email, directoryURL string, certNames []string) (*Manager, error) {
+	// Instantiate the ACME protocol handler and have it store its
+	// validation tokens on etcd.
+	acmeMgr := NewACME(email, directoryURL, newEtcdKeyStore(cli, keyPath), newEtcdTokenStore(cli))
+
+	// Try to fetch the existing certificate from etcd, or
+	// generate a self-signed one.
+	cert, rev, err := fetchCert(ctx, cli, certPath)
+	if err != nil {
+		log.Printf("error fetching certificate: %v", err)
+	}
+	if cert == nil {
+		cert, err = makeSelfSignedCert(certNames)
+		if err != nil {
+			return nil, fmt.Errorf("failed to create self-signed certificate: %v", err)
+		}
+	}
+	tlsCert, err := cert.TLSCertificate()
+	if err != nil {
+		return nil, err
+	}
+
+	m := &Manager{
+		ACME:    acmeMgr,
+		names:   certNames,
+		cli:     cli,
+		cert:    cert,
+		tlsCert: tlsCert,
+	}
+
+	// Update m.cert using a watcher.
+	go m.watch(ctx, certPath, rev)
+
+	// Start the background renewer job.
+	go m.renewLoop(ctx)
+
+	return m, nil
+}
+
+func (m *Manager) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
+	m.mx.RLock()
+	defer m.mx.RUnlock()
+	return m.tlsCert, nil
+}
+
+func (m *Manager) setCert(cert *Cert) error {
+	tlsCert, err := cert.TLSCertificate()
+	if err != nil {
+		return err
+	}
+
+	m.mx.Lock()
+	m.cert = cert
+	m.tlsCert = tlsCert
+	m.renewalDeadline = cert.NotAfter().AddDate(0, 0, -renewalDays)
+	m.mx.Unlock()
+
+	return nil
+}
+
+func (m *Manager) shouldRenew() bool {
+	m.mx.RLock()
+	defer m.mx.RUnlock()
+	return time.Now().After(m.renewalDeadline) || !listsEqual(m.cert.Names, m.names)
+}
+
+func (m *Manager) renewLoop(ctx context.Context) {
+	// Initial delay to stagger concurrent initialization.
+	time.Sleep(time.Duration(mrand.Intn(30)) * time.Second)
+
+	for {
+		if m.shouldRenew() {
+			log.Printf("attempting to renew SSL certificate...")
+			if err := m.renew(ctx); err != nil {
+				log.Printf("renewal failed: %v", err)
+			}
+		}
+
+		// Sleep a semi-random amount of time.
+		t := time.After(time.Duration(checkIntervalSeconds/2+mrand.Intn(checkIntervalSeconds)) * time.Second)
+		select {
+		case <-ctx.Done():
+			return
+		case <-t:
+		}
+	}
+}
+
+func (m *Manager) renew(ctx context.Context) error {
+	ctx, cancel := context.WithTimeout(ctx, renewalTimeout)
+	defer cancel()
+
+	m.mx.RLock()
+	cert := m.cert
+	m.mx.RUnlock()
+
+	var key crypto.Signer
+	var err error
+
+	if len(cert.Priv) == 0 {
+		key, err = rsa.GenerateKey(rand.Reader, 2048)
+		if err != nil {
+			return err
+		}
+		cert.Priv, err = x509.MarshalPKCS8PrivateKey(key)
+	} else {
+		key, err = parsePrivateKey(cert.Priv)
+	}
+	if err != nil {
+		return err
+	}
+
+	cert.Pub, _, err = m.ACME.getCertificate(ctx, key, m.names)
+	if err != nil {
+		return err
+	}
+
+	return storeCert(ctx, m.cli, certPath, cert)
+}
+
+func (m *Manager) watchOnce(ctx context.Context, path string, rev int64) error {
+	for resp := range m.cli.Watch(ctx, path, clientv3.WithRev(rev)) {
+		for _, ev := range resp.Events {
+			if ev.Type != clientv3.EventTypePut {
+				continue
+			}
+			var cert Cert
+			if err := json.Unmarshal(ev.Kv.Value, &cert); err != nil {
+				log.Printf("error unmarshaling cert: %v", err)
+				continue
+			}
+
+			if err := m.setCert(&cert); err != nil {
+				log.Printf("error reading saved cert: %v", err)
+			}
+		}
+	}
+
+	// Watcher is gone, recover.
+	return nil
+}
+
+func (m *Manager) watch(ctx context.Context, path string, rev int64) {
+	for {
+		err := m.watchOnce(ctx, path, rev)
+		if err == context.Canceled {
+			return
+		} else if err != nil {
+			log.Printf("watcher error: %s: %v", path, err)
+		}
+
+		time.Sleep(watcherErrDelay)
+
+		cert, newRev, err := fetchCert(ctx, m.cli, path)
+		if err != nil {
+			log.Printf("fetch error: %s: %v", path, err)
+		} else if cert != nil {
+			if err := m.setCert(cert); err != nil {
+				log.Printf("error reading saved cert: %v", err)
+			}
+			rev = newRev
+		}
+	}
+}
+
+func makeSelfSignedCert(names []string) (*Cert, error) {
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		return nil, err
+	}
+
+	keyBytes, err := x509.MarshalPKCS8PrivateKey(key)
+	if err != nil {
+		return nil, fmt.Errorf("marshaling error: %v", err)
+	}
+
+	notBefore := time.Now().AddDate(0, 0, -1)
+	notAfter := notBefore.AddDate(1, 0, 0)
+
+	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate serial number: %v", err)
+	}
+
+	template := x509.Certificate{
+		SerialNumber: serialNumber,
+		Subject: pkix.Name{
+			Organization: []string{"Autoradio"},
+		},
+		NotBefore: notBefore,
+		NotAfter:  notAfter,
+
+		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		BasicConstraintsValid: true,
+		DNSNames:              names,
+	}
+
+	derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Cert{
+		Names: names,
+		Pub:   [][]byte{derBytes},
+		Priv:  keyBytes,
+	}, nil
+}
+
+func listsEqual(a, b []string) bool {
+	tmp := make(map[string]struct{})
+	for _, aa := range a {
+		tmp[aa] = struct{}{}
+	}
+	for _, bb := range b {
+		if _, ok := tmp[bb]; !ok {
+			return false
+		}
+		delete(tmp, bb)
+	}
+	return len(tmp) == 0
+}
diff --git a/node/acme/manager_test.go b/node/acme/manager_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..266b4530e61d055134ae8c379f025751c28d7270
--- /dev/null
+++ b/node/acme/manager_test.go
@@ -0,0 +1,13 @@
+package acme
+
+import "testing"
+
+func TestMakeSelfSignedCert(t *testing.T) {
+	cert, err := makeSelfSignedCert([]string{"name1", "name2"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(cert.Pub) == 0 {
+		t.Fatal("empty cert")
+	}
+}
diff --git a/node/acme/storage.go b/node/acme/storage.go
new file mode 100644
index 0000000000000000000000000000000000000000..fb13345cd52815e1282ce70af290f6bf2340a32a
--- /dev/null
+++ b/node/acme/storage.go
@@ -0,0 +1,110 @@
+package acme
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"time"
+
+	"go.etcd.io/etcd/clientv3"
+)
+
+var (
+	watcherErrDelay = 1 * time.Second
+	certOpTimeout   = 10 * time.Second
+)
+
+func fetchCert(ctx context.Context, cli *clientv3.Client, path string) (*Cert, int64, error) {
+	// Create a context with a timeout.
+	cctx, cancel := context.WithTimeout(ctx, certOpTimeout)
+	defer cancel()
+
+	// Run a Get first.
+	resp, err := cli.Get(cctx, path)
+	if err != nil {
+		return nil, 0, err
+	}
+	rev := resp.Header.Revision
+	if len(resp.Kvs) == 0 {
+		return nil, rev, nil
+	}
+	var cert Cert
+	if err := json.Unmarshal(resp.Kvs[0].Value, &cert); err != nil {
+		return nil, rev, err
+	}
+	return &cert, rev, nil
+}
+
+func storeCert(ctx context.Context, cli *clientv3.Client, path string, cert *Cert) error {
+	// Create a context with a timeout.
+	cctx, cancel := context.WithTimeout(ctx, certOpTimeout)
+	defer cancel()
+
+	data, err := json.Marshal(cert)
+	if err != nil {
+		return err
+	}
+
+	_, err = cli.Put(cctx, path, string(data))
+	return err
+}
+
+const tokenPrefix = "acme/tokens"
+
+type etcdTokenStore struct {
+	cli *clientv3.Client
+}
+
+func newEtcdTokenStore(cli *clientv3.Client) *etcdTokenStore {
+	return &etcdTokenStore{cli: cli}
+}
+
+func (t *etcdTokenStore) Put(ctx context.Context, key, value string) error {
+	_, err := t.cli.Put(ctx, tokenPrefix+key, value)
+	return err
+}
+
+func (t *etcdTokenStore) Get(ctx context.Context, key string) (string, error) {
+	resp, err := t.cli.Get(ctx, tokenPrefix+key)
+	if err != nil {
+		return "", err
+	}
+	if len(resp.Kvs) == 0 {
+		return "", errors.New("not found")
+	}
+	return string(resp.Kvs[0].Value), nil
+}
+
+func (t *etcdTokenStore) Delete(ctx context.Context, key string) {
+	t.cli.Delete(ctx, tokenPrefix+key) // nolint: errcheck
+}
+
+type etcdKeyStore struct {
+	cli  *clientv3.Client
+	path string
+}
+
+func newEtcdKeyStore(cli *clientv3.Client, path string) *etcdKeyStore {
+	return &etcdKeyStore{cli: cli, path: path}
+}
+
+func (k *etcdKeyStore) Put(ctx context.Context, value []byte) error {
+	kvc := clientv3.NewKV(k.cli)
+
+	_, err := kvc.Txn(ctx).
+		If(clientv3.CreateRevision(k.path)).
+		Then(clientv3.OpPut(k.path, string(value))).
+		Commit()
+	return err
+}
+
+func (k *etcdKeyStore) Get(ctx context.Context) ([]byte, error) {
+	resp, err := k.cli.Get(ctx, k.path)
+	if err != nil {
+		return nil, err
+	}
+	if len(resp.Kvs) == 0 {
+		return nil, errors.New("not found")
+	}
+	return resp.Kvs[0].Value, nil
+}
diff --git a/node/acme/valid_cert.go b/node/acme/valid_cert.go
new file mode 100644
index 0000000000000000000000000000000000000000..d734738233cb70d47680961d3a634f925959b4af
--- /dev/null
+++ b/node/acme/valid_cert.go
@@ -0,0 +1,106 @@
+package acme
+
+import (
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/rsa"
+	"crypto/x509"
+	"errors"
+	"fmt"
+	"time"
+)
+
+// Verify that a certificate is valid according both to the current
+// time and to the specified parameters.
+func validCert(domains []string, der [][]byte, key crypto.Signer) (*x509.Certificate, error) {
+	leaf, err := leafCertFromDER(der)
+	if err != nil {
+		return nil, err
+	}
+
+	// Verify the leaf is not expired.
+	if err := isCertExpired(leaf); err != nil {
+		return nil, err
+	}
+
+	// Verify that it matches the given domains.
+	if err := certMatchesDomains(leaf, domains); err != nil {
+		return nil, err
+	}
+
+	// Verify that it matches the private key.
+	if err := certMatchesPrivateKey(leaf, key); err != nil {
+		return nil, err
+	}
+
+	return leaf, nil
+}
+
+func leafCertFromDER(der [][]byte) (*x509.Certificate, error) {
+	x509Cert, err := x509.ParseCertificates(concatDER(der))
+	if err != nil {
+		return nil, err
+	}
+	if len(x509Cert) == 0 {
+		return nil, errors.New("no public key found")
+	}
+	return x509Cert[0], nil
+}
+
+func isCertExpired(cert *x509.Certificate) error {
+	now := time.Now()
+	if now.Before(cert.NotBefore) {
+		return errors.New("certificate isn't valid yet")
+	}
+	if now.After(cert.NotAfter) {
+		return errors.New("certificate expired")
+	}
+	return nil
+}
+
+func certMatchesDomains(cert *x509.Certificate, domains []string) error {
+	for _, domain := range domains {
+		if err := cert.VerifyHostname(domain); err != nil {
+			return fmt.Errorf("certificate does not match domain %q", domain)
+		}
+	}
+	return nil
+}
+
+func certMatchesPrivateKey(cert *x509.Certificate, key crypto.Signer) error {
+	switch pub := cert.PublicKey.(type) {
+	case *rsa.PublicKey:
+		prv, ok := key.(*rsa.PrivateKey)
+		if !ok {
+			return errors.New("private key type does not match public key type")
+		}
+		if pub.N.Cmp(prv.N) != 0 {
+			return errors.New("private key does not match public key")
+		}
+	case *ecdsa.PublicKey:
+		prv, ok := key.(*ecdsa.PrivateKey)
+		if !ok {
+			return errors.New("private key type does not match public key type")
+		}
+		if pub.X.Cmp(prv.X) != 0 || pub.Y.Cmp(prv.Y) != 0 {
+			return errors.New("private key does not match public key")
+		}
+	default:
+		return errors.New("unsupported public key algorithm")
+	}
+	return nil
+}
+
+func concatDER(der [][]byte) []byte {
+	// Append DERs to a single []byte buffer and parse the results.
+	var n int
+	for _, b := range der {
+		n += len(b)
+	}
+	out := make([]byte, n)
+	n = 0
+	for _, b := range der {
+		n += copy(out[n:], b)
+	}
+	return out
+}
diff --git a/node/http.go b/node/http.go
index ba362ace9193e90c0cdee9f1f99e5c8a24794e7e..a6fe51ea471435f8fbd89bed04f56e0680206002 100644
--- a/node/http.go
+++ b/node/http.go
@@ -5,6 +5,7 @@ package node
 
 import (
 	"context"
+	"crypto/tls"
 	"flag"
 	"fmt"
 	"html/template"
@@ -19,6 +20,7 @@ import (
 	"time"
 
 	"git.autistici.org/ale/autoradio"
+	"git.autistici.org/ale/autoradio/node/acme"
 	pb "git.autistici.org/ale/autoradio/proto"
 	assetfs "github.com/elazarl/go-bindata-assetfs"
 	"github.com/lpar/gzipped"
@@ -182,6 +184,8 @@ func serveRedirect(lb *loadBalancer, mount *pb.Mount, w http.ResponseWriter, r *
 		return
 	}
 
+	// Pick an IP address to redirect to.
+	// TODO: replace with an explicit hostname.
 	targetAddr := randomIPWithMatchingProtocol(targetNode.parsedAddrs, r.RemoteAddr)
 	if targetAddr == "" {
 		// This should not happen if the protocol filter in
@@ -285,19 +289,53 @@ func newHTTPServer(name, addr string, h http.Handler) *httpServer {
 func (s *httpServer) Name() string { return s.name }
 
 func (s *httpServer) Start(ctx context.Context) error {
-	return runHTTPServerWithContext(ctx, s.Server)
+	go func() {
+		<-ctx.Done()
+
+		// Create an standalone context with a short timeout.
+		sctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+		s.Server.Shutdown(sctx) // nolint
+		cancel()
+	}()
+	err := s.Server.ListenAndServe()
+	if err == http.ErrServerClosed {
+		err = nil
+	}
+	return err
+}
+
+type httpsServer struct {
+	*httpServer
+}
+
+func newHTTPSServer(name, addr string, h http.Handler, mgr *acme.Manager) *httpsServer {
+	s := &httpsServer{
+		httpServer: newHTTPServer(name, addr, h),
+	}
+	s.httpServer.Server.TLSConfig = &tls.Config{
+		SessionTicketsDisabled:   true,
+		PreferServerCipherSuites: true,
+		MinVersion:               tls.VersionTLS12,
+		CipherSuites: []uint16{
+			tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+			tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
+		},
+		GetCertificate: mgr.GetCertificate,
+	}
+
+	return s
 }
 
-func runHTTPServerWithContext(ctx context.Context, srv *http.Server) error {
+func (s *httpsServer) Start(ctx context.Context) error {
 	go func() {
 		<-ctx.Done()
 
 		// Create an standalone context with a short timeout.
 		sctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
-		srv.Shutdown(sctx) // nolint
+		s.Server.Shutdown(sctx) // nolint
 		cancel()
 	}()
-	err := srv.ListenAndServe()
+	err := s.Server.ListenAndServeTLS("", "")
 	if err == http.ErrServerClosed {
 		err = nil
 	}
diff --git a/node/server.go b/node/server.go
index 5c3b7530096388cf1caa3d40284b3754a08504e6..efffd44027ae489094156d6601fd88e55bc75f55 100644
--- a/node/server.go
+++ b/node/server.go
@@ -5,9 +5,13 @@ import (
 	"fmt"
 	"log"
 	"net"
+	"net/http"
 	"strconv"
+	"strings"
 	"time"
 
+	"git.autistici.org/ale/autoradio/node/acme"
+	"go.etcd.io/etcd/clientv3"
 	"golang.org/x/sync/errgroup"
 )
 
@@ -65,6 +69,25 @@ func (s *Server) Wait() error {
 	return s.eg.Wait()
 }
 
+// Config holds all the configuration parameters for a
+// Server. NewServer has too many arguments otherwise.
+type Config struct {
+	Domain      string
+	Nameservers []string
+	DNSAddrs    []net.IP
+	PeerAddr    net.IP
+	HTTPPort    int
+	HTTPSPort   int
+	DNSPort     int
+	GossipPort  int
+	IcecastPort int
+	MetricsPort int
+
+	ACMEEmail        string
+	ACMEDirectoryURL string
+	CertNames        []string
+}
+
 // NewServer creates a new Server. Will use publicAddrs / peerAddr to
 // build all the necessary addr/port combinations.
 //
@@ -72,22 +95,42 @@ func (s *Server) Wait() error {
 // DNS servers will bind only to the dnsAddrs (both TCP and
 // UDP). The metrics and the status services, which are internal, will
 // bind on peerAddr.
-func NewServer(ctx context.Context, n *Node, domain string, nameservers []string, dnsAddrs []net.IP, peerAddr net.IP, httpPort, dnsPort, gossipPort, icecastPort, metricsPort int) *Server {
-	httpHandler := newHTTPHandler(n, icecastPort, domain)
-	dnsHandler := newDNSHandler(n, domain, nameservers)
-
-	servers := []genericServer{
-		newStatusServer(mkaddr(peerAddr, gossipPort), n.statusMgr),
-		newHTTPServer("main", fmt.Sprintf(":%d", httpPort), httpHandler),
-		newHTTPServer("metrics", fmt.Sprintf(":%d", metricsPort), newMetricsHandler()),
+func NewServer(ctx context.Context, etcd *clientv3.Client, n *Node, config *Config) (*Server, error) {
+	httpHandler := newHTTPHandler(n, config.IcecastPort, config.Domain)
+	dnsHandler := newDNSHandler(n, config.Domain, config.Nameservers)
+
+	var servers []genericServer
+
+	// If HTTPS is requested, inject the ACME handler on the HTTP handler.
+	if config.HTTPSPort > 0 {
+		acmeMgr, err := acme.NewManager(ctx, etcd, config.ACMEEmail, config.ACMEDirectoryURL, config.CertNames)
+		if err != nil {
+			return nil, err
+		}
+
+		httpsHandler := httpHandler
+		servers = append(servers, newHTTPSServer("https", fmt.Sprintf(":%d", config.HTTPSPort), httpsHandler, acmeMgr))
+		httpHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			if strings.HasPrefix(req.URL.Path, "/.well-known/acme-challenge/") {
+				acmeMgr.ServeHTTP(w, req)
+				return
+			}
+			httpsHandler.ServeHTTP(w, req)
+		})
 	}
-	for _, ip := range dnsAddrs {
+
+	servers = append(servers, newStatusServer(mkaddr(config.PeerAddr, config.GossipPort), n.statusMgr))
+	servers = append(servers, newHTTPServer("main", fmt.Sprintf(":%d", config.HTTPPort), httpHandler))
+	servers = append(servers, newHTTPServer("metrics", fmt.Sprintf(":%d", config.MetricsPort), newMetricsHandler()))
+
+	for _, ip := range config.DNSAddrs {
 		servers = append(servers,
-			newDNSServer("dns(udp)", mkaddr(ip, dnsPort), "udp", dnsHandler),
-			newDNSServer("dns(tcp)", mkaddr(ip, dnsPort), "tcp", dnsHandler),
+			newDNSServer("dns(udp)", mkaddr(ip, config.DNSPort), "udp", dnsHandler),
+			newDNSServer("dns(tcp)", mkaddr(ip, config.DNSPort), "tcp", dnsHandler),
 		)
 	}
-	return buildServer(ctx, servers...)
+
+	return buildServer(ctx, servers...), nil
 }
 
 func mkaddr(ip net.IP, port int) string {