Skip to content

fix(traefik): stop Let's Encrypt from overriding domains set to Certificate: None - #4963

Open
onlyilkr wants to merge 17 commits into
Dokploy:canaryfrom
onlyilkr:fix/4949-certificate-none-letsencrypt
Open

fix(traefik): stop Let's Encrypt from overriding domains set to Certificate: None#4963
onlyilkr wants to merge 17 commits into
Dokploy:canaryfrom
onlyilkr:fix/4949-certificate-none-letsencrypt

Conversation

@onlyilkr

@onlyilkr onlyilkr commented Aug 4, 2026

Copy link
Copy Markdown

What is this PR about?

A domain set to Certificate: None keeps being served its old Let's Encrypt certificate, and Traefik keeps trying to renew it. Once that certificate expires, Cloudflare in Full (Strict) mode returns Error 526 even though a valid Cloudflare Origin CA certificate is installed and the domain looks correctly configured.

The cause is not that the websecure entrypoint carries a certResolver default. It is that the router never overrides it. Traefik applies an entrypoint's TLS default only to routers whose TLS is nil (pkg/server/aggregator.go, applyModel):

if cp.TLS == nil {
    cp.TLS = m.TLS
}

and certificateType: "none" wrote exactly that nil. Writing an empty TLS block instead leaves the entrypoint untouched, so there is no traefik.yml migration and no risk of tls: ending up null, which would disable TLS and turn 526 into 525 across every site. utils/traefik/forward-auth.ts already handled the identical case this way, so this part is really a consistency fix.

Three changes:

1. Routers override the entrypoint default. certificateType: "none" now writes tls: {}. The same widening covers certificateType: "custom" with an empty customCertResolver, which previously matched no branch and fell through to nil as well. The Docker-label path gets the equivalent change; it already emitted tls=true for none, so Compose domains were never hit by the original bug.

2. The stale certificate is removed. Fixing the router stops new issuance but leaves the old certificate in the store, and an exact-hostname Let's Encrypt certificate still wins over a wildcard Origin CA one. When a domain moves off Let's Encrypt its entry is now dropped from acme.json, guarded so a hostname another domain still serves with Let's Encrypt is left alone. The store is written atomically (temp file, 0600, rename) rather than truncated in place, so a failed write cannot destroy the certificates on that server.

3. Existing installations are reconciled. manageDomain runs on domain create and update but not on deploy, so an installation that already has the broken router config on disk would not pick the fix up until every affected domain was re-saved by hand. A one-shot pass at startup regenerates only the router configs that are actually missing their TLS key. It is idempotent, it preserves manual edits because manageDomain replaces only its own router and service keys, and failures are isolated per application so an unreachable remote server cannot block startup.

Things worth knowing while reviewing

  • Traefik must be restarted for the purge to take effect. acme.json is read once at startup and served from memory afterwards. Restarting Traefik briefly drops every site on the server, which is too much of a side effect for saving one domain, so the mutation reports it and the success toast tells the user to restart. The startup pass does reload automatically, once per server and only when something was actually removed, because at boot that is far less disruptive. Happy to change either behaviour if you would rather have it the other way.
  • Compose and preview-deployment domains are deliberately out of scope for the purge and the reconciliation. Compose domains are configured through Docker labels and regenerate on deploy; preview domains carry no applicationId.
  • Not related to Custom certificate not applied via SNI fallback (certificateType: none) for Compose domains — Error 526 persists even with a valid matching cert in the Certificates store #4707. That report is about nested certificate files not being picked up. Traefik's file provider loads recursively but its fsnotify watcher is not recursive, so a nested certificate.yml is read at startup and on any reload triggered by a top-level change, just not on its own. Different problem.

Checklist

  • You created a dedicated branch based on the canary branch.
  • You have read the suggestions in the CONTRIBUTING.md file
  • You have tested this PR in your local instance.

Issues related (if applicable)

closes #4949

Testing

Unit tests cover all three certificate types on both the file-config and the Docker-label path, the ACME store helper including the shared-host guard and the failure path, and the reconciliation pass. 105 tests pass across the affected files, and packages/server and apps/dokploy typecheck clean.

Verified end to end on a local instance:

Step Result
Certificate: None on a domain websecure router written with tls: {}
Switching the same domain to Let's Encrypt tls: { certResolver: letsencrypt }, unchanged from before
Switching back to None that host's entry removed from acme.json, the other certificate untouched, Account preserved, file still 0600, no temp file left behind
Same, with a second domain still serving that host on Let's Encrypt nothing removed, the guard held
A router with its tls key deleted by hand, then a restart Reconciled TLS config for 1 domain(s) on <app>, key restored
A second restart no log line, no file change

Screenshots (if applicable)

certificate-to-none success and restart message

Greptile Summary

The PR prevents domains using Certificate: None from inheriting the default Let's Encrypt resolver and adds stale ACME-certificate cleanup plus startup reconciliation.

  • Emits explicit empty TLS configuration for file-provider routers and enables TLS for equivalent Docker-label routes.
  • Removes obsolete ACME entries with serialized, atomic store updates.
  • Reconciles existing application routers and stale certificates during startup.

Confidence Score: 4/5

The PR is not yet safe to merge because startup reconciliation can immediately restart Traefik after a residual ACME-store race has discarded an unrelated active certificate.

Although the author mitigated concurrent writes by re-reading acme.json before replacement, Traefik can still write during the final comparison-to-rename interval. The new reconciliation path then restarts Traefik immediately, contradicting the earlier assumption that the lost certificate remains available in memory and exposing a TLS failure until reissuance.

Files Needing Attention: packages/server/src/utils/traefik/acme.ts; packages/server/src/setup/domain-tls-reconciliation.ts

Reviews (5): Last reviewed commit: "Merge branch 'canary' into fix/4949-cert..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used (3)

onlyilkr added 12 commits August 4, 2026 02:29
The store was rewritten in place on both the local and the remote path.
The remote command truncated acme.json with `>` before writing and used
`;` to chain chmod, so a dropped SSH stream, a failing base64 or a full
disk left an empty, world-readable store behind and destroyed every
Let's Encrypt certificate on that server. `fs.writeFileSync` had the
same truncate-in-place exposure.

Both paths now write a sibling temp file, set mode 0600 on it and rename
it over the target, which is atomic within a directory. The remote
commands are chained with `&&` so a failure stops the sequence, and the
temp file is removed on failure.
The helper lived inside the tRPC router, so the shared-host guard, the
only thing preventing the deletion of a certificate another domain still
needs, had no test coverage. It now lives next to the domain services it
uses and is exported, with unit tests for the purge, the guard, the
letsencrypt short circuit and the failure path.

A purge failure no longer fails the mutation either: the domain row was
already updated and the router already regenerated, so an unreachable
server made the user see an error for an update that succeeded. Failures
are logged and reported as "no reload required".
The startup pass purged ACME certificates without the shared-host guard
the mutation path applies, so a host still served by another domain with
certificateType "letsencrypt" lost its certificate at boot and Traefik
was restarted on top of it. It now reuses
hasOtherLetsencryptDomainForHost and only purges genuinely unused hosts.

The purge also ran after manageDomain. Once the router carries its `tls`
key, routerNeedsTlsFix is false, so a purge that threw was never retried
on a later boot. Purging first leaves the router untouched on failure
and the next boot retries the whole domain.
…hange

The domain update mutation already returned traefikReloadRequired but no
caller read it. Traefik only reads acme.json at startup, so without a
restart the purge has no effect and can be undone when Traefik next
rewrites the store from memory. The dialog now raises a toast when the
field is true.
The four reconciliation behaviours were asserted inside one large `it`
with a sentence-long title, so a failure said nothing about which one
broke. They are now separate cases over a shared beforeEach fixture,
plus coverage for the shared-host guard and the purge-before-regenerate
ordering.
Raising it as a second toast collapsed it onto the success one, since the
Toaster does not set expand, and no other place in the app fires two
toasts for a single action. It is now the success toast's description,
with a longer duration because the message asks the user to go and
restart Traefik rather than just confirming what happened.
@onlyilkr
onlyilkr requested a review from Siumauricio as a code owner August 4, 2026 00:53
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. bug Something isn't working labels Aug 4, 2026
Comment thread packages/server/src/utils/traefik/acme.ts Outdated
Comment thread packages/server/src/setup/domain-tls-reconciliation.ts
A purge is a read-modify-write over a file Traefik owns. On a remote
server the read is an SSH round trip, so two domains switched off Let's
Encrypt at the same time both read the original store and the later
write puts back whatever the earlier one removed, leaving a stale
certificate that keeps shadowing the Origin CA one.

Purges for a server now run one at a time. The chain survives a failed
purge so one broken SSH connection cannot block later ones.
The domain query ran outside the per-application error isolation, so a
database failure propagated to the shared catch in server.ts. Everything
sequenced after it was skipped: the backup cron jobs, the restart
notifications, the enterprise cron jobs and the deployment worker, all
while the HTTP server was already listening.

The pass is best effort and now never rejects.
Comment thread packages/server/src/utils/traefik/acme.ts
Traefik owns acme.json and rewrites it in full whenever it issues or
renews a certificate, with no locking protocol to join. A snapshot taken
before such a write would have dropped the new certificate on rename.

The store is now re-read and compared just before the swap, and the
purge starts over if it changed. That narrows the window to the final
round trip rather than closing it, which is as far as this can go
without stopping Traefik to edit its own state. If a purged certificate
is reinstated by a write we lost the race to, the startup reconciliation
purges it again on the next boot.
The startup pass only purged an application's stale certificates when
its routers still needed regeneration. Once a router carried `tls: {}`
routerNeedsTlsFix went false and the application was skipped entirely,
so a purge that had exhausted its retry budget or lost a race against
Traefik was never revisited.

Split reconcileDomainTls into two phases: phase 1 regenerates router
configs as before, phase 2 purges every domain's host regardless of
whether its router needed regeneration. Phase 2 groups domains by
server rather than by application, since acme.json is one file per
server, cutting repeated reads (and remote SSH round trips) down to
one per server per pass.
@onlyilkr

onlyilkr commented Aug 6, 2026

Copy link
Copy Markdown
Author

@Siumauricio all three review-bot findings are addressed and the threads are resolved. One open question for you, carried over from the last one: I have a prototype that closes the remaining acme.json write race properly by stopping Traefik around the purge instead of racing it. I deliberately kept it out of this PR because it trades a bounded, self-healing race for an availability risk and changes what settings.reloadTraefik does. Happy to open it as a follow-up, or to drop the "Dokploy writes to acme.json" approach altogether if you would rather that file stayed Traefik's alone.

Resolves the toast conflict in handle-domain.tsx. Both sides added a
description to the same toast.success call: canary asks compose users to
redeploy (898af1f), this branch asks application users to restart Traefik
after a certificate purge.

The two never apply at once. purgeStaleCertificate only runs in the update
mutation's `domain.applicationId` branch, so traefikReloadRequired is always
false for compose domains, and the redeploy hint is gated on
domainType === "compose". They are also different remedies: a compose domain
is a docker label that lands on the next deploy, while a purged acme.json
entry only takes effect once Traefik re-reads the store at startup.

Kept both as mutually exclusive branches picking a single description.

const removed = await purgeAcmeCertificates(purgeableHosts, serverId);
if (removed.length > 0) {
await reloadDockerResource("dokploy-traefik", serverId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Reload exposes overwritten certificates

If Traefik issues or renews an unrelated certificate after the purge's final comparison but before its whole-file replacement, the stale snapshot overwrites that certificate and this immediate restart discards the in-memory copy, leaving the affected hostname without its expected certificate until ACME issuance succeeds again.

Knowledge Base Used: Traefik Networking

@onlyilkr onlyilkr Aug 16, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Siumauricio this one is a product call rather than a technical one, and I'd rather have your read before I build it out. Greptile's P1 above is valid, I verified it, but the fix depends on a decision I can't make on your behalf.

What the finding is

initDomainTlsReconciliation purges stale entries from acme.json at startup and then reloads Traefik immediately. Traefik owns that file and rewrites it in full on issue or renewal, so there is a window between our final "unchanged?" check and the rename where a certificate Traefik just wrote gets overwritten by our snapshot.

Until now the consolation was that the lost certificate stays in Traefik's memory and keeps being served, and the startup pass re-purges on the next boot. The immediate reload removes that consolation: it discards the in-memory copy right away, so an unrelated hostname is left without its certificate until ACME issues a new one.

Two things worth stating plainly:

This is entirely new behaviour from this PR. Nothing on canary reads or writes acme.json. The only existing references set its permissions (traefik-setup.ts, server-setup.ts). This PR is the first code in Dokploy to write to Traefik's certificate store, so the whole race class comes with the fix, not before it.

It would also be the first automatic service restart in Dokploy. reloadDockerResource has exactly two other callers, settings.reloadServer and settings.reloadTraefik, both adminProcedure mutations behind an explicit click and both audited. The startup chain in server.ts sets up crons, schedules and notifications. Nothing there restarts infrastructure. That seems like a line worth deciding deliberately rather than inheriting from a bug fix.

The purge itself is load-bearing

Just so the options are judged on the right premise: dropping the acme.json work entirely is not on the table.

The router fix (tls: {}) stops new issuance, but Traefik still serves an already-issued certificate from its store, and it wins on specificity. An exact-match app.example.com from Let's Encrypt beats a wildcard *.example.com Origin CA certificate.

On my instance, emptying the ACME store is what moved all 13 hostnames onto Origin CA. Nothing else changed. Existing installs need the entry gone.

The three options

Same starting point for all three: an operator upgrades Dokploy on a server where domains were switched to Certificate: None months ago, so acme.json still holds stale Let's Encrypt certificates for them.

A: purge and reload immediately (what the PR does now).

Their sites go down for a few seconds during an upgrade they thought was routine, with no warning. When it comes back the problem is fixed for good.

Rare bad case: if Traefik was writing a certificate in that window, that hostname breaks until ACME reissues.

B: purge, don't reload.

Nothing goes down and the operator notices nothing, including that the problem is still there, because Traefik keeps serving the old certificates from memory. It resolves on the next Traefik restart, whenever that happens.

Risk: if Traefik rewrites its store before that restart, our removal is undone and the next boot has to redo it. That loop can outlast the certificate's expiry, which is the failure the issue is about.

C: stop Traefik, purge, start it again.

Identical cost to A from the operator's side, same few seconds. But nothing can race us because Traefik is not running while we write, so A's bad case disappears.

New risk: if the start fails, Traefik stays down, and on a single-server install that takes the Dokploy UI with it.

A and C cost the operator the same thing. C is strictly more correct for that price. B is the only one that costs nothing, and it pays for that by leaving the timing of the fix to chance.

What I'd suggest

Split the PR, and keep this one to the parts that are already settled:

  • the router fix on both the file-config and Docker-label paths
  • the acme.json purge on the mutation path, which returns traefikReloadRequired and surfaces "Restart Traefik to apply the certificate change for this domain" in the success toast, leaving the restart to the operator

That is all user-triggered, introduces no automatic restart, and the residual race stays behind a restart the operator chooses. That is exactly the design I described in the issue. Greptile's P1 does not apply to it.

Then the startup reconciliation becomes its own PR, where A/B/C can be argued on its own merits, and where B would also need a UI surface to be honest. An operator with no reason to restart Traefik will never learn that they should.

The closest existing pattern is UpdateServerButton in the sidebar footer, which polls a condition and shows a persistent affordance when it is true. The same shape would work here with settings.reloadTraefik as the action.

The cost of splitting is that existing installs don't get fixed automatically on upgrade. They'd need to re-save each affected domain, which now does the right thing end to end and tells them to restart Traefik. The manual path is documented in Dokploy/website#172 .

If you'd rather keep it all in one PR, tell me which of A, B or C you want and I'll build it. My own preference is C for the reconciliation path, because it closes the race for the same downtime A already spends. But it is your call whether Dokploy should ever restart a service without being asked.

One thing I could not verify

Whether Traefik flushes its in-memory ACME store to acme.json on SIGTERM. If it does, a stop or a forced restart could write the purged certificate back and defeat the cleanup in both A and C.

I have not been able to test this properly, and it would change the analysis, so I'd rather flag it than assume either way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cloudflare Origin CA: "Certificate: None" does not stop Let's Encrypt for the domain (websecure entrypoint default)

1 participant