Configuration and environment reference

Updated 2026-06-22

Last Updated: 2026-06-22 · Applies to: OpenWatch 0.2.0-rc series (Go single-binary)

This document is the field reference for how you configure the OpenWatch Go binary: the TOML file, the environment-variable overrides, and the on-disk paths that the service reads at boot.

OpenWatch is a single Go binary (/usr/bin/openwatch) that serves both the REST API and the embedded React UI over HTTPS on port 8443. It uses PostgreSQL only. There is no container runtime, no Redis, no Celery, and no separate web tier to configure. The compliance engine is Kensa (SSH-based, native YAML rules).

For end-to-end install and first-run steps, see docs/guides/INSTALLATION.md. This page documents only the configuration surface and does not repeat the install flow.

Configuration layering

The binary resolves configuration from four layers. Higher layers win:

PrecedenceLayerSource
1 (highest)CLI flags--listen, --log-level, --config
2Environment variablesOPENWATCH_<SECTION>_<KEY> (see below)
3TOML file/etc/openwatch/openwatch.toml (override with --config)
4 (lowest)Built-in defaultscompiled into the binary

The layering is defined in internal/config/load.go and internal/config/config.go. Only the environment variables listed in internal/config/load.go (envOverrides) are recognized. There is no reflection-based mapping, so an unrecognized OPENWATCH_* variable has no effect.

Run openwatch check-config to print the resolved configuration (secrets redacted) and validate it. Exit code 0 means valid, 1 means invalid.

TOML configuration

The default file lives at /etc/openwatch/openwatch.toml (mode 0640, owner root:openwatch). The package ships it with [server], [database], and [logging] populated. The [identity] section is optional in the file; when it is absent the defaults below apply, and you can set the two key paths through the TOML file or through the matching environment variables.

The full set of recognized keys, their sections, defaults, and the environment variable that overrides each one:

SectionKeyDefaultEnv override
serverlisten0.0.0.0:8443OPENWATCH_SERVER_LISTEN
servertls_cert/etc/openwatch/tls/cert.pemOPENWATCH_SERVER_TLS_CERT
servertls_key/etc/openwatch/tls/key.pemOPENWATCH_SERVER_TLS_KEY
databasedsnpostgres://openwatch@localhost/openwatch?sslmode=disableOPENWATCH_DATABASE_DSN
databasemax_connections25OPENWATCH_DATABASE_MAX_CONNECTIONS
logginglevelinfoOPENWATCH_LOGGING_LEVEL
loggingformatjsonOPENWATCH_LOGGING_FORMAT
identityjwt_private_key/etc/openwatch/keys/jwt_private.pemOPENWATCH_IDENTITY_JWT_PRIVATE_KEY
identitycredential_key_file/etc/openwatch/keys/credential.keyOPENWATCH_IDENTITY_CREDENTIAL_KEY_FILE

These are the only configuration keys the binary reads. The values are validated by internal/config/validate.go.

Example /etc/openwatch/openwatch.toml:

[server]
listen   = "0.0.0.0:8443"
tls_cert = "/etc/openwatch/tls/cert.pem"
tls_key  = "/etc/openwatch/tls/key.pem"

[database]
# Keep the password out of this file; set OPENWATCH_DATABASE_DSN in
# /etc/openwatch/secrets.env instead.
dsn             = "postgres://openwatch@localhost/openwatch?sslmode=disable"
max_connections = 25

[logging]
level  = "info"
format = "json"

[identity]
jwt_private_key     = "/etc/openwatch/keys/jwt_private.pem"
credential_key_file = "/etc/openwatch/keys/credential.key"

Environment variables

Configuration overrides

Each variable maps to exactly one TOML key (see the table above). The format is OPENWATCH_<SECTION>_<KEY>, all uppercase. The recognized set is fixed:

VariableOverridesNotes
OPENWATCH_SERVER_LISTEN[server].listenMust be host:port.
OPENWATCH_SERVER_TLS_CERT[server].tls_certPath to the TLS certificate.
OPENWATCH_SERVER_TLS_KEY[server].tls_keyPath to the TLS private key.
OPENWATCH_DATABASE_DSN[database].dsnMust parse as postgres:// or postgresql://.
OPENWATCH_DATABASE_MAX_CONNECTIONS[database].max_connectionsInteger greater than 0.
OPENWATCH_LOGGING_LEVEL[logging].levelOne of debug, info, warn, error.
OPENWATCH_LOGGING_FORMAT[logging].formatOne of json, text.
OPENWATCH_IDENTITY_JWT_PRIVATE_KEY[identity].jwt_private_keyPEM RSA private key, mode 0600.
OPENWATCH_IDENTITY_CREDENTIAL_KEY_FILE[identity].credential_key_file32-byte AES-256 key, mode 0600.

The canonical place to set the database secret is /etc/openwatch/secrets.env, which the systemd unit reads through EnvironmentFile=-/etc/openwatch/secrets.env. Keeping the DSN there keeps the password out of the world-readable TOML file:

sudo tee /etc/openwatch/secrets.env >/dev/null <<'EOF'
OPENWATCH_DATABASE_DSN=postgres://openwatch:CHANGE_ME@localhost/openwatch?sslmode=require
EOF
sudo chown root:openwatch /etc/openwatch/secrets.env
sudo chmod 0640 /etc/openwatch/secrets.env

Other environment variables read at runtime

VariableDefaultRead byPurpose
OPENWATCH_LICENSE_FILE/etc/openwatch/license.licserve, workerPath to the OpenWatch+ license file. A missing file is not fatal; the service runs at the free tier.
OPENWATCH_POLICIES_DIR/etc/openwatch/policiesserveDirectory scanned when an admin triggers a policy reload through the API.
OPENWATCH_DEV_MODEunsetserveWhen set to true, accepts unsigned policy envelopes. Development only; never set in production.

Standard PostgreSQL libpq environment variables (for example PGSSLROOTCERT) are honored by the underlying driver when present, but OpenWatch itself only reads the DSN. Prefer encoding connection options in the DSN query string (?sslmode=verify-full&...) so the configuration stays in one place.

On-disk paths

PathOwner / modePurpose
/usr/bin/openwatchroot, 0755The single binary (API + UI + CLI).
/etc/openwatch/openwatch.tomlroot:openwatch, 0640Main config file.
/etc/openwatch/secrets.envroot:openwatch, 0640OPENWATCH_DATABASE_DSN and other secrets; loaded by systemd.
/etc/openwatch/tls/cert.pemreadable by openwatchTLS server certificate.
/etc/openwatch/tls/key.pemopenwatch, 0600TLS server private key.
/etc/openwatch/keys/jwt_private.pemopenwatch, 0600RSA key that signs access and refresh JWTs.
/etc/openwatch/keys/credential.keyopenwatch, 0600AES-256 key encrypting MFA secrets and stored SSH credentials.
/etc/openwatch/license.licreadable by openwatchOptional OpenWatch+ license.
/var/lib/openwatchopenwatchService state directory (ReadWritePaths in the unit).
/var/log/openwatchopenwatchLog directory; journald remains the primary log sink.

The systemd unit (packaging/common/openwatch.service) runs the service as the openwatch user with ProtectSystem=strict and writes only to /var/lib/openwatch and /var/log/openwatch. Both [server].tls_key, [identity].jwt_private_key, and [identity].credential_key_file must be present and readable, or openwatch serve exits with an explicit error rather than falling back to ephemeral keys.

CLI subcommands

The binary's lifecycle is driven through these subcommands (cmd/openwatch/main.go). All of them honor the same configuration layering.

SubcommandPurpose
serveRun the HTTPS API + UI server. This is the default when no subcommand is given, which is what the systemd unit invokes.
workerRun the scan-job claimer/dispatcher loop against the PostgreSQL-native queue.
migrateApply pending database migrations (internal/db/migrations/) and print the resulting version.
create-adminCreate the first admin user. Requires --username and --email; reads the password from --password or stdin.
check-configPrint the resolved, secret-redacted config and validate it.

Global flags: --config <path>, --listen <host:port>, --log-level <level>, --version, -h/--help.

Validate configuration before starting the service:

sudo -u openwatch env $(cat /etc/openwatch/secrets.env | xargs) \
    openwatch check-config --config /etc/openwatch/openwatch.toml

Service control and verification

OpenWatch runs under systemd as openwatch.service:

sudo systemctl enable --now openwatch
sudo systemctl status openwatch
sudo journalctl -u openwatch -f

Logs are structured JSON on stdout/stderr, captured by journald. Boot, shutdown, and per-request events carry a correlation ID. Health check:

curl -k https://localhost:8443/api/v1/health

The API is served under /api/v1/; api/openapi.yaml is the contract source of truth. Role definitions live in docs/engineering/rbac_registry.md and internal/auth/permissions.yaml.

Operational runbooks

These are operational procedures for the single binary on systemd with PostgreSQL. All commands assume the default paths above.

Service down

The service is not responding on 8443.

  1. Check the unit state and recent logs:

    sudo systemctl status openwatch
    sudo journalctl -u openwatch -n 200 --no-pager
  2. If the service failed to start, validate the config and confirm the key files exist and are readable by the openwatch user:

    sudo -u openwatch env $(cat /etc/openwatch/secrets.env | xargs) openwatch check-config
    sudo ls -l /etc/openwatch/tls/ /etc/openwatch/keys/

    A missing or unreadable tls_key, jwt_private_key, or credential_key_file causes serve to exit immediately with an explicit error in the journal.

  3. Confirm PostgreSQL is up and the DSN is reachable:

    sudo systemctl status postgresql
    psql "$OPENWATCH_DATABASE_DSN" -c 'SELECT 1;'
  4. Restart and watch the logs:

    sudo systemctl restart openwatch
    sudo journalctl -u openwatch -f

Service down after an upgrade

If the service fails immediately after a package upgrade, a migration may be pending. Run it as the service user, then restart:

sudo -u openwatch env $(cat /etc/openwatch/secrets.env | xargs) openwatch migrate
sudo systemctl restart openwatch

Disk full

A full disk most often manifests as failed writes to /var/lib/openwatch, /var/log/openwatch, or the PostgreSQL data directory.

  1. Find what filled up:

    df -h
    sudo du -xh /var/log/openwatch /var/lib/openwatch | sort -h | tail -20
  2. journald is the primary log sink. If journald is the culprit, vacuum it:

    sudo journalctl --disk-usage
    sudo journalctl --vacuum-time=7d
  3. If PostgreSQL's volume is full, free space there (archive or drop old data per your retention policy) before restarting the database and the service.

  4. After freeing space, confirm recovery:

    sudo systemctl restart openwatch
    curl -k https://localhost:8443/api/v1/health

High CPU

  1. Identify the hot process:

    top -b -n1 | head -20
    sudo systemctl status openwatch
  2. If the openwatch process is busy, check whether scan jobs are saturating the worker. Inspect the journal for scan and queue activity:

    sudo journalctl -u openwatch --since '15 min ago' | grep -i 'scan\|queue\|worker'
  3. If PostgreSQL is the hot process, look for long-running or stuck queries:

    psql "$OPENWATCH_DATABASE_DSN" -c \
      "SELECT pid, state, now() - query_start AS runtime, left(query, 80) AS query
         FROM pg_stat_activity
        WHERE state <> 'idle' ORDER BY runtime DESC LIMIT 10;"
  4. Reduce database connection pressure with OPENWATCH_DATABASE_MAX_CONNECTIONS (or [database].max_connections) if the pool is oversized for the host, then restart the service.

Security incident

  1. Contain. Stop the service to halt all API, UI, and scan activity:

    sudo systemctl stop openwatch
  2. Preserve evidence. Export the journal and protect the audit trail before any remediation:

    sudo journalctl -u openwatch --since '24 hours ago' > /var/tmp/openwatch-incident.log

    Authentication and authorization events are emitted to the audit log and the journal. Review them for the affected window.

  3. Rotate credentials. If key material may be exposed, rotate the database password (update OPENWATCH_DATABASE_DSN in /etc/openwatch/secrets.env), and rotate the JWT signing key and credential key only with a planned procedure — replacing credential.key makes previously encrypted SSH credentials and MFA secrets unreadable, so re-enrollment is required.

  4. Verify file ownership and modes have not drifted:

    sudo ls -l /etc/openwatch /etc/openwatch/keys /etc/openwatch/tls

    secrets.env and the key files must be owner-only or root:openwatch 0640/0600.

  5. Patch and restart only after the cause is understood:

    sudo systemctl start openwatch
    sudo journalctl -u openwatch -f

Not yet implemented

The following capabilities existed in the archived Python/Docker stack but are not present in the current Go binary. Do not configure them; they have no effect.

CapabilityStatus
Prometheus /metrics endpointNot implemented. Audit counters exist internally (internal/audit/emit.go) but are not exposed over HTTP. Use journald metrics and pg_stat_* views for observability.
Redis / Celery configurationRemoved. Background jobs use a PostgreSQL-native queue (SKIP LOCKED); there is nothing to configure.
MongoDB configurationRemoved. OpenWatch is PostgreSQL-only.
Container-runtime / docker-compose variablesRemoved. The service is a native binary under systemd.
SMTP / LDAP environment variablesNot read by the binary today. Notification channels and SSO are configured through the API and database, not environment variables.
Separate CORS / ALLOWED_ORIGINS variableNot a recognized config key. The UI is served from the same origin as the API by the single binary.

Edit this page on GitHub →