Skip to content

Allow SSL certificate on the default site (#422)#5544

Open
benjaminchodroff wants to merge 5 commits into
NginxProxyManager:developfrom
benjaminchodroff:feature/default-site-ssl
Open

Allow SSL certificate on the default site (#422)#5544
benjaminchodroff wants to merge 5 commits into
NginxProxyManager:developfrom
benjaminchodroff:feature/default-site-ssl

Conversation

@benjaminchodroff
Copy link
Copy Markdown

Summary

Implements #422: adds the ability to attach an SSL certificate (and optionally force HTTPS) to the "Default Site" used when nginx is hit with an unknown host. The Default Site previously only listened on port 80, which made it unusable on HSTS domains, behind Cloudflare-with-strict-SSL, or in browsers that auto-upgrade to HTTPS.

  • The 404, 444, redirect, and html modes now accept an optional SSL certificate (custom or Let's Encrypt) and a Force-SSL toggle.
  • The congratulations page is unchanged — it ignores SSL fields, matching today's behavior.
  • When a certificate is selected, the generated default_host/site.conf adds a listen 443 ssl default server block and an SNI fallback so unknown hosts hit your default page over HTTPS.
  • When Force-SSL is enabled, HTTP traffic to unknown hosts is 301-redirected to HTTPS.

Changes

  • backend/templates/default.conf — render an HTTPS listener, _certificates.conf, and an optional force-SSL include based on the certificate/ssl_forced fields.
  • backend/internal/setting.js — hydrate the default-site row with its certificate before rendering, and reset the SSL fields if the referenced certificate has been deleted.
  • backend/schema/paths/settings/settingID/put.json — accept certificate_id (integer, ≥0) and ssl_forced (boolean) in meta.
  • frontend/src/pages/Settings/DefaultSite.tsx — certificate picker and Force-SSL toggle in the Default Site settings page.
  • test/cypress/e2e/api/Settings.cy.js — coverage for the new meta fields.

Test plan

  • npm run lint in backend/ — clean
  • npm run lint in frontend/ — clean
  • npm run validate-schema in backend/ — schema valid
  • npm run build in frontend/ (tsc + vite) — clean
  • Liquid template rendering verified for: no SSL / Let's Encrypt + force / custom SSL no force / congratulations
  • Brought up scripts/start-dev; uploaded a self-signed custom cert; configured the default site through the API with meta.certificate_id and meta.ssl_forced; verified nginx -t passes and the generated /data/nginx/default_host/site.conf is correct.
  • HTTP request to an unknown host → 301 to HTTPS when ssl_forced=true; serves the configured content (404/redirect/html) when ssl_forced=false.
  • HTTPS request to an unknown host → served the configured content with the chosen certificate.
  • Disabling SSL (certificate_id=0) regenerates a port-80-only config.
  • Setting a certificate_id that no longer exists falls back to no-SSL (defensive reset in setting.js).

Adds the ability to select an SSL certificate and force HTTPS for the
"Default Site" used when nginx is hit with an unknown host. The default
site now listens on 443 with the chosen certificate (custom or
Let's Encrypt) when configured, and optionally redirects HTTP to HTTPS
when ssl_forced is enabled. The "congratulations" option keeps its
existing behavior and ignores SSL fields.

- backend/templates/default.conf: add SSL listener, certificates
  include, and optional force-SSL block
- backend/internal/setting.js: hydrate the default-site row with its
  certificate before rendering nginx config
- backend/schema/paths/settings/settingID/put.json: accept
  certificate_id and ssl_forced in meta
- frontend/src/pages/Settings/DefaultSite.tsx: certificate picker and
  force-SSL toggle in the Default Site settings page
- test/cypress/e2e/api/Settings.cy.js: cover new meta fields
Two follow-up fixes on top of the SSL-on-default-site feature (NginxProxyManager#422):

1. Strip cert PEM contents from the nginx render context. The cert row
   pulled from the DB carries the certificate and certificate_key PEMs
   in `meta`, and `internalNginx.generateConfig` debug-logs the full
   render context via JSON.stringify — so under DEBUG the private key
   ended up in stdout / /data/logs. The nginx template only reads
   `certificate.provider` and renders disk paths from `certificate_id`,
   so meta is unnecessary.

2. Regenerate the default-site nginx config when the cert it references
   is deleted. Other host types are covered by the existing
   disableInUseHosts / enableInUseHosts flow, which is keyed off cert
   domain_names and doesn't see the default-site setting. Without this,
   deleting a cert in use by the default-site leaves a stale
   ssl_certificate path on disk and the next nginx reload fails.
@benjaminchodroff
Copy link
Copy Markdown
Author

Heads-up on a couple of follow-ups I just pushed (3aef29c9) and a deliberate design choice for review:

Hardening fixes in the new commit

  1. buildDefaultSiteRenderContext now strips meta from the fetched certificate row before passing it to the nginx template. The PEM cert and private key were riding along in the render context and getting written to the debug log via internalNginx.generateConfig's JSON.stringify. The template only reads certificate.provider and derives PEM file paths from certificate_id, so meta was unused — just leaky.
  2. internalCertificate.delete now regenerates the default-site nginx config if the deleted cert was in use by it. The existing disableInUseHosts / enableInUseHosts flow is keyed off certificate.domain_names, which doesn't intersect with the default-site setting, so without this hook a cert delete leaves a dangling ssl_certificate path on disk and the next nginx reload fails for every host.

Deliberate scope choice: only certificate_id + ssl_forced

I left out HSTS / HSTS-subdomains / HTTP/2 / trust-forwarded-proto, even though SSLOptionsFields already exposes them for proxy/redirect/dead hosts. HSTS on the default site is actively harmful: it teaches the browser that every host under the parent domain must be HTTPS, including subdomains and the apex that may legitimately serve HTTP (or no SSL at all). Since the default-site catches unknown hosts, the user has no way to scope HSTS to a domain set they actually control via NPM. A wildcard certificate makes this worse, not better. HTTP/2 was omitted for symmetry — happy to add either if you'd prefer parity with other host types.

@nginxproxymanagerci
Copy link
Copy Markdown

Docker Image for build 3 is available on DockerHub:

nginxproxymanager/nginx-proxy-manager-dev:pr-5544

Note

Ensure you backup your NPM instance before testing this image! Especially if there are database changes.
This is a different docker image namespace than the official image.

Warning

Changes and additions to DNS Providers require verification by at least 2 members of the community!

@jc21
Copy link
Copy Markdown
Member

jc21 commented May 17, 2026

Code Review

Correctness Issues

1. regenerateDefaultSiteConfig reloads without testing first

In backend/internal/setting.js (the exported regenerateDefaultSiteConfig), the sequence is:

await internalNginx.deleteConfig("default");
await internalNginx.generateConfig("default", renderContext);
await internalNginx.reload();  // ← no test() before reload

The normal update flow does test()reload(). If the generated config is invalid, this path would load it without validation. It should follow the same pattern.

2. Stale certificate_id in DB after certificate deletion

In backend/internal/certificate.js, when the referenced certificate is deleted, the code correctly regenerates the nginx config but does not clear meta.certificate_id from the default-site settings row. Other host types go through deleteFromAllHosts which updates their DB records. The default-site row will continue to carry a stale reference, meaning:

  • The Settings UI reloads with a certificate_id pointing to a non-existent cert (poor UX)
  • Every subsequent buildDefaultSiteRenderContext call takes the "cert deleted, fall back to no-SSL" branch unnecessarily

The fix should also update the DB row:

await settingModel.query().patchAndFetchById("default-site", {
    meta: { ...defaultSite.meta, certificate_id: 0, ssl_forced: false },
});

3. _certificates.conf included unconditionally

In backend/templates/default.conf, {% include "_certificates.conf" %} is placed outside the {% if certificate and certificate_id > 0 %} guard. If that partial uses the certificate variable without its own null-guard, it may emit blank ssl_certificate directives or cause a template error when no cert is configured. Worth verifying, or moving the include inside the conditional block.


Code Quality

4. ForceSSLField bypasses Formik's field binding pattern

The component wraps a <Field> but ignores field.onChange and field.checked in favour of useFormikContext() values and setFieldValue. The <Field> wrapper is redundant — either rely solely on useFormikContext, or use <Field as="input" type="checkbox">.

5. Redundant condition in template

{% if ssl_forced == 1 or ssl_forced == true %}

ssl_forced is set via !!row?.meta?.ssl_forced in buildDefaultSiteRenderContext, so it will always be a boolean when rendered. The == 1 branch is unreachable from this code path.


Test Coverage

The new Cypress test only covers certificate_id: 0. There is no test for certificate_id > 0, ssl_forced: true, or the cert-deletion fallback path in certificate.js. The deletion fallback in particular could be covered with a self-signed cert.


Minor Notes

  • Number.parseInt(defaultSite?.meta?.certificate_id, 10) in certificate.jsparseInt is designed for strings; if the field is already stored as a number, a direct comparison after Number(...) or just === row.id would be clearer.
  • cert.meta = {} in buildDefaultSiteRenderContext mutates the ORM result in place to strip PEM material. Safer to destructure: const { meta: _meta, ...safeCert } = cert; certificate = safeCert; — the comment already explains the intent well.

The feature is well-scoped and the main flows are covered. The two blockers are the missing nginx -t before reload and the stale certificate_id in the DB after cert deletion. The unconditional _certificates.conf include needs a quick verification.

1. regenerateDefaultSiteConfig: run nginx -t before reload, fall back
   to an empty config on failure.
2. On certificate delete, also clear stale certificate_id / ssl_forced
   from default-site meta in the DB.
3. Move _certificates.conf include inside the certificate guard in
   default.conf.
4. ForceSSLField: drop redundant <Field> wrapper, use useFormikContext
   directly.
5. Simplify ssl_forced check in default.conf to {% if ssl_forced %}.
6. Cypress: cover SSL-enabled PUT and the cert-deletion fallback.
7. Number(...) instead of Number.parseInt(..., 10).
8. Destructure cert.meta out instead of mutating the ORM row.
@benjaminchodroff
Copy link
Copy Markdown
Author

Thanks again for the review @jc21 and all your help. This change should address all the feedback you requested and hope this small improvement helps others wishing to get a certificate assigned to the default site.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants