Interfaces
setupInterfaces() defines the network interfaces your service exposes and how they are made available to the user. This function runs on service install, update, and config save.
Network Reachability
Your package declares what it exposes. The user decides where it is reachable. An interface is bound to the server’s gateways, and the user enables or disables each resulting address individually from the service’s Interfaces tab. LAN addresses (the .local hostname, the LAN IP) are enabled by default; public IPv4 addresses are off by default.
Two consequences worth internalizing before you write any interface code:
typeis a label, not a control.'ui','api', and'p2p'tell the user what an interface is for. They do not select a transport, grant public access, or imply anything about how the interface is reached.- Tor is opt-in and per-interface. Tor is not part of StartOS. The user installs the Tor service from the marketplace, and then explicitly adds an onion address to each interface they want on Tor — see Tor. Nothing your package does provisions one.
Warning
Never state — in
README.md,instructions.md, a comment, or a plan — that a service “is exposed on Tor” or “is published to the internet.” Your package cannot know: no binding type, and no value oftype, causes an onion or a clearnet address to exist. Describe what the interface serves and let the user decide how to reach it.
Single Interface
For a service with one web interface:
import { i18n } from './i18n'
import { sdk } from './sdk'
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const multi = sdk.MultiHost.of(effects, 'ui')
const origin = await multi.bindPort(80, {
protocol: 'http',
preferredExternalPort: 80,
})
const ui = sdk.createInterface(effects, {
name: i18n('Web Interface'),
id: 'ui',
description: i18n('The main web interface'),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
return [await origin.export([ui])]
})
Multiple Interfaces
Expose multiple paths (e.g., web UI and admin panel) from the same port:
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const multi = sdk.MultiHost.of(effects, 'web')
const origin = await multi.bindPort(80, {
protocol: 'http',
preferredExternalPort: 80,
})
const ui = sdk.createInterface(effects, {
name: i18n('Web UI'),
id: 'ui',
description: i18n('The web interface'),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
const admin = sdk.createInterface(effects, {
name: i18n('Admin Panel'),
id: 'admin',
description: i18n('Admin interface'),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '/admin/',
query: {},
})
return [await origin.export([ui, admin])]
})
Expose interfaces on separate ports:
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const receipts = []
// Web UI — HTTP
const uiMulti = sdk.MultiHost.of(effects, 'ui')
const uiOrigin = await uiMulti.bindPort(80, {
protocol: 'http',
preferredExternalPort: 80,
})
const ui = sdk.createInterface(effects, {
name: i18n('Web Interface'),
id: 'ui',
description: i18n('The main browser interface'),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
receipts.push(await uiOrigin.export([ui]))
// API — HTTPS with SSL termination
const apiMulti = sdk.MultiHost.of(effects, 'api')
const apiOrigin = await apiMulti.bindPort(8080, {
protocol: 'https',
preferredExternalPort: 8080,
addSsl: {
alpn: null,
preferredExternalPort: 8080,
addXForwardedHeaders: false,
},
})
const api = sdk.createInterface(effects, {
name: i18n('REST API'),
id: 'api',
description: i18n('Programmatic access'),
type: 'api',
masked: true,
schemeOverride: null,
username: null,
path: '',
query: {},
})
receipts.push(await apiOrigin.export([api]))
// Peer — raw TCP (not HTTP)
const peerMulti = sdk.MultiHost.of(effects, 'peer')
const peerOrigin = await peerMulti.bindPort(9735, {
protocol: null,
addSsl: null,
preferredExternalPort: 9735,
secure: { ssl: false },
})
const peer = sdk.createInterface(effects, {
name: i18n('Peer Interface'),
id: 'peer',
description: i18n('Peer-to-peer network connections'),
type: 'p2p',
masked: true,
schemeOverride: null,
username: null,
path: '',
query: {},
})
receipts.push(await peerOrigin.export([peer]))
return receipts
})
The key steps are:
- Create a
MultiHostand bind a port with protocol and options - Create one or more interfaces using
sdk.createInterface() - Export the interfaces from the origin and return the receipt(s)
bindPort Options
| Option | Type | Description |
|---|---|---|
protocol | 'http' | 'https' | null | The protocol. Use null for raw TCP (non-HTTP). |
preferredExternalPort | number | The port users will see in their URLs. |
addSsl | object | null | SSL termination options for HTTPS. Set to null for no SSL. |
addSsl.alpn | string | null | ALPN protocol negotiation (e.g., 'h2'). Usually null. |
addSsl.preferredExternalPort | number | External port for SSL connections. |
addSsl.addXForwardedHeaders | boolean | Whether to add X-Forwarded-* headers. |
addSsl.auth | ProxyAuth | null | Optional auth gate enforced by the OS reverse proxy. See Authenticating at the Proxy. |
addSsl.upstreamCertValidation | 'disable' | { certificate: string } | omitted | How the OS validates your container’s TLS cert when it rewraps SSL. Omit to validate against the StartOS root CA (default). See Rewrapping SSL. |
secure | { ssl: boolean } | null | For non-HTTP protocols, whether the connection is secure. { ssl: true } with addSsl: null serves your container’s own TLS end to end — see Serving Your Own TLS. |
Interface Options
sdk.createInterface(effects, {
name: i18n('Display Name'), // Shown in UI (wrap with i18n)
id: 'unique-id', // How you find this interface under its host
description: i18n('Description'), // Shown in UI (wrap with i18n)
type: 'ui', // 'ui', 'api', or 'p2p'
masked: false, // Hide URLs with sensitive credentials?
schemeOverride: null, // Override URL scheme (see below)
username: null, // Auth username embedded in URL
path: '/some/path/', // URL path
query: {}, // URL query params
})
| Option | Type | Description |
|---|---|---|
name | string | Display name shown to the user. Wrap with i18n(). |
id | string | Unique identifier. How you find this interface at runtime, by walking the host from sdk.host.getOwn() (see main.ts). |
description | string | Description shown to the user. Wrap with i18n(). |
type | 'ui', 'api', or 'p2p' | 'ui' for browser interfaces, 'api' for programmatic endpoints, 'p2p' for peer-to-peer connections. |
masked | boolean | If true, the interface URL is shown as a copyable secret. Use for URLs containing credentials or tokens. |
schemeOverride | { ssl: string | null; noSsl: string | null } | null | Override the URL scheme for custom protocols. For example, { ssl: 'lndconnect', noSsl: 'lndconnect' } produces lndconnect:// URLs. Use null for standard http/https. |
username | string | null | Username embedded in the URL (e.g., for smp://fingerprint:password@host). |
path | string | URL path appended to the base address (e.g., '/admin/'). |
query | object | URL query parameters as key-value pairs (e.g., { macaroon: 'abc123' }). |
Tip
The
idyou assign to an interface is what you use inmain.tsto retrieve hostnames for it. Interfaces are reached through their host:sdk.host.getOwn(effects, hostId)returns the host, and the interface lives athost.bindings[internalPort].interfaces[id]. See Main for details.
Port Ranges
Some services need a contiguous block of ports rather than a single one — coturn / RTP media relays, bitcoin’s ZMQ notification endpoints, passive-FTP data ports. Use bindPortRange instead of one bindPort per port:
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const turn = sdk.MultiHost.of(effects, 'turn')
const range = await turn.bindPortRange({
internalStartPort: 49152,
externalStartPort: 49152, // may differ; the forward maps by offset
numberOfPorts: 100, // 2–500 contiguous ports
})
await range.export(
sdk.createRangeInterface(effects, {
id: 'turn-relay',
name: i18n('TURN Relay'),
description: i18n('WebRTC media relay ports'),
}),
)
return []
})
A range binds TCP + UDP together and exposes exactly one api service interface spanning the whole range. The interface is deliberately restricted compared to createInterface: it is always type: 'api' and has no masked, username, path, query, or schemeOverride. The one extra option is an optional scheme — a transport prefix for protocols addressed as scheme://host:port, e.g. tcp for bitcoin ZMQ:
const zmq = sdk.MultiHost.of(effects, 'zmq')
const zmqRange = await zmq.bindPortRange({
internalStartPort: 28332,
externalStartPort: 28332,
numberOfPorts: 2,
})
await zmqRange.export(
sdk.createRangeInterface(effects, {
id: 'zmq',
name: i18n('ZMQ'),
description: i18n('Bitcoin ZMQ notification endpoints'),
scheme: 'tcp', // omit for raw UDP/TCP ranges (coturn, RTP, FTP data)
}),
)
Two distinct endpoints are two bindPortRange calls — a range is a homogeneous pool of ports, so it maps to one named interface. Range interfaces show up in the service’s Interfaces page using the same per-gateway address cards as single-port interfaces (non-SSL, IPv4-only). The public/WAN address is disabled by default; enabling it surfaces the exact port range to forward on the router.
createRangeInterface option | Type | Description |
|---|---|---|
id | string | Unique identifier for the range interface. |
name | string | Display name shown to the user. Wrap with i18n(). |
description | string | Description shown to the user. Wrap with i18n(). |
scheme | string | null | Optional transport prefix (e.g. 'tcp'). Omit for raw UDP/TCP ranges. |
TLS Termination
StartOS terminates TLS at the platform edge and proxies plain HTTP to your container. This has two important consequences any time your service generates URLs or makes scheme decisions:
1. Inside the container, every request arrives over HTTP. A reverse proxy like nginx will see $scheme == "http", the X-Forwarded-Proto header is not authoritative by default, and there is no TLS certificate to terminate. Do not configure in-container HTTPS — StartOS is already doing it.
2. The browser loaded the page over https://. Any URL your service emits for the browser to consume (login redirects, API endpoints in a config.json, OAuth callbacks, absolute links in HTML) must use https://. If you emit http:// or derive the scheme from $scheme, the browser will block the request as mixed active content.
Hardcode https:// for browser-facing URLs rather than interpolating $scheme or reading the protocol from the incoming request:
# BAD — $scheme is always "http" inside the container
return 200 '{"api_url":"$scheme://$host/api"}';
# GOOD — match what the browser actually sees
return 200 '{"api_url":"https://$host/api"}';
This applies to any configuration file generated in setupMain or any runtime response that includes absolute URLs — not just nginx. When in doubt, hardcode https://.
Rewrapping SSL to a TLS container
The guidance above (“do not configure in-container HTTPS”) applies when StartOS terminates TLS and forwards plain HTTP — the http/ws protocols. The https/wss protocols are different: the container serves its own TLS, StartOS terminates the client’s TLS at the edge, and then opens a fresh TLS connection to your container (a “rewrap”). This happens whenever addSsl is set and the protocol’s secure.ssl is true.
On that inner OS→container leg, StartOS validates your container’s certificate. By default it requires a certificate signed by the StartOS root CA. A container serving a self-signed certificate on the internal bridge will fail that check, so use addSsl.upstreamCertValidation to control it:
| Value | Behavior |
|---|---|
| omitted | Validate against the StartOS root CA (default). |
'disable' | Skip certificate validation entirely. Appropriate for a self-signed cert on the trusted internal bridge. |
{ certificate: '<pem>' } | Validate against the supplied PEM certificate/chain instead of the root CA. |
const origin = await multi.bindPort(443, {
protocol: 'https',
addSsl: {
upstreamCertValidation: 'disable', // container serves its own self-signed cert
},
})
Note
For
{ certificate }, StartOS connects to the container by IP, so the pinned certificate must be valid for that internal IP (present in its SANs). If it isn’t, use'disable'instead.
Serving Your Own TLS (Passthrough)
There is a third arrangement, distinct from both plain termination and the rewrap: passthrough, where your container’s certificate reaches the client unmodified. Set secure: { ssl: true } with no addSsl:
const origin = await multi.bindPort(10009, {
protocol: null,
addSsl: null,
preferredExternalPort: 10009,
secure: { ssl: true },
})
StartOS still fronts the port with one of its TLS listeners, but that listener pipes the raw TLS stream through instead of terminating it, so nothing about the handshake is rewritten. The container sees the client’s real source address rather than the proxy’s — except for a client on the box itself, which appears as the bridge IP.
When to use it
Reach for passthrough only when the rewrap genuinely cannot serve, which is one of two cases:
- The client must verify your container’s own certificate. A wallet that pins a certificate carried in a connection URI can only do so if the certificate it pins is the one actually served.
- The handshake carries something a rewrap does not. ALPN is the concrete case: StartOS negotiates no ALPN with the client across an
addSslrewrap, and gRPC-go rejects a connection with no selected ALPN (missing selected ALPN property). LND binds its gRPC interface this way for exactly that reason.
Otherwise prefer addSsl. Passthrough gives up everything the proxy does on your behalf:
| Capability | addSsl | Passthrough |
|---|---|---|
| Certificate the client sees | The device certificate | Your container’s |
Proxy auth (addSsl.auth) | Available | Not available — auth lives under addSsl |
X-Forwarded-* headers | Available | Not applicable |
| ACME on a custom domain | StartOS obtains and renews it | Skipped — your container is the ACME client |
| UDP on the same port | Not applicable | No; the port accepts TLS only |
| Certificate issuance and renewal | Handled by the platform | Yours to handle |
Minting the certificate
sdk.getSslCertificate returns a PEM fullchain — leaf, intermediate, StartOS root CA — for the hostnames you name, and sdk.getSslKey returns the matching key. Because the chain terminates at the StartOS root CA, a client that already trusts the box validates your certificate without pinning anything.
The SANs are the whole contract. Nothing rewrites the handshake, so the certificate must be valid for every address a client actually dials — there is no proxy to paper over a mismatch:
sdk.getOsIp(10.0.3.1) — the bridge, where other services reach you127.0.0.1— your own subcontainers, which share the service’s network namespacesdk.getContainerIp— the container itself- any other address you expect a client to use
export const setupCerts = sdk.setupOnInit(async effects => {
const hostnames = [await sdk.getContainerIp(effects).const(), '127.0.0.1', await sdk.getOsIp(effects)]
const cert = (await sdk.getSslCertificate(effects, hostnames).const()).join('')
const key = await sdk.getSslKey(effects, { hostnames })
await writeFile('/media/startos/volumes/main/tls.cert', cert)
await writeFile('/media/startos/volumes/main/tls.key', key)
})
Read the container IP with .const() rather than .once(): a container that comes back on a new IP must reissue the certificate, or every client dialing the old one fails verification.
Warning
Do not add a
<package-id>.startosDNS name to the SANs. That overlay DNS is deprecated and slated for removal, and it resolves to the container IP rather than the bridge — so it bypasses the platform entirely. Dependents reach you through the bridge; see Service-to-Service Networking.
A passthrough port carries its external port in net.assignedSslPort, the same as an addSsl port — which of the two fields is populated says whether the port speaks TLS, not who terminates it. Dependents should read neither field directly; sdk.host.getBridgeAddress resolves the binding’s derived address and is correct under every arrangement on this page.
Authenticating at the Proxy
For protocols that StartOS fronts with its reverse proxy (http, https, ws, wss), you can gate an interface with HTTP authentication by setting addSsl.auth. The OS reverse proxy validates the Authorization header on every incoming request before forwarding it to your container. Requests that fail get 401 Unauthorized with a WWW-Authenticate challenge and never reach your service. You do not need to build auth into the service or run a sidecar proxy — the platform enforces it at the edge.
auth takes a ProxyAuth, which is one of two shapes:
// Basic — one or more username/password pairs; any match passes
const uiOrigin = await uiMulti.bindPort(uiPort, {
protocol: 'http',
addSsl: {
auth: {
type: 'basic',
credentials: [{ username: 'admin', password }],
realm: null, // advertised in the WWW-Authenticate challenge; defaults to "StartOS"
},
},
})
// Bearer — any of the listed tokens is accepted as `Authorization: Bearer <token>`
const apiOrigin = await apiMulti.bindPort(apiPort, {
protocol: 'https',
addSsl: {
auth: { type: 'bearer', tokens: [apiToken], realm: null },
},
})
ProxyAuth field | Type | Description |
|---|---|---|
type | 'basic' | 'bearer' | The auth scheme the proxy enforces. |
credentials (basic) | Array<{ username, password }> | Accepted pairs. Any match passes. The matched username is forwarded upstream as X-Forwarded-User. |
tokens (bearer) | Array<string> | Accepted bearer tokens. Any match passes. |
realm | string | null | Realm advertised in the 401 WWW-Authenticate challenge. Defaults to "StartOS". Use a stable realm across bindings that share credentials so browsers reuse them. |
Setting auth implies HTTP-aware proxying, so it is only valid on the SSL-variant protocols above — not on raw TCP (protocol: null).
Note
The
usernamefield oncreateInterfaceis unrelated to this gate — it only embeds a username in the displayed URL (e.g.https://user@host/). The enforced credential check isaddSsl.auth.
Generating and rotating credentials
Don’t hard-code the password. Generate it at install time and let the user rotate it through an action. Store the credential in a file model such as store.json and read it reactively in setupInterfaces — when the action rewrites the stored value, setupInterfaces re-runs and the proxy picks up the new credential automatically:
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const password = await storeJson.read(s => s.uiPassword).const(effects)
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
const uiOrigin = await uiMulti.bindPort(uiPort, {
protocol: 'http',
addSsl: {
auth: { type: 'basic', credentials: [{ username: 'admin', password }], realm: null },
},
})
const ui = sdk.createInterface(effects, {
name: i18n('Web UI'),
id: 'ui',
description: i18n('The web interface'),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
return [await uiOrigin.export([ui])]
})
Seed uiPassword with a generated value during install init so the gate is active from first start, and pair it with a reset-password action that rewrites the stored value and surfaces it to the user once. See Reset Password.