diff --git a/backend/lib/public-ports.js b/backend/lib/public-ports.js
new file mode 100644
index 0000000000..347e3eb7d5
--- /dev/null
+++ b/backend/lib/public-ports.js
@@ -0,0 +1,28 @@
+function parsePort(env, name, fallback) {
+ const value = env[name];
+ if (value === undefined) return fallback;
+ const port = Number(value);
+ if (!/^\d+$/.test(value) || !Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new Error(`${name} must be an integer between 1 and 65535`);
+ }
+ return port;
+}
+
+export function getPublicPorts(env = process.env) {
+ return {
+ http: parsePort(env, "PUBLIC_HTTP_PORT", 80),
+ https: parsePort(env, "PUBLIC_HTTPS_PORT", 443),
+ };
+}
+
+export function renderPublicPortsConfig(ports) {
+ const suffix = ports.https === 443 ? "" : `:${ports.https}`;
+ return [
+ "# Generated from PUBLIC_HTTP_PORT / PUBLIC_HTTPS_PORT at container startup.",
+ "map $host $npm_public_https_port_suffix {",
+ `\tdefault "${suffix}";`,
+ "}",
+ ...(ports.https === 443 ? [] : ["error_page 497 =307 https://$host$npm_public_https_port_suffix$request_uri;"]),
+ "",
+ ].join("\n");
+}
diff --git a/backend/lib/public-ports.test.js b/backend/lib/public-ports.test.js
new file mode 100644
index 0000000000..7fd5b65578
--- /dev/null
+++ b/backend/lib/public-ports.test.js
@@ -0,0 +1,36 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { getPublicPorts, renderPublicPortsConfig } from "./public-ports.js";
+
+test("uses standard ports when deployment variables are absent", () => {
+ assert.deepEqual(getPublicPorts({}), { http: 80, https: 443 });
+ assert.match(renderPublicPortsConfig(getPublicPorts({})), /default "";/);
+ assert.doesNotMatch(renderPublicPortsConfig(getPublicPorts({})), /error_page/);
+});
+
+test("shares custom ports with the API and produces the HTTPS redirect suffix", () => {
+ const ports = getPublicPorts({ PUBLIC_HTTP_PORT: "232", PUBLIC_HTTPS_PORT: "233" });
+ assert.deepEqual(ports, { http: 232, https: 233 });
+ assert.match(renderPublicPortsConfig(ports), /default ":233";/);
+ assert.match(
+ renderPublicPortsConfig(ports),
+ /error_page 497 =307 https:\/\/\$host\$npm_public_https_port_suffix\$request_uri;/,
+ );
+});
+
+test("accepts the full valid port range independently for each protocol", () => {
+ assert.deepEqual(getPublicPorts({ PUBLIC_HTTP_PORT: "1", PUBLIC_HTTPS_PORT: "65535" }), {
+ http: 1,
+ https: 65535,
+ });
+});
+
+test("rejects invalid input before emitting Nginx configuration", () => {
+ for (const key of ["PUBLIC_HTTP_PORT", "PUBLIC_HTTPS_PORT"]) {
+ for (const value of ["", "0", "65536", "-1", "233.5", "2e2", " 233", "233; return 200;"]) {
+ assert.throws(() => getPublicPorts({ [key]: value }), {
+ message: `${key} must be an integer between 1 and 65535`,
+ });
+ }
+ }
+});
diff --git a/backend/routes/main.js b/backend/routes/main.js
index 2d719bb486..c3f8622c07 100644
--- a/backend/routes/main.js
+++ b/backend/routes/main.js
@@ -2,6 +2,7 @@ import express from "express";
import { isCI } from "../lib/config.js";
import errs from "../lib/error.js";
import logRequest from "../lib/express/log-request.js";
+import { getPublicPorts } from "../lib/public-ports.js";
import pjson from "../package.json" with { type: "json" };
import { isSetup } from "../setup.js";
import auditLogRoutes from "./audit-log.js";
@@ -39,6 +40,7 @@ router.get("/", async (_, res /*, next*/) => {
res.status(200).send({
status: "OK",
setup,
+ public_ports: getPublicPorts(),
version: {
major: Number.parseInt(version.shift(), 10),
minor: Number.parseInt(version.shift(), 10),
diff --git a/backend/scripts/configure-public-ports.mjs b/backend/scripts/configure-public-ports.mjs
new file mode 100644
index 0000000000..e9dbf51c41
--- /dev/null
+++ b/backend/scripts/configure-public-ports.mjs
@@ -0,0 +1,5 @@
+import fs from "node:fs";
+import { getPublicPorts, renderPublicPortsConfig } from "../lib/public-ports.js";
+
+const filename = process.argv[2] || "/etc/nginx/conf.d/public-ports.conf";
+fs.writeFileSync(filename, renderPublicPortsConfig(getPublicPorts()), { mode: 0o644 });
diff --git a/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf b/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf
index 8e58c64a38..ce6c147f4c 100644
--- a/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf
+++ b/docker/rootfs/etc/nginx/conf.d/include/force-ssl.conf
@@ -28,5 +28,5 @@ if ($test_ssl_handled = "TS") {
}
if ($test = H) {
- return 301 https://$host$request_uri;
+ return 301 https://$host$npm_public_https_port_suffix$request_uri;
}
diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh
index 172290eae6..2d17c08c97 100755
--- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh
+++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh
@@ -20,4 +20,5 @@ fi
. /etc/s6-overlay/s6-rc.d/prepare/45-admin-port.sh
. /etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh
. /etc/s6-overlay/s6-rc.d/prepare/60-secrets.sh
+. /etc/s6-overlay/s6-rc.d/prepare/70-public-ports.sh
. /etc/s6-overlay/s6-rc.d/prepare/90-banner.sh
diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/70-public-ports.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/70-public-ports.sh
new file mode 100755
index 0000000000..3c60d8018c
--- /dev/null
+++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/70-public-ports.sh
@@ -0,0 +1,7 @@
+#!/command/with-contenv bash
+# shellcheck shell=bash
+
+set -e
+
+log_info "Configuring public ports ..."
+/command/with-contenv node /app/scripts/configure-public-ports.mjs
diff --git a/docs/src/advanced-config/index.md b/docs/src/advanced-config/index.md
index a93a1a4d09..69d354f536 100644
--- a/docs/src/advanced-config/index.md
+++ b/docs/src/advanced-config/index.md
@@ -168,6 +168,46 @@ By default, NPM fetches IP ranges from CloudFront and Cloudflare during applicat
IP_RANGES_FETCH_ENABLED: 'false'
```
+## Public HTTP and HTTPS ports
+
+Some networks block incoming connections to ports 80 and 443. NPM can be published
+on other ports using Docker port mappings or router forwarding, while its internal
+listeners remain on 80 and 443. Configure the public ports so Force SSL redirects
+and host links in the manager use the addresses clients can actually reach:
+
+```yml
+services:
+ app:
+ image: 'jc21/nginx-proxy-manager:{{VERSION}}'
+ ports:
+ - '232:80'
+ - '233:443'
+ - '81:81'
+ environment:
+ PUBLIC_HTTP_PORT: '232'
+ PUBLIC_HTTPS_PORT: '233'
+ # ...
+```
+
+`PUBLIC_HTTP_PORT` defaults to 80 and `PUBLIC_HTTPS_PORT` defaults to 443.
+Both must be decimal integers between 1 and 65535. They describe the client-facing
+ports; configure Docker or router mappings to match and recreate the container
+after changing them. These settings do not change internal listeners or ACME
+validation requirements.
+
+Force SSL redirects preserve their 301 status, path, and query, including the
+nonstandard HTTPS port. HTTP sent to an SSL listener redirects with 307 when the
+public HTTPS port is nonstandard; default 443 behavior is unchanged.
+
+Proxy, Redirection, and 404 Host links use HTTPS when a certificate is configured,
+and HTTP otherwise. Both the visible domain and link include nonstandard ports;
+standard 80/443 are omitted. Certificate-list links retain their existing behavior.
+The existing health API exposes the configured ports as `public_ports`.
+
+Explicit redirection destinations, upstream ports, and application-generated URLs
+remain separately configured. Remove any custom `error_page` rule that previously
+hardcoded a public port if it would override the new redirects.
+
## Custom Nginx Configurations
If you are a more advanced user, you might be itching for extra Nginx customizability.
diff --git a/frontend/src/api/backend/responseTypes.ts b/frontend/src/api/backend/responseTypes.ts
index 2f88ede547..9233d794ac 100644
--- a/frontend/src/api/backend/responseTypes.ts
+++ b/frontend/src/api/backend/responseTypes.ts
@@ -4,6 +4,7 @@ export interface HealthResponse {
status: string;
version: AppVersion;
setup: boolean;
+ publicPorts?: { http: number; https: number };
}
export interface TokenResponse {
diff --git a/frontend/src/components/Table/Formatter/DomainsFormatter.test.tsx b/frontend/src/components/Table/Formatter/DomainsFormatter.test.tsx
new file mode 100644
index 0000000000..6c267698cb
--- /dev/null
+++ b/frontend/src/components/Table/Formatter/DomainsFormatter.test.tsx
@@ -0,0 +1,36 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { DomainsFormatter } from "./DomainsFormatter";
+
+const { health } = vi.hoisted(() => ({ health: { publicPorts: { http: 232, https: 233 } } }));
+vi.mock("src/context", () => ({ useLocaleState: () => ({ locale: "en" }) }));
+vi.mock("src/hooks/useHealth", () => ({ useHealth: () => ({ data: health }) }));
+vi.mock("src/locale", () => ({ formatDateTime: () => "", T: () => null }));
+
+afterEach(cleanup);
+
+describe("public host links", () => {
+ it.each([
+ ["https", 232, 233, "example.com:233", "https://example.com:233"],
+ ["http", 232, 233, "example.com:232", "http://example.com:232"],
+ ["https", 80, 443, "example.com", "https://example.com"],
+ ["http", 80, 443, "example.com", "http://example.com"],
+ ] as const)("formats %s with HTTP %s / HTTPS %s", (scheme, http, https, text, href) => {
+ health.publicPorts = { http, https };
+ render();
+ const link = screen.getByRole("link", { name: text });
+ expect(link.getAttribute("href")).toBe(href);
+ });
+
+ it("preserves certificate-list links that have no host scheme", () => {
+ health.publicPorts = { http: 232, https: 233 };
+ render();
+ expect(screen.getByRole("link", { name: "example.com" }).getAttribute("href")).toBe("http://example.com");
+ });
+
+ it("keeps wildcard links non-navigable", () => {
+ health.publicPorts = { http: 232, https: 233 };
+ render();
+ expect(fireEvent.click(screen.getByRole("link", { name: "*.example.com:233" }))).toBe(false);
+ });
+});
diff --git a/frontend/src/components/Table/Formatter/DomainsFormatter.tsx b/frontend/src/components/Table/Formatter/DomainsFormatter.tsx
index 3ccc4d804a..75f458ed0d 100644
--- a/frontend/src/components/Table/Formatter/DomainsFormatter.tsx
+++ b/frontend/src/components/Table/Formatter/DomainsFormatter.tsx
@@ -1,6 +1,7 @@
import cn from "classnames";
import type { ReactNode } from "react";
import { useLocaleState } from "src/context";
+import { useHealth } from "src/hooks/useHealth";
import { formatDateTime, T } from "src/locale";
interface Props {
@@ -9,9 +10,20 @@ interface Props {
niceName?: string;
provider?: string;
color?: string;
+ linkScheme?: "http" | "https";
}
-const DomainLink = ({ domain, color }: { domain?: string; color?: string }) => {
+const DomainLink = ({
+ domain,
+ color,
+ scheme,
+ suffix,
+}: {
+ domain?: string;
+ color?: string;
+ scheme: "http" | "https";
+ suffix: string;
+}) => {
// when domain contains a wildcard, make the link go nowhere.
// Apparently the domain can be null or undefined sometimes.
// This try is just a safeguard to prevent the whole formatter from breaking.
@@ -24,13 +36,14 @@ const DomainLink = ({ domain, color }: { domain?: string; color?: string }) => {
return (
{domain}
+ {suffix}
);
} catch {
@@ -38,8 +51,13 @@ const DomainLink = ({ domain, color }: { domain?: string; color?: string }) => {
}
};
-export function DomainsFormatter({ domains, createdOn, niceName, provider, color }: Props) {
+export function DomainsFormatter({ domains, createdOn, niceName, provider, color, linkScheme }: Props) {
const { locale } = useLocaleState();
+ const health = useHealth();
+ const scheme = linkScheme ?? "http";
+ const defaultPort = scheme === "https" ? 443 : 80;
+ const port = linkScheme ? (health.data?.publicPorts?.[scheme] ?? defaultPort) : defaultPort;
+ const suffix = port === defaultPort ? "" : `:${port}`;
const elms: ReactNode[] = [];
if ((!domains || domains.length === 0) && !niceName) {
@@ -58,7 +76,9 @@ export function DomainsFormatter({ domains, createdOn, niceName, provider, color
}
if (domains) {
- domains.map((domain: string) => elms.push());
+ domains.map((domain: string) =>
+ elms.push(),
+ );
}
return (
diff --git a/frontend/src/pages/Nginx/DeadHosts/Table.tsx b/frontend/src/pages/Nginx/DeadHosts/Table.tsx
index e5c1b184f0..e9716e7ddb 100644
--- a/frontend/src/pages/Nginx/DeadHosts/Table.tsx
+++ b/frontend/src/pages/Nginx/DeadHosts/Table.tsx
@@ -49,7 +49,13 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
},
cell: (info: any) => {
const value = info.getValue();
- return ;
+ return (
+ 0 ? "https" : "http"}
+ />
+ );
},
}),
columnHelper.accessor((row: any) => row.certificate, {
diff --git a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx
index 7a9b4cd6b2..2e1666c0e0 100644
--- a/frontend/src/pages/Nginx/ProxyHosts/Table.tsx
+++ b/frontend/src/pages/Nginx/ProxyHosts/Table.tsx
@@ -60,7 +60,13 @@ export default function Table({
},
cell: (info: any) => {
const value = info.getValue();
- return ;
+ return (
+ 0 ? "https" : "http"}
+ />
+ );
},
}),
columnHelper.accessor((row: any) => row, {
diff --git a/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx b/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx
index 2457a47380..19367f36ad 100644
--- a/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx
+++ b/frontend/src/pages/Nginx/RedirectionHosts/Table.tsx
@@ -49,7 +49,13 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
},
cell: (info: any) => {
const value = info.getValue();
- return ;
+ return (
+ 0 ? "https" : "http"}
+ />
+ );
},
}),
columnHelper.accessor((row: any) => row.forwardHttpCode, {
diff --git a/scripts/ci/test-and-build b/scripts/ci/test-and-build
index a0de34140a..06bc90ee4f 100755
--- a/scripts/ci/test-and-build
+++ b/scripts/ci/test-and-build
@@ -12,7 +12,7 @@ docker run --rm \
-v "$(pwd)/backend:/app" \
-w /app \
"${TESTING_IMAGE}" \
- sh -c 'yarn install && yarn lint . && rm -rf node_modules'
+ sh -c 'yarn install && yarn lint . && node --test lib/public-ports.test.js && rm -rf node_modules'
echo -e "${BLUE}❯ ${GREEN}Testing Complete${RESET}"
# Build
diff --git a/test/public-ports-smoke.py b/test/public-ports-smoke.py
new file mode 100644
index 0000000000..b7b282b097
--- /dev/null
+++ b/test/public-ports-smoke.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+"""Verify the built image using disposable containers and synthetic hosts."""
+
+import http.client
+import json
+import subprocess
+import sys
+import tempfile
+import time
+from pathlib import Path
+import uuid
+
+
+def docker(*args, **kwargs):
+ return subprocess.check_output(["docker", *args], text=True, **kwargs).strip()
+
+
+def request(port, path, method="GET", headers=None):
+ connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
+ try:
+ connection.request(method, path, headers=headers or {})
+ response = connection.getresponse()
+ return response.status, dict(response.getheaders()), response.read()
+ finally:
+ connection.close()
+
+
+def check_image(image, public_http=None, public_https=None, from_files=False):
+ name = f"npm-public-ports-smoke-{uuid.uuid4().hex[:10]}"
+ args = ["run", "-d", "--platform", "linux/amd64", "--name", name,
+ "-e", "IP_RANGES_FETCH_ENABLED=false",
+ "-p", "127.0.0.1::80", "-p", "127.0.0.1::443", "-p", "127.0.0.1::81",
+ "--mount", "type=volume,destination=/etc/letsencrypt"]
+ port_files = tempfile.TemporaryDirectory(prefix="npm-public-ports-") if from_files else None
+ if from_files:
+ for protocol, port in (("HTTP", public_http), ("HTTPS", public_https)):
+ Path(port_files.name, protocol).write_text(str(port))
+ args.extend(["-e", f"PUBLIC_{protocol}_PORT__FILE=/run/public-ports/{protocol}"])
+ args.extend(["--mount", f"type=bind,source={port_files.name},destination=/run/public-ports,readonly"])
+ else:
+ if public_http is not None:
+ args.extend(["-e", f"PUBLIC_HTTP_PORT={public_http}"])
+ if public_https is not None:
+ args.extend(["-e", f"PUBLIC_HTTPS_PORT={public_https}"])
+ args.append(image)
+ expected_ports = {"http": public_http or 80, "https": public_https or 443}
+ try:
+ docker(*args)
+ ports = {port: int(docker("port", name, f"{port}/tcp").rsplit(":", 1)[1])
+ for port in (80, 443, 81)}
+ deadline = time.monotonic() + 90
+ while True:
+ try:
+ status, _, body = request(ports[81], "/api/")
+ health = json.loads(body)
+ if status == 200 and health.get("status") == "OK":
+ break
+ except (OSError, ValueError, http.client.HTTPException):
+ pass
+ if time.monotonic() >= deadline:
+ raise AssertionError("Manager health endpoint did not become ready")
+ time.sleep(2)
+ assert health["public_ports"] == expected_ports, health
+ docker("exec", name, "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
+ "-keyout", "/tmp/public-ports.key", "-out", "/tmp/public-ports.crt",
+ "-days", "1", "-subj", "/CN=audit.example.test", stderr=subprocess.DEVNULL)
+ config = """server {
+ listen 80;
+ listen 443 ssl;
+ server_name audit.example.test;
+ ssl_certificate /tmp/public-ports.crt;
+ ssl_certificate_key /tmp/public-ports.key;
+ set $trust_forwarded_proto "F";
+ include conf.d/include/force-ssl.conf;
+ location / { return 200 "public ports smoke"; }
+}
+server {
+ listen 80;
+ server_name trusted.example.test;
+ set $trust_forwarded_proto "T";
+ include conf.d/include/force-ssl.conf;
+ location / { return 200 "trusted HTTPS"; }
+}
+"""
+ subprocess.run(["docker", "exec", "-i", name, "sh", "-c",
+ "cat > /data/nginx/proxy_host/999.conf"], input=config, text=True, check=True)
+ docker("exec", name, "nginx", "-t")
+ docker("exec", name, "nginx", "-s", "reload")
+ time.sleep(1)
+ suffix = "" if expected_ports["https"] == 443 else f':{expected_ports["https"]}'
+ path = "/nested/path?check=1&two=2"
+ location = f"https://audit.example.test{suffix}{path}"
+ for method in ("GET", "POST"):
+ status, headers, _ = request(ports[80], path, method,
+ {"Host": f'audit.example.test:{expected_ports["http"]}'})
+ assert status == 301, (method, status, headers)
+ assert headers.get("Location") == location, headers
+ status, _, _ = request(ports[80], "/.well-known/acme-challenge/test-challenge",
+ headers={"Host": "audit.example.test"})
+ assert status == 200, ("ACME exception", status)
+ status, _, _ = request(ports[80], "/", headers={"Host": "trusted.example.test",
+ "X-Forwarded-Proto": "https"})
+ assert status == 200, ("trusted forwarded HTTPS", status)
+ if expected_ports["https"] != 443:
+ status, headers, _ = request(ports[443], path, headers={"Host": "audit.example.test"})
+ assert status == 307 and headers.get("Location") == location, (status, headers)
+ print(json.dumps({"ports": expected_ports, "health": "passed", "force_ssl": "passed",
+ "acme_exception": "passed", "trusted_forwarded_https": "passed",
+ "http_on_ssl": "passed" if expected_ports["https"] != 443 else "upstream behavior"}), flush=True)
+ except Exception:
+ subprocess.run(["docker", "logs", "--tail", "60", name], check=False)
+ raise
+ finally:
+ subprocess.run(["docker", "rm", "-fv", name], stdout=subprocess.DEVNULL, check=False)
+ if port_files is not None:
+ port_files.cleanup()
+
+
+check_image(sys.argv[1], 232, 233)
+check_image(sys.argv[1])
+check_image(sys.argv[1], 232, 233, from_files=True)