Configuration

ETTIX STREAM is configured by one JSON file. There are no other configuration files, no include directives and no environment variables that change behaviour; everything the server does is described in that one document, and the server writes the same document back when you change something through the administration interface.

This page covers the parts an operator touches, the rules the validator enforces, and how a change reaches a running server. It is not a field-by-field reference of every setting that exists. Viewer authorization, publisher credentials, TLS and encryption have their own pages.

Where the file lives

The default path is /etc/ettix-stream/config.json. Every command that reads a configuration takes --config PATH, and falls back to the ETTIX_STREAM_CONFIG environment variable before it falls back to the default. The systemd unit installed by install.sh names the path explicitly:

ExecStart=/usr/local/bin/ettix-stream serve --config /etc/ettix-stream/config.json

The installer creates the file with mode 0640, owner root, group ettix-stream, inside a directory created as 2770 root:ettix-stream. Those permissions are worth understanding rather than copying, because two different things need them.

The file is not world-readable because it holds live credentials: RTMP stream keys, SRT passphrases, administration password hashes and ingest tokens. Anyone who can read it can publish to your streams. The directory is group-writable because the administration API replaces the file by writing a temporary file beside it and renaming over the top — an atomic replace needs write permission on the directory, not on the file. When the server creates a configuration file that did not exist it uses mode 0600; when it replaces one that did, it keeps whatever mode the file already had, so a deliberate choice of yours is never quietly widened.

The service account never needs to read the file as root. If you tighten the permissions yourself, keep the ettix-stream group able to read the file and write the directory, or the server will start but every save from the administration interface will fail with a read-only-filesystem or permission error.

How the document is read

It is strict JSON: UTF-8, no comments, no trailing commas. Two rules beyond that are worth knowing before you start editing.

Unknown fields are rejected. A misspelt key is an error, not a setting that silently does nothing. This is the single most useful property of the loader — a typo in a security-relevant field would otherwise leave you believing a control is in force when it is not:

$ ettix-stream config check --config /etc/ettix-stream/config.json
error: /etc/ettix-stream/config.json: config: parse: json: unknown field "admin_lister"

Durations and sizes are written as strings. A duration is a Go duration string: "4s", "500ms", "1m30s", "72h". A bare number is read as seconds. A size is either a plain number of bytes or a string with a unit — "512MB", "2GiB", "64KB". Every unit is a binary multiple, so MB and MiB mean the same thing here.

The top-level version field is the schema version and must be 1. It is not the product version. Absent settings take their defaults, and absent blocks take the defaults of every field inside them, so a short file is a legitimate file.

Checking a configuration before you use it

ettix-stream config check loads a file, applies the defaults and runs every validation rule, then exits without starting anything. Run it after every edit, and run it with the new binary before an upgrade.

ettix-stream config check --config /etc/ettix-stream/config.json

A file that passes prints a one-line summary:

/etc/ettix-stream/config.json: OK (1 streams, http :8080, rtmp :1935)

A file that fails prints every problem it found, each one naming the field by its path in the document, and exits non-zero. The validator does not stop at the first error, because fixing a configuration one error per run is how an outage gets longer:

$ ettix-stream config check --config bad.json
error: bad.json: config: 3 problems:
  - http.admin_listen: "0.0.0.0:8081" is not loopback; bind it to 127.0.0.1, or set
    http.admin_allow_remote and configure http.admin_auth once you have put a firewall,
    VPN or authenticating proxy in front of it too
  - streams[0] (channel1).source.rtmp.stream_key: still the example placeholder, which is
    published in the documentation and in every release; anyone who has read it could
    publish to this stream. Replace it
  - streams[0] (channel1).hls.segment_duration: must be between 1s and 30s

The stream index and the stream id both appear, so streams[0] (channel1) tells you which entry to edit in a file with forty of them.

The exit code distinguishes the two ways a check can fail, which matters in a deployment script:

Exit codeMeaning
0The file is valid.
3The document parsed, but one or more settings were refused. The problems are listed on standard error.
1The file could not be read or is not valid JSON, including an unknown field.
2The command was not recognised, or help was asked for. A mistyped flag exits 1.

Two other commands help here. ettix-stream config example prints a starter document generated by the binary that will read it, so the two can never disagree about the schema. ettix-stream config check --dvr-roots prints the DVR destination directories, one per line, and nothing else — that is what the installer uses to fill in ReadWritePaths in the systemd unit.

Applying a change

Editing the file changes nothing by itself. A running server re-reads it on SIGHUP, and the systemd unit maps systemctl reload onto exactly that signal.

Edit the file

Work on the real file, or on a copy you move into place. Keep a backup: the file is often the only written record of a stream key.

Validate it

ettix-stream config check --config /etc/ettix-stream/config.json

This is not optional courtesy. A reload that fails leaves the running server on its old configuration, which is safe, but it also means your change silently did not happen.

Reload

sudo systemctl reload ettix-stream

Without systemd, send the signal directly:

sudo kill -HUP $(pidof ettix-stream)

Confirm it took effect

The server logs reload requested and then reload complete with the number of streams added, removed and restarted. A rejected file logs reload rejected; keeping current configuration together with the validation problems.

journalctl -u ettix-stream -n 30

A reload applies the whole document or none of it. It is validated completely before anything is swapped in, so a broken file cannot take half of your streams off the air.

What a reload cannot change

Streams and the log level change while the server runs. Adding, removing or editing a stream is exactly what reload is for: only the streams that actually changed are restarted, and the rest keep their publishers and their viewers.

Settings that belong to a listener or to a process-wide subsystem cannot change under a running process. They are still written into the new configuration, and the server tells you it needs a restart rather than pretending:

configuration reloaded; listener, RTMP or cache settings changed and require a restart to take effect
BlockReload
streams[]Applied. Changed streams are restarted, untouched ones are left alone.
log.levelApplied immediately.
log.formatRestart. See the warning below.
http (any field)Restart.
rtmp (any field)Restart.
hls.cache_max_bytesRestart.
server.data_dirRestart.
license, telemetryRestart.

log.format is the one setting that changes on reload without being reported. The level is applied live and the format is read only when the process starts, so a reload after switching "text" to "json" succeeds, says nothing, and leaves the log in the old format until you restart. If your log shipper suddenly stops parsing, restart the service.

A restart is systemctl restart ettix-stream. It drops publishers and viewers, who reconnect; it does not damage recordings.

The administration API writes this file

The administration interface is not a read-only view. Saving a change there — enabling a stream, adding a DVR destination, editing a stream key — writes the configuration file on disk and then applies it through the same code path SIGHUP uses. This is deliberate: a running server whose settings have drifted away from the file it starts from is one restart away from silently reverting.

The routes involved are on the administration listener:

RouteWhat it does
GET /api/v1/configReturns the running document, its source path, a version fingerprint, and whether this server can write it back.
PUT /api/v1/configValidates a complete document, writes it to disk, then applies it.
POST /api/v1/config/checkValidates a document without writing or applying anything.
POST /api/v1/config/reloadRe-reads the file from disk, which is what SIGHUP does.
GET|PUT|DELETE /api/v1/streams/{id}/configThe same mechanism applied to one stream's block.

The document returned by GET /api/v1/config is redacted and cannot be written back unchanged. Stream keys, SRT passphrases, tokens and password hashes render as ***, because a configuration reaches log files, diagnostic bundles and the API. The response carries "redacted": true to say so. A script that reads the configuration, edits one field and PUTs the result has its write refused rather than applied: *** is three characters where a stream key needs eight and an SRT passphrase ten, and it is not a hash at all, so the document fails validation with CONFIG_INVALID and nothing is written — the edit you meant to make is lost with it. Read the file on disk when you need the real values, or use the single-stream routes, which edit the file as text and leave untouched blocks exactly as you wrote them.

What this means for hand edits is straightforward. The file on disk is the source of truth for secrets, and the API manipulates it as text rather than round-tripping it through parsed values. But if you edit the file by hand while someone else is working in the administration interface, one of the two changes will be lost — the API replaces the file wholesale. To guard against that, a write may carry the version fingerprint it read; a document that changed underneath is refused with CONFIG_CONFLICT rather than merged, because merging two edits to a configuration is guessing.

Two more consequences of the file being writable by the server:

The blocks you will actually edit

server

"server": {
  "name": "origin-1",
  "data_dir": "/var/lib/ettix-stream"
}

name is a display name shown in the status output and in telemetry; it defaults to the machine's hostname. data_dir defaults to /var/lib/ettix-stream, must be an absolute path, and is created with mode 0700 if it is missing and checked for writability at startup. It holds the state that makes this installation itself: the installation identity and private key, the licence lease, and the encryption keys. Back it up, and do not point two servers at the same directory — copying an identity is exactly the cloning the licensing hardware fingerprint exists to notice.

log

"log": { "level": "info", "format": "json" }

level is debug, info (the default), warn or error. format is text (the default) or json, one JSON object per line, which is what you want when a log shipper reads the journal. Remember that only the level changes on reload.

debug is genuinely verbose on a busy server: a line for every HTTP request, for every completed segment and for each step of an RTMP connection. Raise it to diagnose something and then put it back — the level changes with a reload and no restart, so this costs you nothing but the log volume while it is on.

http

This is the block where a mistake is most expensive, so the defaults are deliberately timid.

"http": {
  "listen": ":8080",
  "admin_listen": "127.0.0.1:8081",
  "cors_origins": ["https://player.example.com"],
  "trusted_proxies": ["10.0.0.0/24"]
}

listen is the playback listener, default ":8080". HLS playlists, segments, DVR playback and the /health and /ready probes are served here. Anyone who can watch a stream can reach this listener.

admin_listen is the administration listener, default "127.0.0.1:8081". The /api/v1/ routes, the administration interface and these documentation pages are served here and only here — they are never available on the playback listener, whatever else you configure. The default is loopback because this API can stop a broadcast, read operational detail, delete recorded media and change the configuration file. The two addresses must differ.

Binding the administration listener to anything other than loopback takes two separate acknowledgements, and the validator refuses the configuration until it has both:

"http": {
  "listen": ":8080",
  "admin_listen": "10.0.0.5:8081",
  "admin_allow_remote": true,
  "admin_auth": {
    "users": [
      {"username": "alice",
       "password_hash": "$argon2id$v=19$m=19456,t=2,p=1$…salt…$…digest…",
       "read_only": false}
    ],
    "api_keys": [
      {"id": "deploy", "hash": "$argon2id$v=19$m=19456,t=2,p=1$…salt…$…digest…",
       "read_only": true}
    ],
    "session_ttl": "12h",
    "session_idle": "1h",
    "max_sessions": 1024
  }
}

admin_allow_remote says you meant to expose it. admin_auth gives it a credential. Neither substitutes for the other: a remote listener with only admin_allow_remote is refused, and so is one with only admin_auth. There is no default administrator and no bundled password, because a shipped credential is a credential every installation in the world shares.

Passwords are never written in this file. Both password_hash and an API key's hash hold an Argon2id or bcrypt hash, and anything else is refused — plaintext most of all. Produce one with:

ettix-stream admin hash --user alice
ettix-stream admin hash --api-key deploy --read-only

The first prompts for a password twice without echoing it and prints the JSON object to paste into users. The second generates the secret itself, prints the Authorization: Bearer <id>.<secret> line once — it is not stored and cannot be printed again — and prints the object to paste into api_keys. read_only restricts a credential to safe methods, which is the right setting for anything that only reads status. Sessions default to a 12-hour lifetime and a 1-hour idle timeout, and the session table is bounded at 1024 by default; there is deliberately no unlimited setting for a table that anyone who can reach the login endpoint can grow.

cors_origins lists the browser origins allowed to fetch playback responses. Each entry is an origin like https://player.example.com, or "*" for any origin. The default is empty, which sends no CORS headers at all — that is correct for a native player or a CDN, and wrong for a web page on another domain, which will fail with an opaque browser error.

trusted_proxies lists the CIDR blocks whose X-Forwarded-For header may be believed. A bare address is accepted and treated as a single host. The default is empty, and empty means the header is ignored entirely and the client is always the peer that connected.

Get this one wrong in the unsafe direction and every IP-based rule in the product stops working. Behind a CDN or reverse proxy the peer is the proxy, so IP allow-lists and IP-bound tokens need this list to be correct. But if the server believes the header from anybody, any client can declare its own address and walk straight through those rules. That is why an entry covering everything — 0.0.0.0/0 or ::/0 — is refused outright rather than accepted as a working configuration. List every hop that can reach this server, including the load balancer in front of the CDN. Listing too few makes IP rules see the proxy address, which is visible and fixable; listing too many hands the decision to the client, which is neither.

The remaining http fields are timeouts and limits with defaults that suit a media server: read_header_timeout 10s, idle_timeout 60s, write_timeout 30s (the deadline for sending one response, so it must exceed the time a slow but legitimate viewer needs for a single segment), and max_connections 10000 per listener. There is no unlimited setting for connections.

rtmp

"rtmp": { "listen": ":1935" }

The RTMP ingest listener, default ":1935", which is the port encoders expect. It must differ from both HTTP listeners. The other fields rarely need changing: chunk_size 4096 (128–65536), max_message_size "16MB" (64KB–64MB, and a larger declared message closes the connection), max_connections 1000, handshake_timeout 10s, read_timeout 30s — which is how long a silent publisher is kept before it is disconnected — and write_timeout 10s. All three timeouts must be at least 1s.

This is the ingest listener for the whole server. Which stream a publisher lands on, and what credential it must present, is configured per stream.

hls

"hls": { "cache_max_bytes": "512MB" }

One global setting: the RAM budget for completed segments across every stream, default "512MB", minimum "16MB". When the budget is exceeded, the oldest segments that are outside their stream's live playlist window are evicted first; segments still inside a live window are never evicted, because dropping one would break playback for everyone watching. If the live windows alone exceed the budget the server reports that in its status rather than hiding a configuration error by dropping media, so size this against the number of streams times cache_segments times your segment size. Per-stream HLS settings — segment duration, playlist length — live inside each stream, not here.

streams[] with an RTMP source

Each entry describes one channel. id is required and must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$; it appears in every URL and API path for the stream, and must be unique. name defaults to the id, and enabled defaults to true — a disabled stream refuses publishers and playback but keeps all of its configuration.

{
  "id": "channel1",
  "name": "Channel 1",
  "enabled": true,
  "source": {
    "type": "rtmp_push",
    "rtmp": {
      "app": "live",
      "stream_key": "wZ4pEr6nQ2tKcXm8Ld0vBs1y",
      "replace_policy": "reject",
      "min_replace_interval": "2s"
    }
  },
  "hls": {
    "segment_duration": "4s",
    "playlist_segments": 6,
    "cache_segments": 12,
    "program_date_time": true
  }
}

With this configuration the encoder publishes to rtmp://host:1935/live/channel1?key=wZ4pEr6nQ2tKcXm8Ld0vBs1y and viewers play http://host:8080/hls/channel1/index.m3u8.

app is the RTMP application name, default live. stream_key is the publisher's credential, at least 8 characters, presented as the ?key= query parameter. It is required unless you set allow_anonymous (which accepts any publisher and is for isolated networks only) or configure publisher credentials for the stream. The two are mutually exclusive: a stream that has both a key and allow_anonymous is refused, because there is no reading of that pair that is not a mistake.

replace_policy decides what happens when a second publisher arrives while one is live. reject, the default, refuses the newcomer and protects the broadcast that is already on air. replace disconnects the existing publisher and takes over, which is what you want when an encoder that crashed reconnects with the old connection still half-open. min_replace_interval, default 2s, is the shortest gap between two takeovers: inside the window replace behaves as reject. That default exists because two encoders sharing one credential, both set to reconnect, will otherwise take the stream from each other as fast as TCP allows, and every takeover flushes a partial segment and inserts a discontinuity in the playlist. The first publisher on an idle stream is never a replacement and is never delayed.

max_publishers may only be 0 (which selects 1) or 1. A stream has one source and one HLS timeline, so a second concurrent publisher has nowhere to go; a larger number is refused rather than accepted and quietly ignored.

The HLS fields control the live window. segment_duration is a target, default 4s, allowed 1s30s; actual cuts follow keyframes, so the encoder's keyframe interval decides how close you get. playlist_segments is how many segments the live playlist offers, default 6, allowed 3–60 — this is the main lever on latency, and 3 is the floor the HLS specification sets. cache_segments is how many are kept in memory, default 12, and must be at least playlist_segments so that a viewer slightly behind the edge still finds what the playlist advertises. program_date_time defaults to true.

streams[] with an SRT source

An srt_pull stream is the opposite direction of travel: this server dials out to remote SRT listeners and pulls MPEG-TS from them. Sources are prioritised and the server fails over between them.

{
  "id": "feed1",
  "name": "Feed 1",
  "source": {
    "type": "srt_pull",
    "srt": {
      "sources": [
        {"id": "primary", "host": "203.0.113.10", "port": 9000,
         "priority": 1, "latency": "200ms",
         "stream_id": "publish/feed1", "passphrase": "a-long-passphrase"},
        {"id": "backup", "host": "203.0.113.20", "port": 9000,
         "priority": 2, "latency": "200ms"}
      ],
      "reconnect": {"delay": "1s", "max_delay": "30s", "multiplier": 2.0},
      "failover": {
        "mode": "return_to_primary",
        "unhealthy_after": "5s",
        "stability_period": "30s",
        "warm_standby": false
      }
    }
  },
  "hls": { "segment_duration": "4s", "playlist_segments": 6 }
}

At least one source is required and at most eight are allowed. Each needs an id (same grammar as a stream id, unique within the stream), a host and a port. Two sources may not share the same host, port and stream_id, since that is one source written twice rather than two paths.

latency is the SRT receiver buffer, default 120ms, allowed 20ms8s. It has to cover the round-trip time of the path plus room to retransmit, so a transcontinental feed needs considerably more than the default; too small and the stream drops packets that could have been recovered. stream_id is the SRT stream identifier the remote listener expects. It is treated as a secret and never logged or returned by the API, because it commonly carries an access token. passphrase turns on SRT encryption and must be 10–79 bytes; pbkeylen is 16, 24 or 32 and defaults to 16 when a passphrase is set, and must be absent otherwise.

priority orders the sources — lower is preferred — and defaults to the position in the list. Priorities must be unique within the stream. failover.unhealthy_after (default 5s) is how long the active source may be unhealthy before the server switches, and stability_period (default 30s) is how long a source must be continuously healthy before it is eligible again. That second one is the anti-flap control: a source that fails every minute would otherwise be reinstated the instant it came back, and every switch is a discontinuity for viewers. mode is return_to_primary (the default, which goes back to the highest-priority source once it is stable) or stay. warm_standby keeps every source connected for a faster switch, at the cost of bandwidth and memory for packets that are discarded.

An srt_pull stream must not carry a publisher block, and the validator says so. Nobody connects to this server for such a stream — it dials out — so ingest credentials could never be presented. The credentials for the remote end are the stream_id and passphrase on each source.

dvr

Recording is configured per stream. The DVR page covers the on-disk format and retention behaviour; this is the shape of the block.

"dvr": {
  "enabled": true,
  "mode": "always",
  "destinations": [
    {"id": "fast", "path": "/mnt/dvr1",
     "max_age": "48h", "min_free_percent": 10, "sync": "interval"},
    {"id": "archive", "path": "/mnt/dvr2",
     "max_bytes": "4TB", "retention_interval": "60s"}
  ],
  "export": { "enabled": true, "max_range": "2h" }
}

enabled turns recording on and mode decides when: always (the default), schedule, api or off. In schedule mode at least one window is required, each with start and end as "HH:MM" in the server's local time and an optional list of days from mon to sun; an end earlier than the start wraps past midnight.

Each destination needs a unique id and an absolute path, and at most eight are allowed per stream. Give each destination its own filesystem: free-space retention measures the medium, so two destinations sharing one make that rule fight itself, and two destinations of the same stream sharing a root are refused. The retention rules — max_age, max_bytes, min_free_percent, min_free_bytes — are all optional, all combinable, and all disabled at 0. Destinations are independent: one failing never affects another and never stops live HLS.

sync selects durability. interval, the default, runs a background syncer every sync_interval (default 1s) and survives a process crash unharmed, risking only the last second on sudden power loss. segment costs an fdatasync per segment and is the right choice on a host that can lose power without a battery-backed write cache. none leaves it to the operating system.

export.enabled offers MP4 download of a recorded range and is off by default, because one request produces one file from an arbitrarily long span. max_range caps that span, default 4h and never more than 24h; without a cap a single request can read the whole archive, which is a cheap way to saturate the disks you are recording to.

The hardened systemd unit sets ProtectSystem=strict, which makes the filesystem read-only except for the paths listed in ReadWritePaths. A DVR root that is not listed there fails at the first write with a permission error that looks nothing like a configuration mistake. The installer fills the list in from ettix-stream config check --dvr-roots. If you add a destination later, add its path to the unit and run systemctl daemon-reload.

license

The whole block may be absent, and absent means the defaults. It never means "no licence required": every field says where the licensing authority is, which keys may sign for it, or how eagerly to renew. There is deliberately no switch that turns licensing off, because a product whose licensing can be disabled by editing a JSON file has none.

"license": {
  "authority_url": "https://api.ettix.com",
  "product": "ettix-stream",
  "dir": "/var/lib/ettix-stream/license",
  "identity_dir": "/var/lib/ettix-stream/identity",
  "renew_interval": "6h",
  "max_offline": "72h",
  "grace": "60s",
  "request_timeout": "20s"
}

Every value above is the default, so an installation talking to Ettix.com needs none of them. authority_url must be HTTPS — the only exception is a loopback address, for development — and must carry no credentials or query string, because the installation authenticates with its own key and ETTIX stores no reusable password. dir and identity_dir both default under server.data_dir, must be absolute, and must differ from each other: the lease is rewritten constantly and can be replaced by asking for another, while the identity directory holds the private key that is this installation.

max_offline is how long the server keeps running without reaching the authority. It defaults to 72 hours, and 72 hours is also the ceiling — a shorter window may be configured, a longer one is refused, and a lease claiming a longer life is honoured only this far. renew_interval defaults to 6 hours and must be no more than half of max_offline, so that a single failed renewal is never the one that matters. Do not shorten max_offline below an hour; the validator refuses it, because a window that short turns an ordinary restart of the authority into an outage here.

trusted_keys and authority_pins exist for a self-hosted authority. A trusted key can only be added: a key id that collides with one compiled into the binary is refused, so no configuration edit can substitute a different key for Ettix's own.

Changing anything in license requires a restart. A reload will report success and change nothing, which is why the server flags the restart explicitly rather than leaving you to compare an authority URL against a symptom.

What config check refuses, and why

The fastest way to understand the rules is to read the things the validator will not accept. Each of these is refused at load time, so the server never runs with one of them in force.

Structural

Administration exposure

Ingest credentials

Values that cannot work

If you get stuck

Two rules cover most confusion. If a change appears to do nothing, check whether it needs a restart rather than a reload. If the server will not start or reload, run config check against the same file and read the field paths it prints — the validator names the exact setting, and it lists all of them at once.

The Troubleshooting page covers symptoms; the Security page covers viewer authorization, publisher credentials, TLS and encryption, all of which are configured in blocks this page has deliberately left out.