ACME Configuration#

The ACME module in Angie enables automatic certificate retrieval using the ACME protocol. The ACME protocol supports various domain verification methods (also called "validation"); this module implements HTTP validation, DNS validation, ALPN validation, and hook-based validation through a custom external service.

Configuration Steps#

General steps to enable certificate requests in the configuration:

  • Configure an ACME client in the http block using the acme_client directive, which specifies a unique client name and other parameters. Multiple ACME clients can be configured.

  • Specify the domains for which certificates are requested: A single certificate will be issued for all domain names listed in server_name directives within all server blocks that use acme directives pointing to the same ACME client.

    IP addresses (IPv4 or IPv6) specified in server_name are also valid certificate identifiers, except with DNS validation.

    Note that public certificate authorities may issue certificates for IP addresses only under specific certificate profiles; for example, Let's Encrypt currently requires a profile that permits IP identifiers (see its profiles documentation). To request one, set the profile= parameter of acme_client.

  • Set up request handling and ACME callbacks: This is required to verify domain ownership. The setup depends on the chosen domain validation method:

    Method

    User Requirements

    Multi-domain

    Wildcard Domains

    HTTP Validation

    Open port 80 (or the one specified in acme_http_port) for incoming connections on the Angie server.

    ✔

    DNS Validation

    Open port 53 (or the one specified in acme_dns_port) for incoming connections on the Angie server.

    Set an NS record for the _acme-challenge. subdomain pointing to your Angie server.

    ✔

    ✔

    ALPN Validation

    Open port 443 (or the TLS port used by your server) for incoming connections on the Angie server.

    ✔

    Hook validation

    Create an external service (script or application) that can, on request from Angie, update DNS records or serve a special response via the web server.

    ✔

    ✔

  • Configure SSL using the obtained certificate and key: The module makes certificates and keys available as embedded variables that can be used in configuration to populate ssl_certificate and ssl_certificate_key.

    For SSL setup instructions, refer to SSL Configuration.

Tip

The certificate acquisition and renewal process depends on many services and may take some time. Be patient, and if you encounter problems or have doubts, check the debug log.

Implementation Details#

Client keys and certificates are stored in PEM encoding within subdirectories of the directory specified by the --http-acme-client-path build option:

$ ls /var/lib/angie/acme/example/

  account.key  certificate.pem  private.key

Note

These files persist on disk between restarts. On startup, the client reuses a stored certificate that is still valid instead of requesting a new one, avoiding both the issuance delay and unnecessary requests to the CA, which may be subject to rate limits. In a container, place the storage directory (by default /var/lib/angie/acme/) on a persistent volume so that issued certificates survive container recreation; see running Angie in a container.

Note

Reloading the configuration (angie -s reload) re-evaluates every ACME client against the certificates stored on disk. A client whose certificate is not currently valid (including one whose previous request ended in an error) is requested again immediately, regardless of retry_after_error. The retry delay (and retry_after_error=off) applies only while Angie keeps running; it does not survive a reload. Reloading repeatedly while a request keeps failing can therefore send frequent requests to the CA, subject to the rate limits noted above.

The ACME client requires an account on the CA server. To create and manage this account, the client uses a private key (account.key). If no key exists, it is generated at startup. The client then uses this key to register the account with the server.

Note

If you already have an account key, place it in the client's subdirectory before starting to reuse the account. Alternatively, specify the key file using the account_key parameter in acme_client.

Some CAs require the ACME account to be linked with an existing external account through External Account Binding (EAB) before they will issue certificates. To enable this, configure the eab parameter of acme_client with the key identifier and MAC key the CA provides; see the directive's description for the exact syntax.

The ACME client also uses a separate key (private.key) for Certificate Signing Requests (CSRs). This certificate key is automatically created at startup if needed.

At startup, the client requests a certificate if one doesn't exist, signing and sending a CSR for all domains under its management to the CA server. The server verifies domain ownership using HTTP or DNS validation and issues a certificate, which the client saves locally (certificate.pem).

As mentioned earlier, a single certificate covers all domain names managed by the same ACME client, potentially resulting in a multi-domain certificate. The list of all names covered by the certificate can be found in the Subject Alternative Name (SAN) section of the obtained certificate. To check this from the command line:

$ openssl x509 -in certificate.pem -noout -text | grep -A5 "Subject Alternative Name"

When a certificate is about to expire or the domain list changes, the client signs and sends another CSR to the CA server. The server re-verifies ownership and issues a new certificate, which the client installs locally, replacing the previous one.

In the configuration, the obtained certificate and its corresponding key are available through the prefix variables $acme_cert_<name> and $acme_cert_key_<name>. Their values are the contents of the respective files, which should be used with the ssl_certificate and ssl_certificate_key directives:

server {

    listen 443 ssl;

    server_name example.com www.example.com;
    acme example;

    ssl_certificate $acme_cert_example;
    ssl_certificate_key $acme_cert_key_example;
}

Note

The ACME client resolves the CA server's host name through the resolver of the same context, which by default queries the DNS servers listed in /etc/resolv.conf; configure the directive explicitly only to use other servers or parameters. On a host without IPv6 connectivity, the resolver may still issue AAAA (IPv6) queries for the CA and fail to connect; disable them with the ipv6=off parameter, for example resolver conf ipv6=off;.

Domain Collection vs. Certificate Usage#

The acme directive serves only to collect domain names for certificate requests. It does not control where the certificate can be used: any server block can reference the obtained certificate through the $acme_cert_<name> variable, regardless of whether the block contains an acme directive.

For example, if you have a wildcard server block that already covers all subdomains, additional server blocks for specific subdomains do not need the acme directive:

http {

    acme_client example https://acme-v02.api.letsencrypt.org/directory
        challenge=dns;

    # This block lists the domains for the certificate request
    server {

        listen 443 ssl;

        server_name example.com *.example.com;
        acme example;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;
    }

    # This block uses the same certificate but does not
    # add its server_name to the certificate request
    server {

        listen 443 ssl;

        server_name app.example.com;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;
    }
}

Explicit Domain List#

To control the exact set of domain names in a certificate without relying on automatic collection from all server blocks, create a dedicated server block that contains only the server_name and acme directives. To prevent this block from handling real traffic, bind it to a Unix domain socket:

# Dedicated block that defines the certificate's domain list
server {

    listen unix:/tmp/acme_example.sock;

    server_name example.com www.example.com;
    acme example;
}

Other server blocks can then use the certificate through the $acme_cert_<name> variable without affecting which domains are requested.

Separate Certificates for Different Domains#

Each acme_client manages a single certificate. To obtain several independent certificates (for example, for unrelated domains that should not share one certificate), configure a separate acme_client for each in the http block, and point the acme directive of every server block at the relevant client by name:

http {

    # Two independent clients, each managing its own certificate
    acme_client shop https://acme-v02.api.letsencrypt.org/directory
        challenge=http;

    acme_client blog https://acme-v02.api.letsencrypt.org/directory
        challenge=http;

    server {

        listen 443 ssl;

        server_name shop.example.com www.shop.example.com;
        acme shop;

        ssl_certificate $acme_cert_shop;
        ssl_certificate_key $acme_cert_key_shop;
    }

    server {

        listen 443 ssl;

        server_name blog.example.com;
        acme blog;

        ssl_certificate $acme_cert_blog;
        ssl_certificate_key $acme_cert_key_blog;
    }
}

HTTP Validation#

Validation is automatic. When Angie orders a certificate, the ACME server requests a token file over HTTP from Angie at /.well-known/acme-challenge/<TOKEN>. Angie answers these requests itself, so you need not serve that path yourself. The ACME server then compares the token it receives with the one it issued and, if they match, accepts the domain as yours.

Configuration Example#

In this example, the ACME client named example manages certificates for example.com and www.example.com (note that wildcard certificates aren't supported with HTTP validation):

http {

    acme_client example https://acme-v02.api.letsencrypt.org/directory;

    server {

        listen 80; # Not required: if nothing else listens on the HTTP
                   # challenge port, the module opens it itself
                   # (see the 'acme_http_port' directive)

        listen 443 ssl;

        server_name example.com www.example.com;
        acme example;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;
    }
}

As noted earlier, port 80 must be open to handle HTTP ACME calls. If no server is configured to listen on the HTTP challenge port, the module creates a dedicated listener on port 80 (or the one set in acme_http_port). A separate server block is not required.

DNS Validation#

Validation is automatic, but it needs DNS records on your side. When Angie orders a certificate, the ACME server sends a DNS query for a TXT record under the _acme-challenge. subdomain of the domain being validated. Angie answers these queries itself: it acts as the authoritative name server for that subdomain and returns the TXT record the ACME server expects. The ACME server compares the record it receives with the value it issued and, if they match, accepts the domain as yours.

For the query to reach Angie, your DNS must delegate the _acme-challenge. subdomain to the Angie server, as shown below. Angie answers with a very short TTL by default; some DNS providers filter out responses with a TTL that low, so raise it with acme_dns_ttl if validation never completes.

DNS validation proves control of a domain name, so it cannot validate IP address identifiers: Angie ignores an IP address in server_name for a client configured with challenge=dns, logging a warning at startup. A server block that lists an IP address must still name at least one domain the client can validate.

Note

The Angie server must be reachable from the internet on UDP port 53 (or the one specified in acme_dns_port). If the server is behind a firewall, make sure this port is open for incoming connections.

For example, to verify the domain example.com using an Angie server at IP address 203.0.113.10, your domain's DNS configuration should include the following records:

_acme-challenge.example.com. 60    IN      NS       ns.example.com.
             ns.example.com. 60    IN       A       203.0.113.10

This configuration delegates DNS resolution for _acme-challenge.example.com to ns.example.com, ensuring ns.example.com is accessible by mapping it to the IP address (203.0.113.10).

Warning

NS record propagation can take anywhere from a few minutes to 48 hours depending on TTL and DNS provider. It is recommended to verify the configuration is correct before requesting a certificate.

To verify that DNS is configured correctly, you can use the following commands:

$ dig NS _acme-challenge.example.com +short  # Check NS record for _acme-challenge subdomain

  ns.example.com.

$ dig A ns.example.com +short  # Check A record for name server

  203.0.113.10

$ nc -zv 203.0.113.10 53  # Check DNS server accessibility on port 53

This method allows requesting wildcard certificates, for example, a certificate that includes the entry *.example.com in the Subject Alternative Name (SAN) section. To explicitly request a certificate for a subdomain, such as www.example.com, you must separately verify that subdomain using the method described above.

Warning

The applicability of this scenario largely depends on the capabilities provided by your DNS provider; some providers do not allow such configurations.

Configuration Example#

Overall, the configuration is similar to the example in the previous section. There is no need for HTTP-specific settings; instead, it's sufficient to set challenge=dns for the acme_client directive.

In this example, the ACME client named example manages certificates for example.com and *.example.com:

http {

    acme_client example https://acme-v02.api.letsencrypt.org/directory
        challenge=dns;

    server {

        server_name example.com *.example.com;
        acme example;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;
    }
}

ALPN Validation#

Validation is handled automatically. The ACME server connects using TLS and requests the acme-tls/1 protocol via ALPN. The module serves a temporary certificate for the validation request.

To enable this method, configure challenge=alpn in the acme_client directive and ensure your TLS listener is reachable on port 443 (or the port used for TLS).

Configuration Example#

The configuration is similar to the previous sections; it is enough to set challenge=alpn for the acme_client directive and ensure the TLS server is reachable on port 443.

In this example, the ACME client named example manages a certificate for example.com and www.example.com:

http {

    acme_client example https://acme-v02.api.letsencrypt.org/directory
        challenge=alpn;

    server {

        listen 443 ssl;

        server_name example.com www.example.com;
        acme example;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;
    }
}

Hook-Based Validation#

Unlike the previous methods, this validation requires additional effort. The ACME server performs standard HTTP validation or DNS validation, but instead of interacting directly with the Angie server, it communicates with an external service managed by the Angie server using hook calls (acme_hook). This service configures a separate DNS or HTTP server where the ACME server sends its requests.

Once the ACME server receives the expected response from the configured DNS or HTTP server, it confirms domain ownership.

When certificate issuance or renewal requires domain verification, Angie generates an internal request to the named location containing the acme_hook directive. How this request is handled depends entirely on the other directives configured in the same location.

The general pattern is:

  1. Create a named location with the acme_hook directive.

  2. Configure a request handler in the same location using whatever module fits your setup: fastcgi_pass for FastCGI, proxy_pass for HTTP, cgi_pass for CGI scripts, etc.

  3. Pass ACME variables to the handler using the mechanism it supports, for example fastcgi_param for FastCGI or cgi_set_var for CGI.

The handler must return a 2xx status code, which can be sent via the Status header. Any other code from the add hook aborts the renewal attempt; from the remove hook, the same code is only logged as a warning, and the renewal continues. Output from the handler is ignored.

Minimal Configuration#

Regardless of the handler used, the hook location follows this structure:

location @acme_hook_location {

    acme_hook example;

    # Handler directive (fastcgi_pass, proxy_pass, cgi_pass, ...)
    # Pass ACME variables using the handler's mechanism:
    #   ACME_HOOK       — $acme_hook_name ("add" or "remove")
    #   ACME_CHALLENGE  — $acme_hook_challenge ("dns" or "http")
    #   ACME_DOMAIN     — $acme_hook_domain
    #   ACME_TOKEN      — $acme_hook_token
    #   ACME_KEYAUTH    — $acme_hook_keyauth
}

For DNS validation, the handler must use ACME_HOOK to determine the action: when it is add, create a TXT record for _acme-challenge.ACME_DOMAIN with the value from ACME_KEYAUTH; when it is remove, delete that record.

FastCGI Example#

In this example, the ACME client example is configured for domain verification using DNS callbacks, indicated by the challenge=dns parameter in the acme_client directive.

The server block applies to all subdomains of example.com (e.g., *.example.com) and uses the ACME client example to manage certificates, as specified by the acme directive.

A named location block handles the hook calls. The acme_hook directive associates it with the ACME client example. Hook requests are sent to a local FastCGI server on port 9000 using fastcgi_pass. The fastcgi_param directives pass the ACME variables to the external service.

acme_client example https://acme-v02.api.letsencrypt.org/directory
    challenge=dns;

server {

    listen 80;

    server_name *.example.com;

    acme example;

    ssl_certificate $acme_cert_example;
    ssl_certificate_key $acme_cert_key_example;

    location @acme_hook_location {

        acme_hook example;

        fastcgi_pass localhost:9000;

        fastcgi_param ACME_CLIENT $acme_hook_client;
        fastcgi_param ACME_HOOK $acme_hook_name;
        fastcgi_param ACME_CHALLENGE $acme_hook_challenge;
        fastcgi_param ACME_DOMAIN $acme_hook_domain;
        fastcgi_param ACME_TOKEN $acme_hook_token;
        fastcgi_param ACME_KEYAUTH $acme_hook_keyauth;

        include fastcgi.conf;
    }
}

The following Perl script demonstrates a corresponding external FastCGI service:

#!/usr/bin/perl

use strict; use warnings;

use FCGI;

my $socket = FCGI::OpenSocket(":9000", 5);
my $request = FCGI::Request(\*STDIN, \*STDOUT, \*STDERR, \%ENV, $socket);

while ($request->Accept() >= 0) {
    print "\r\n";

    my $client =    $ENV{ACME_CLIENT};
    my $hook =      $ENV{ACME_HOOK};
    my $challenge = $ENV{ACME_CHALLENGE};
    my $domain =    $ENV{ACME_DOMAIN};
    my $token =     $ENV{ACME_TOKEN};
    my $keyauth =   $ENV{ACME_KEYAUTH};

    if ($hook eq 'add') {

        DNS_set_TXT_record("_acme-challenge.$domain.", $keyauth);

    } elsif ($hook eq 'remove') {

        DNS_clear_TXT_record("_acme-challenge.$domain.");
    }
};

FCGI::CloseSocket($socket);

Here, DNS_set_TXT_record() and DNS_clear_TXT_record() are functions assumed to add and remove TXT records in the configuration of an external DNS server that the ACME server queries. These records must contain the data provided by the Angie server to allow the external DNS server to successfully pass validation, similar to the process described in DNS Validation. The implementation details of such functions are beyond the scope of this guide; for example, parameters can also be passed through the request URI:

# ...

location @acme_hook_location {

    acme_hook example uri=/acme_hook/$acme_hook_name?domain=$acme_hook_domain&key=$acme_hook_keyauth;

    fastcgi_pass localhost:9000;

    fastcgi_param REQUEST_URI $request_uri;
    fastcgi_param ACME_CLIENT $acme_hook_client;
    fastcgi_param ACME_CHALLENGE $acme_hook_challenge;
    fastcgi_param ACME_TOKEN $acme_hook_token;

    include fastcgi.conf;
}

PHP-FPM Example#

Another example, using PHP-FPM:

location @acme_hook_location {

    acme_hook example;
    root /var/www/dns;
    fastcgi_pass unix:/run/php-fpm/php-dns.sock;
    fastcgi_index hook.php;
    fastcgi_param SCRIPT_FILENAME /var/www/dns/hook.php;
    include fastcgi_params;

    fastcgi_param ACME_CLIENT $acme_hook_client;
    fastcgi_param ACME_HOOK $acme_hook_name;
    fastcgi_param ACME_CHALLENGE $acme_hook_challenge;
    fastcgi_param ACME_DOMAIN $acme_hook_domain;
    fastcgi_param ACME_TOKEN $acme_hook_token;
    fastcgi_param ACME_KEYAUTH $acme_hook_keyauth;
}
[dns]
listen = /run/php-fpm/php-dns.sock
listen.mode = 0666
user = angie
group = angie
chdir = /var/www/dns
# ...

Parameters passed can be accessed in PHP via $_SERVER['...'].

CGI and octoDNS Example#

Here the hook is a script run by the CGI module; it adds and removes the _acme-challenge TXT record through octoDNS, which keeps DNS as code: a zone is described in YAML and synchronized to a DNS provider. Cloudflare is shown here; any other octoDNS provider works the same way once it is declared under providers and named in targets. The API token needs the Zone:Read, DNS:Read, and DNS:Edit permissions for the zone.

The module is loaded in the main context. The ACME client example requests a single certificate for example.com and *.example.com, verified through DNS callbacks as indicated by the challenge=dns parameter in the acme_client directive. A named location block handles the hook calls: the acme_hook directive associates it with the client, the cgi_set_var directives pass the ACME variables under the standard names, and cgi_pass runs the script for each call. Of those names, this script uses ACME_CHALLENGE, ACME_HOOK, ACME_DOMAIN, and ACME_KEYAUTH.

# in the main context
load_module modules/ngx_http_cgi_module.so;

acme_client example https://acme-v02.api.letsencrypt.org/directory
    challenge=dns;

server {

    listen 443 ssl;

    server_name example.com *.example.com;

    acme example;

    ssl_certificate $acme_cert_example;
    ssl_certificate_key $acme_cert_key_example;

    location @acme_hook_location {

        acme_hook example;

        cgi_set_var ACME_CLIENT $acme_hook_client;
        cgi_set_var ACME_HOOK $acme_hook_name;
        cgi_set_var ACME_CHALLENGE $acme_hook_challenge;
        cgi_set_var ACME_DOMAIN $acme_hook_domain;
        cgi_set_var ACME_TOKEN $acme_hook_token;
        cgi_set_var ACME_KEYAUTH $acme_hook_keyauth;

        cgi_pass /usr/share/angie/cgi-bin/acme-octodns;
    }
}

octoDNS builds a zone from its sources in order and merges them, so the hook keeps the challenge record in a source directory of its own, /etc/octodns/acme, and certificate renewals never rewrite the zone file under /etc/octodns/zones. The ignore_missing_zones option (octoDNS 1.16 and later) lets /etc/octodns/acme stay empty between renewals. The zone file itself must not define _acme-challenge records.

/etc/octodns/octodns.yaml#
providers:
  config:
    class: octodns.provider.yaml.YamlProvider
    directory: /etc/octodns/zones
  acme:
    class: octodns.provider.yaml.YamlProvider
    directory: /etc/octodns/acme
    ignore_missing_zones: true
  cloudflare:
    class: octodns_cloudflare.CloudflareProvider
    token: YOUR_API_TOKEN

zones:
  example.com.:
    sources:
      - config
      - acme
    targets:
      - cloudflare

The zone file holds the records you maintain yourself:

/etc/octodns/zones/example.com.yaml#
'':
  type: A
  value: 203.0.113.10
www:
  type: A
  value: 203.0.113.10

To check the octoDNS configuration by hand, run octodns-sync without --doit.

The script reads the ACME variables from its environment and writes the challenge record into the acme source:

The acme-octodns script
/usr/share/angie/cgi-bin/acme-octodns#
#!/opt/octodns/bin/python3
# DNS-01 hook for the Angie ACME client: keeps the _acme-challenge TXT record
# in a separate octodns source directory and publishes it with octodns-sync.

import fcntl
import os
import subprocess
import sys
import time

import dns.message
import dns.query
import dns.resolver
import yaml

CONFIG = "/etc/octodns/octodns.yaml"
ACME_DIR = "/etc/octodns/acme"
SYNC = "/opt/octodns/bin/octodns-sync"
TTL = 120
WAIT = int(os.environ.get("ACME_WAIT", "300"))


def respond(code, message=""):
    if message:
        print(message, file=sys.stderr)
    sys.stdout.write(f"Status: {code}\r\n\r\n")
    sys.exit(0)


def find_zone(domain):
    with open(CONFIG) as f:
        zones = [z.rstrip(".") for z in yaml.safe_load(f)["zones"]]
    match = [z for z in zones if domain == z or domain.endswith("." + z)]
    if not match:
        respond(500, f"no zone for {domain} in {CONFIG}")
    return max(match, key=len)


def update(zone, name, value, add):
    # One record may hold several values: a certificate for example.com and
    # *.example.com is validated with two TXT values on the same name.
    path = f"{ACME_DIR}/{zone}.yaml"
    with open(f"{ACME_DIR}/.lock", "w") as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)
        data = {}
        if os.path.exists(path):
            with open(path) as f:
                data = yaml.safe_load(f) or {}
        values = set(data.get(name, {}).get("values", []))
        (values.add if add else values.discard)(value)
        if values:
            data[name] = {"type": "TXT", "ttl": TTL, "values": sorted(values)}
        else:
            data.pop(name, None)
        if data:
            with open(path, "w") as f:
                yaml.safe_dump(data, f)
        elif os.path.exists(path):
            os.remove(path)
        cmd = [SYNC, f"--config-file={CONFIG}", "--doit", zone + "."]
        run = subprocess.run(cmd, capture_output=True, text=True)
        if run.returncode != 0:
            respond(500, "octodns-sync failed:\n" + run.stdout + run.stderr)


def has_txt(server, fqdn, value):
    try:
        reply = dns.query.udp(dns.message.make_query(fqdn, "TXT"), server, timeout=3)
    except Exception:
        return False
    return any(value == b"".join(getattr(r, "strings", ())).decode()
               for rrset in reply.answer for r in rrset)


def wait_visible(zone, fqdn, value):
    # The ACME server asks the zone's authoritative servers, so poll them
    # directly: a resolver may have cached the record's absence.
    deadline = time.monotonic() + WAIT
    while time.monotonic() < deadline:
        try:
            servers = [str(a) for ns in dns.resolver.resolve(zone, "NS")
                       for a in dns.resolver.resolve(str(ns.target), "A")]
            if servers and all(has_txt(s, fqdn, value) for s in servers):
                return True
        except Exception:
            pass
        time.sleep(5)
    return False


def main():
    hook = os.environ.get("ACME_HOOK")
    domain = os.environ.get("ACME_DOMAIN", "")
    value = os.environ.get("ACME_KEYAUTH", "")
    if os.environ.get("ACME_CHALLENGE") != "dns" or hook not in ("add", "remove"):
        respond(500, "the hook supports only DNS validation")
    zone = find_zone(domain)
    rel = domain[: -len(zone) - 1]
    name = "_acme-challenge" + (f".{rel}" if rel else "")
    update(zone, name, value, hook == "add")
    if hook == "add" and WAIT and not wait_visible(zone, f"{name}.{zone}.", value):
        update(zone, name, value, False)
        respond(500, f"{name}.{zone} is not visible on the name servers after {WAIT}s")
    respond(200)


try:
    main()
except Exception as e:
    respond(500, f"{type(e).__name__}: {e}")

As soon as the add hook returns a 2xx code, Angie asks the ACME server to validate the challenge and polls the result for 60 seconds. Angie never waits for the record to propagate, so the script does that itself. It queries the zone's authoritative name servers directly, since a resolver may have cached the record's absence, and returns as soon as every one of them serves the value, waiting up to 300 seconds by default.

If the value does not appear in time, the script answers Status: 500, and Angie aborts the attempt and retries it after the interval set by the retry_after_error parameter. Angie does not call the remove hook after a failed add, so the script deletes its own value before answering.

The wait limit comes from the ACME_WAIT environment variable. The hook location above does not set it; to change the limit, add it there, passing the value through a variable like the others:

set $acme_wait 600;
cgi_set_var ACME_WAIT $acme_wait;

Setting it to 0 disables the wait, which suits a self-hosted authoritative server where an API write is visible immediately.

The $acme_hook_domain variable arrives without the *. prefix, so a certificate for example.com and *.example.com produces two add calls for the same _acme-challenge.example.com name. The script keeps a list of values on one record, and on remove it drops only the value from that call; once no values are left, it deletes the record and the file that held it under /etc/octodns/acme.

The script runs as the user of the Angie worker processes, angie. It needs read access to the octoDNS configuration, which holds the token, and write access to /etc/octodns/acme; nothing else. The CGI process does not inherit Angie's environment, which is why the token lives in the configuration file and the script uses absolute paths.

The script's standard error goes to the Angie error log at the warn level, where hook failures are diagnosed; these messages appear only when the log level is warn or less severe.

To put all of this in place on a Debian-based system:

  1. Install the CGI module:

    $ sudo apt-get install angie-module-cgi
    
  2. Install octoDNS and the Cloudflare provider into a virtual environment of their own:

    $ sudo apt-get install python3-venv
    $ sudo python3 -m venv /opt/octodns
    $ sudo /opt/octodns/bin/pip install 'octodns>=1.16' octodns-cloudflare
    
  3. Create the directories; the module package does not create /usr/share/angie/cgi-bin:

    $ sudo install -d /etc/octodns/zones /usr/share/angie/cgi-bin
    $ sudo install -d -o angie -g angie -m 750 /etc/octodns/acme
    
  4. Create /etc/octodns/octodns.yaml and /etc/octodns/zones/example.com.yaml as shown above, then restrict the configuration to the angie group, since it holds the API token:

    $ sudo chgrp angie /etc/octodns/octodns.yaml
    $ sudo chmod 640 /etc/octodns/octodns.yaml
    
  5. Save the script shown above as acme-octodns, then install it:

    $ sudo install -m 755 acme-octodns /usr/share/angie/cgi-bin/
    
  6. Add the configuration shown above, then test it and reload Angie:

    $ sudo angie -t
    $ sudo kill -HUP $(cat /run/angie.pid)
    

    The client requests the certificate immediately after the reload; its state and the certificate status appear in the /status/http/acme_clients/ API section, and the issued certificate is saved in the client's storage directory.

ACME in the Stream Module#

The Stream ACME module enables automated certificate issuance and usage for TCP traffic. For it to work correctly, you must first configure its HTTP counterpart: the ACME client must be declared in the http context, and the stream block itself must be placed after the http block in the configuration.

Configuration Example#

By default, HTTP validation mode is used to obtain certificates. As mentioned in the HTTP Validation section, this requires an HTTP server listening on port 80:

# HTTP part
http {

    # ACME client for the stream part
    acme_client example https://acme-v02.api.letsencrypt.org/directory;

    # Server for HTTP validation
    server {

        listen 80;
        return 444;
    }
}

# Stream part
stream {

    server {

        listen 12345 ssl;
        proxy_pass backend_upstream;

        ssl_certificate $acme_cert_example;
        ssl_certificate_key $acme_cert_key_example;

        server_name example.com www.example.com;
        acme example; # reference to the ACME client defined in the HTTP part
    }

    upstream backend_upstream {

        server 127.0.0.1:54321;
    }
}

You can also use DNS validation by configuring challenge=dns in the acme_client directive; in that case, the server will not be needed.

Migrating from certbot#

If you previously used certbot to obtain and renew SSL certificates from Let's Encrypt before migrating from nginx to Angie, follow these steps to transition to using our ACME module.

Suppose you configured certificates as follows:

$ sudo certbot --nginx -d example.com -d www.example.com

The configuration automatically created by this command is typically located in /etc/nginx/sites-available/example.conf and looks something like this:

server {

    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {

    listen 443 ssl;
    server_name example.com www.example.com;

    root /var/www/example;
    index index.html;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}

In the example above, the highlighted lines need to be modified. Depending on your circumstances and preferences, configure HTTP validation or DNS validation using the ACME module.

The resulting Angie configuration might look something like this:

http {

    acme_client example https://acme-v02.api.letsencrypt.org/directory;

    server {

        listen 80;
        server_name example.com www.example.com;
        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl;
        server_name example.com www.example.com;

        root /var/www/example;
        index index.html;

        acme                 example;

        ssl_certificate      $acme_cert_example;
        ssl_certificate_key  $acme_cert_key_example;
    }
}

Remember to reload the configuration after making changes:

$ sudo kill -HUP $(cat /run/angie.pid)

Once you have verified that this configuration works, you can delete the certbot certificates and disable or remove certbot entirely from the server if it is no longer used elsewhere, for example:

$ sudo rm -rf /etc/letsencrypt

$ sudo systemctl stop certbot.timer
$ sudo systemctl disable certbot.timer
$ # -- or --
$ sudo rm /etc/cron.d/certbot

$ sudo apt remove certbot
$ # -- or --
$ sudo dnf remove certbot