Skip to content

Ports & host detection

norsk-ctl binds three classes of port: the daemon’s local API (talked to by the CLI), the proxy front door (what your browser hits), and per-instance ingest ports (SRT, RTMP, etc. that your pipelines listen on). This page is the single source of truth for the defaults, how to change them, and how the installer picks the public host that goes into the TLS certificate.

The proxy port depends on the network mode. From shared/src/derived-settings.ts:

Network modeDefault proxy portNotes
docker (default)443The default for install.sh --server
hybrid443Same binding as docker; set --network-mode hybrid at install / init

TLS is always on; the insecure-HTTP mode was removed (#280).

Plus, regardless of network mode:

PortServiceDirectionNotes
8333norsk-ctl daemon API + /mcpLocal-onlyCLI and the norsk-ctl mcp stdio bridge talk to this. Do NOT expose to the public internet. The nginx proxy enforces a loopback bypass on /mcp — external /mcp requests are blocked
22SSHInboundTo run the installer and operate the box.
80HTTP→HTTPS redirect + Let’s Encrypt issuanceInboundBound by default for the redirect (a user typing http://<host>/ is bounced to HTTPS) and used by certbot HTTP-01. When 80 belongs to something else, --http-redirect-port <n> moves the listener, or --no-http-redirect drops it — the latter also makes certbot unavailable, since HTTP-01 needs 80.
5001+ (typical)Media ingestInboundSRT/RTMP/etc. — configured per instance, published from media containers to the host. 5001 is the SRT default (backend/src/docker/constants.ts:62); your pipeline can listen on any port you give it.

The proxy port lives in the proxyPort field of config.yaml. Two ways to set it:

Terminal window
sudo bash install.sh --server --license /tmp/license.json --ip auto --proxy-port 8443
# or, post-install:
norsk-ctl init --proxy-port 8443

Writes proxyPort: 8443 into config.yaml. The defaults above don’t apply once proxyPort is set.

# /etc/norsk-ctl/config.yaml (or ~/.norsk-ctl/config.yaml for --local installs)
proxyPort: 8443
Terminal window
sudo systemctl restart norsk-ctl

Behind another reverse proxy — externalPort

Section titled “Behind another reverse proxy — externalPort”

When something else fronts the box — publishing 443 for several services and forwarding one of them here — the port the proxy binds is no longer the port a browser connects to. Set both:

Terminal window
sudo bash install.sh --server --license /tmp/license.json \
--public-host studio.example.com --proxy-port 8443 --external-port 443
# or, on a running daemon:
norsk-ctl config set --proxy-port 8443 --external-port 443
sudo systemctl restart norsk-ctl
FieldQuestion it answers
proxyPortWhere do we listen? — so it’s what the fronting proxy forwards to
externalPortWhere do clients arrive? — so it’s what every advertised URL names

externalPort defaults to proxyPort, which is correct whenever nothing fronts the box. Set only proxyPort behind a fronting proxy and the oauth2 sign-in redirect — plus the base URL baked into each instance at launch — names :8443, a port the fronting proxy doesn’t publish, so sign-in dead-ends.

On the fronting proxy: forward to https://<box>:8443 preserving Host, allow WebSocket upgrades, and turn response buffering off (the UI uses Server-Sent Events; instance screens use WebSockets). Our certificate is self-signed unless you supplied one, so that hop needs verification disabled or a real certificate via --cert-source user.

Only a subdomain at the root is supported (https://studio.example.com), not a path prefix (https://example.com/ctl): the UI bundle, the nginx routes, the oauth2 callback and the per-instance URL prefixes are all rooted at /.

The public host is the hostname or IP your clients type into the browser. It serves two roles:

  • It’s baked into the self-signed TLS certificate’s Subject Alternative Name (SAN), so the browser doesn’t reject the cert as for-the-wrong-host.
  • It’s the prefix the proxy advertises for per-instance URLs (Studio, API, etc.).

You set it at install time. Three options:

Use the exact string you’ll type into the browser — DNS name or IP:

Terminal window
sudo bash install.sh --server --license /tmp/license.json --public-host norsk.example.com

Prefer this when:

  • You have a DNS name (it’ll appear correctly in the cert SAN).
  • You’re on a private network and the public-IP probe won’t find a usable address.
  • You’re behind NAT and the box’s outbound IP differs from what clients reach.

The value must match what you type into the browser exactly, because it prefixes every advertised instance URL. If clients reach the box on a non-standard port, include it — --public-host 127.0.0.1:8443. A host carrying an explicit port is used verbatim; without one, the proxy port is appended. This is what makes a tunnelled setup work, where clients connect to a forwarded local port rather than to the box’s own address:

Terminal window
# clients reach the proxy through `ssh -L 8443:localhost:443`
norsk-ctl config set --public-host 127.0.0.1:8443
Terminal window
sudo bash install.sh --server --license /tmp/license.json --ip auto

The installer probes the public IP by curling these endpoints in order (deployment/install.sh:132-138):

  1. https://api.ipify.org
  2. https://ifconfig.me

Each call has a 5-second timeout. The first one that responds with a non-empty IP wins, and that IP becomes --public-host for init. If both fail, the install aborts with couldn't auto-detect a public IP — pass --ip <host>.

--ip auto is most useful on a public cloud VM where the box has a single public IP and you don’t have a DNS name yet.

If you don’t pass either flag and the install is interactive, it prompts: Public host/IP clients use to reach this box (blank = localhost only). Leave blank for a localhost-only install (useful for evaluation on a desktop with no remote clients).

publicHost lives in config.yaml. Editing it requires re-issuing the TLS cert so its SAN matches the new host:

Terminal window
sudo norsk-ctl init --force --public-host new.example.com --cert-source self-signed

For certbot-issued certs, point at the existing letsencrypt path:

Terminal window
sudo norsk-ctl init --force --public-host new.example.com \
--cert-source certbot --domain new.example.com --cert-email ops@example.com

Ingest ports aren’t a global config — they’re declared per instance when you launch it (in the workflow YAML, or via the Studio UI). The media container --publishes each declared port to the host, so they need to be open in the host firewall.

To see which ingest ports a running instance is using, open the instance overview in the web UI, or query the daemon API:

Terminal window
curl -sf https://<host>/api/instances/<id> | jq '.media.ingestPorts'

(The CLI talks to the daemon via the local :8333 socket; --host <url> lets you point it at a remote one if needed.)

Every product publishes a Studio host port — STUDIO_HOST_PORT, the raw Studio editor and API on something like 18000. Docker publishes a port with no bind IP on 0.0.0.0, and inserts its forwarding rules ahead of host firewalls such as ufw, so a ufw deny rule does not protect one. That used to put an unauthenticated control surface on every interface of the box.

So norsk-ctl rewrites the compose it launches to give the studio service’s published ports an explicit 127.0.0.1 bind. Only studio — every other service a product declares keeps publishing exactly as the product wrote it, because those ports carry media (a packager origin a CDN pulls, an SRT listener, an RTMP relay) and ctl cannot tell one from the other without the product saying so.

PortBindsReach it at
The proxy front door (443)every interfacehttps://<host>/ — unchanged, and the intended way in
An instance’s UI, dashboards and Studioevery interface, via the proxyhttps://<host>/instance/<id>/ — unchanged; nginx reaches the container over the norsk-net bridge, never the host port
Media ingest / egress, and any other service’s published portsevery interfacesrt://<host>:5001 etc. — unchanged
The studio service’s ports (STUDIO_HOST_PORT)127.0.0.1 onlyhttp://localhost:18000 on the box itself, or an SSH tunnel: ssh -L 18000:127.0.0.1:18000 <host>

Three things keep even a studio port on every interface: a UDP mapping, a host side naming a port the product declared in allocatedPorts, and — the escape hatch to reach for — an entry that already states a bind IP. 0.0.0.0:18000:8000, 1.2.3.4:18000:8000 and [::1]:18000:8000 are all left exactly as written, so a product that needs its Studio port on a particular interface says so in its own compose.

For a host where the surrounding network is trusted and you want the direct port back:

Terminal window
norsk-ctl instance launch-template my-instance --template my-product --publish-debug-ports

It is per launch (relaunch re-applies whatever the launch config says), and it only affects the studio service — everything else was binding every interface either way. The opposite end of the scale is --internal-only, which publishes no host ports at all and leaves the instance reachable only over norsk-net.

The launch log names every port it rebound and the flag that undoes it, so norsk-ctl instance logs (or the daemon journal) is where to look if a port you expected is refusing connections. It also names a Studio port it could not rebind — an entry written as a single compose variable ("${STUDIO_PORTS}") has no shape ctl can safely prefix, so it is left published and reported.