Acme nginx forgejo ssl certificates not working

Hi all

I’m setting up a homelab and deploying Forgejo with nginx as a reverse proxy on NixOS.
I’ve configured nginx to use ACME (Let’s Encrypt staging for development).
Forgejo runs and is reachable over HTTP, but HTTPS uses a self-signed cert so ACME fails to obtain a valid certificate.

nginx / ACME config:

{ config, ... }:
let
  cfg = config.services.nginx;
in
{
  services.nginx = {
    enable = true;
    recommendedGzipSettings = true;
    recommendedOptimisation = true;
    recommendedProxySettings = true;
    recommendedTlsSettings = true;
  };
  users.users.nginx.extraGroups = [ "acme" ];

  networking.firewall.allowedTCPPorts = [
    cfg.defaultHTTPListenPort
    cfg.defaultSSLListenPort
  ];

  sops.secrets."host/${config.hostSpec.hostName}/nginx/cf-dns-api-token".owner = cfg.user;
  security.acme = {
    acceptTerms = true;
    defaults = {
      server = "https://acme-staging-v02.api.letsencrypt.org/directory";
      email = "cloudflare@domain.com";
      dnsProvider = "cloudflare";
      dnsResolver = "1.1.1.1:53";
      environmentFile = config.sops.secrets."host/${config.hostSpec.hostName}/nginx/cf-dns-api-token".path;
    };
  };
}

nginx virtual host + Forgejo service:

{
  lib,
  config,
  pkgs,
  ...
}:
let
  domain = "git.home.domain.com";
  cfg = config.services.forgejo;
  srv = cfg.settings.server;
in
{
  imports = [
    ./runner.nix
  ];

  services.nginx = {
    virtualHosts.${srv.DOMAIN} = {
      forceSSL = true;
      enableACME = true;
      acmeRoot = null;
      extraConfig = ''
        client_max_body_size 512M;
      '';
      locations."/".proxyPass = "http://localhost:${toString srv.HTTP_PORT}";
    };
  };

  services.forgejo = {
    enable = true;
    package = pkgs.forgejo;
    database.type = "sqlite3";
    lfs.enable = true;
    settings = {
      server = {
        DOMAIN = domain;
        ROOT_URL = "https://${srv.DOMAIN}/";
        HTTP_PORT = 3000;
        SSH_PORT = lib.head config.services.openssh.ports;
      };
      service.DISABLE_REGISTRATION = true;
    };
  };
}

ACME/systemd logs (excerpt):

Jan 22 19:56:06 zenzi acme-order-renew-git.home.domain.com-start[2633]: [git.home.domain.com] propagation: time limit exceeded: last error: authoritative nameservers: NS khalid.ns.cloudflare.com.:53 did not return the expected TX>
Jan 22 19:56:06 zenzi acme-order-renew-git.home.domain.com-start[2600]: + echo Failed to fetch certificates. This may mean your DNS records are set up incorrectly. Self-signed certs are in place and dependant services will still s>
Jan 22 19:56:06 zenzi acme-order-renew-git.home.domain.com-start[2600]: Failed to fetch certificates. This may mean your DNS records are set up incorrectly. Self-signed certs are in place and dependant services will still start.
Jan 22 19:56:06 zenzi acme-order-renew-git.home.domain.com-start[2600]: + exit 10
Jan 22 19:56:06 zenzi systemd[1]: acme-order-renew-git.home.domain.com.service: Main process exited, code=exited, status=10/n/a
Jan 22 19:56:06 zenzi systemd[1]: acme-order-renew-git.home.domain.com.service: Failed with result 'exit-code'.
Jan 22 19:56:06 zenzi systemd[1]: Failed to start Order (and renew) ACME certificate for git.home.domain.com.

Sops environmentFile secret

CF_API_EMAIL=cloudflare@domain.com
CF_DNS_API_TOKEN=...

What I’ve checked:

  • TXT _acme-challenge records are created and visible in Cloudflare UI.
  • Cloudflare has a DNS-only A record for *.home.domain.com pointing to the machine IP.
  • Using a Cloudflare API token with these permissions: Zone.Zone READ, Zone.DNS READ, Zone.Zone Edit, Zone.DNS Edit.
  • Browser shows a self-signed cert (Common Name: minica root ca hash).

What am I missing?
Why does NixOS/nginx ACME DNS challenge not complete even though the TXT record is present in Cloudflare UI, and how can I fix it so Forgejo is served with a valid Let’s Encrypt certificate?

Thank you for your help stranger :slight_smile:

Yeah Ive checked it, its also linked in systemd cat
EnvironmentFile=/run/secrets/host/zenzi/nginx/cf-dns-api-token

Yeah I fully read your message afterwards. I was just surprised that you did not use a template.

I also saw that you can see that the entry is created so auth shouldn’t be a problem.

What do you mean by template?
Are there any homelab infrastructure templates?

I meant sops templates.

This is what I use:

sops.secrets."services/hetzner/api-key" = lib.mkDefault {};
    sops.secrets."services/hetzner/api-token" = lib.mkDefault {};
    sops.templates."hetzner" = lib.mkDefault {
      # content = ''
      #   HETZNER_API_KEY=${config.sops.placeholder."services/hetzner/api-key"}
      # '';
      content = ''
        HETZNER_API_TOKEN=${config.sops.placeholder."services/hetzner/api-token"}
      '';
      owner = config.users.users.traefik.name;
      group = config.users.users.traefik.group;
    };

    security.acme = {
      acceptTerms = true;
      defaults.email = "security@exaple.com";
      certs."${config.mailserver.fqdn}" = {
        dnsProvider = "hetzner";
        environmentFile = config.sops.templates."hetzner".path;
      };
    };

Ah I get it now I find the file workflow more elegant but will try your approach now :slight_smile:

But on the end I don’t think it is the reason because you wrote that you can see that the record is created.

yeah just for my sanity

As expected did not work this is so strange

Have you actually checked the logs of your acne client? I think nginx keeps those logs around.

Reading logs helped a lot when debugging why my traefik setup didn’t work.

1 Like

Isn’t that what I wrote in my first message, or is there another location? If so I don’t know where. Please help :slight_smile:

kilisei@zenzi:~/ > journalctl -If -u acme-order-renew-git.home.domain.com.service
Jan 27 14:03:53 zenzi acme-order-renew-git.home.domain.com-start[1802]: + lego --accept-tos --path . --email cloudflare@domain.com --dns cloudflare --dns.resolvers 1.1.1.1:53 --server https://acme-staging-v02.api.letsencrypt.org/directory --key-type ec256 -d git.home.domain.com run
Jan 27 14:03:54 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:03:54 [INFO] [git.home.domain.com] acme: Obtaining bundled SAN certificate
Jan 27 14:03:54 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:03:54 [INFO] [git.home.domain.com] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz/...
Jan 27 14:03:54 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:03:54 [INFO] [git.home.domain.com] acme: Could not find solver for: tls-alpn-01
Jan 27 14:03:54 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:03:54 [INFO] [git.home.domain.com] acme: Could not find solver for: http-01
Jan 27 14:03:54 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:03:54 [INFO] [git.home.domain.com] acme: use dns-01 solver
Jan 27 14:03:54 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:03:54 [INFO] [git.home.domain.com] acme: Preparing to solve DNS-01
Jan 27 14:03:56 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:03:56 [INFO] cloudflare: new record for git.home.domain.com, ID 9a2e91b21fb8557f54a723ba7f60b7da
Jan 27 14:03:56 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:03:56 [INFO] [git.home.domain.com] acme: Trying to solve DNS-01
Jan 27 14:03:56 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:03:56 [INFO] [git.home.domain.com] acme: Checking DNS record propagation. [nameservers=1.1.1.1:53]
Jan 27 14:03:58 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:03:58 [INFO] Wait for propagation [timeout: 2m0s, interval: 2s]
Jan 27 14:03:58 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:03:58 [INFO] [git.home.domain.com] acme: Waiting for DNS record propagation.
Jan 27 14:04:00 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:04:00 [INFO] [git.home.domain.com] acme: Waiting for DNS record propagation.
...
Jan 27 14:05:59 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:05:59 [INFO] [git.home.domain.com] acme: Cleaning DNS-01 challenge
Jan 27 14:06:00 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:06:00 [INFO] Deactivating auth: https://acme-staging-v02.api.letsencrypt.org/acme/authz/...
Jan 27 14:06:00 zenzi acme-order-renew-git.home.domain.com-start[1810]: 2026/01/27 14:06:00 Could not obtain certificates:
Jan 27 14:06:00 zenzi acme-order-renew-git.home.domain.com-start[1810]:         error: one or more domains had a problem:
Jan 27 14:06:00 zenzi acme-order-renew-git.home.domain.com-start[1810]: [git.home.domain.com] propagation: time limit exceeded: last error: authoritative nameservers: NS khalid.ns.cloudflare.com.:53 did not return the expected TXT record [fqdn: _acme-challenge.git.home.kilisei.dev., value: 1mD2if6n33Prd6Le24hZwGakQEjc3w_6yDqK3_OgheE]:
Jan 27 14:06:00 zenzi acme-order-renew-git.home.domain.com-start[1802]: + echo Failed to fetch certificates. This may mean your DNS records are set up incorrectly. Self-signed certs are in place and dependant services will still start.
Jan 27 14:06:00 zenzi acme-order-renew-git.home.domain.com-start[1802]: Failed to fetch certificates. This may mean your DNS records are set up incorrectly. Self-signed certs are in place and dependant services will still start.
Jan 27 14:06:00 zenzi acme-order-renew-git.home.domain.com-start[1802]: + exit 10
Jan 27 14:06:00 zenzi systemd[1]: acme-order-renew-git.home.domain.com.service: Main process exited, code=exited, status=10/n/a
Jan 27 14:06:00 zenzi systemd[1]: acme-order-renew-git.home.domain.com.service: Failed with result 'exit-code'.
Jan 27 14:06:00 zenzi systemd[1]: Failed to start Order (and renew) ACME certificate for git.home.domain.com.
Jan 27 14:06:00 zenzi systemd[1]: acme-order-renew-git.home.domain.com.service: Consumed 232ms CPU time, 14.9M memory peak, 56K incoming IP traffic, 27.6K outgoing IP traffic.

I missed the logs before.

So when you query 1.1.1.1 or another non-CF DNS for the TXT record, do you get it back?

Is any other IP resolved correctly?

Are you sure the CF DNS and whatever DNS is authorative for your domain are glued together correctly?

We sadly can’t do such basic checks, as you are obfuscating your actual domain name.

Yes i bought the domain from cloudflare my main DNS on my router is 1.1.1.1

My domain is kilisei.dev i changed it so other people can maybe use this thread better but maybe that was dumb

i had a Kubernetes cluster a while back where it worked with cert-manager but it fried itself and i don’t have the config or machine anymore

➜  dashing git:(chore/cleanup) ✗ dig @1.1.1.1 _acme-challenge.git.home.kilisei.dev TXT

; <<>> DiG 9.20.15 <<>> @1.1.1.1 _acme-challenge.git.home.kilisei.dev TXT
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 54640
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
; COOKIE: 2b58fe21ecab4375 (echoed)
;; QUESTION SECTION:
;_acme-challenge.git.home.kilisei.dev. IN TXT

;; ANSWER SECTION:
_acme-challenge.git.home.kilisei.dev. 120 IN TXT "cC-5ddJVK9_jbpIHQLVnkI3JvQCHl8zKDGhwfPdjf78"

;; Query time: 39 msec
;; SERVER: 1.1.1.1#53(1.1.1.1) (UDP)
;; WHEN: Tue Jan 27 14:33:59 CET 2026
;; MSG SIZE  rcvd: 169

➜  dashing git:(chore/cleanup) ✗ dig @8.8.8.8 _acme-challenge.git.home.kilisei.dev TXT

; <<>> DiG 9.20.15 <<>> @8.8.8.8 _acme-challenge.git.home.kilisei.dev TXT
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 24113
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
; COOKIE: fe031dfdae51920c (echoed)
;; QUESTION SECTION:
;_acme-challenge.git.home.kilisei.dev. IN TXT

;; ANSWER SECTION:
_acme-challenge.git.home.kilisei.dev. 115 IN TXT "cC-5ddJVK9_jbpIHQLVnkI3JvQCHl8zKDGhwfPdjf78"

;; Query time: 1 msec
;; SERVER: 8.8.8.8#53(8.8.8.8) (UDP)
;; WHEN: Tue Jan 27 14:34:04 CET 2026
;; MSG SIZE  rcvd: 169

I got a cert now its staging ill try prod now i changed literally nothing at all

Common Name (CN)

(STAGING) Puzzling Parsnip E7

Organization (O)

(STAGING) Let’s Encrypt

i got the same error as in my first message when using prod

this is so so strange

I’ve switched to Traefik and it worked flawlessly on the first try. I would highly recommend Traefik over Nginx for anyone with the same issue.

That is indeed what I do.

1 Like