Skip to content

Configuration

The relay reads tapflow.config.json from the directory where it is started. Generate it by running tapflow init, then restart the relay after any changes.

Example

json
{
  "local": {
    "port": 4000,
    "dataDir": ".tapflow/data"
  },
  "relay": {
    "url": "https://your-relay-url"
  },
  "smtp": {
    "host": "smtp.example.com",
    "port": 587,
    "secure": false,
    "user": "relay@example.com",
    "pass": "password"
  },
  "webhooks": [
    { "url": "https://ci.internal/hooks/tapflow", "secretEnv": "TAPFLOW_WEBHOOK_SECRET_CI" }
  ]
}
KeyDescription
localSettings for the relay server running on this machine.
relay.urlURL of the relay to connect to. Used by tapflow agent start, tapflow admin init, tapflow status, and tapflow logs as the default — no --relay flag needed when this is set. Leave empty for local mode (ws://localhost:[local.port]).
tlsLAN HTTPS (secure context) settings, required for WebCodecs hardware decode. See the HTTPS section below.
smtpSMTP settings for sending invitation and password reset emails.
webhooksOutbound endpoints notified when a build's review status changes. Signing secrets are read from env vars named by secretEnv. See the Webhooks section below.

smtp.from defaults to tapflow <smtp.user> when smtp.user is set. Override it explicitly if you need a different sender address.

Environment variable overrides

Environment variables always take precedence over the config file — useful for server deployments and CI.

Secrets can also live in the .tapflow/data/.env file. The relay loads it first thing on start, so any variable below can come from there instead of the shell. Precedence is shell env > .env > config file. See Configuring tapflow for the file format and the one exception (TAPFLOW_DATA_DIR).

VariableConfig keyDefaultDescription
TAPFLOW_PORTlocal.port4000Server port
JWT_SECRET(auto-generated)JWT signing key (env only). If unset, a strong per-install secret is generated on first boot and persisted to the data directory.
TAPFLOW_DATA_DIRlocal.dataDir.tapflow/dataDB and uploads directory (supports relative paths)
TAPFLOW_RELAY_URLrelay.url(empty)Relay URL used as default by CLI commands
TAPFLOW_AGENT_TOKEN(empty)Token with the agent scope for remote relay authentication. The --token flag takes precedence. See Agent Setup.
TAPFLOW_TRUSTED_PROXIES(empty)Comma-separated IPs of trusted reverse proxies (e.g. 127.0.0.1,::1). Set this when the relay runs behind a same-host reverse proxy so it reads the real client IP from X-Forwarded-For instead of the proxy's address. Empty disables forwarded-header parsing.
TAPFLOW_BUILD_TTL_DAYS7Days a build is kept after its deletion is scheduled before the files and record are purged. Scheduling is a manual action — marking a build Done no longer deletes it. Set to a small value (e.g. 0.001) to verify cleanup quickly in local testing.
TAPFLOW_WS_BACKPRESSURE_BYTES1048576 (1 MB)Binary frame drop threshold per browser socket. Frames are silently dropped when the socket buffer exceeds this value.
TAPFLOW_AGENT_GRACE_MS15000 (15 s)Milliseconds a session stays alive after its agent's connection drops, waiting for that agent to come back. An agent registers about a second after its process starts, so the default covers a restart several times over; the open tab says it is waiting rather than showing a frame that has stopped updating, and the device is not offered to anyone else until the window closes. 0 disables the hold — the session ends the moment the agent's socket does, as it did before this existed. A blank, non-numeric or negative value falls back to the default and logs a warning at startup.
TAPFLOW_CLOUDFLARE_TOKEN(empty)Cloudflare API token for DNS-01 issuance when tls.dnsProvider is cloudflare.
TAPFLOW_VERCEL_TOKEN(empty)Vercel API token for DNS-01 issuance when tls.dnsProvider is vercel.
TAPFLOW_VERCEL_TEAM_ID(empty)Vercel team ID, required when the domain belongs to a team scope.
TAPFLOW_ACME_EMAIL(empty)Optional contact email for the Let's Encrypt account.
TAPFLOW_ADMIN_EMAIL(empty)Email for the first Admin account, created while the relay boots. Set it together with TAPFLOW_ADMIN_PASSWORD. Does nothing on an install that already has an owner.
TAPFLOW_ADMIN_PASSWORD(empty)Password for that account, at least 8 characters.
SMTP_HOSTsmtp.host``SMTP host
SMTP_PORTsmtp.port587SMTP port
SMTP_SECUREsmtp.securefalseEnable TLS (set to string "true")
SMTP_USERsmtp.user``SMTP username
SMTP_PASSsmtp.pass``SMTP password
SMTP_FROMsmtp.fromtapflow <smtp.user>Sender address

JWT_SECRET is optional

If JWT_SECRET is not set, the relay generates a strong per-install secret on first boot and stores it in the data directory (jwt-secret, owner-only). Set JWT_SECRET explicitly only when you need a fixed key — for example, to share one secret across multiple relay instances:

sh
openssl rand -hex 32

Put the value in .tapflow/data/.env or inject it as a shell environment variable.

Behind a reverse proxy, set TAPFLOW_TRUSTED_PROXIES

If the relay runs behind a same-host reverse proxy (nginx, Caddy) and TAPFLOW_TRUSTED_PROXIES is left unset, the proxy's loopback address makes every remote client look like localhost — and localhost is unauthenticated. Set TAPFLOW_TRUSTED_PROXIES to the proxy's address (e.g. 127.0.0.1,::1) and configure the proxy to forward X-Forwarded-For.

For proxied or tunneled deployments, also set a public URL (tunnel.publicUrl or relay.url). Otherwise the CORS/CSRF allowlist is loopback-only and the dashboard's cross-origin requests can be blocked.

Create the first Admin account in a Docker container (TAPFLOW_ADMIN_EMAIL)

Set both variables and the relay creates the first Admin account while it starts — the path for a Docker install, where neither the browser onboarding nor tapflow admin init can reach.

Normally you create that first account through the /setup page in a browser, and tapflow admin init stands in for it on a server with no browser. A container closes both doors: /setup only answers a request from loopback — the check that stops a stranger claiming a public instance first — and a container reaches the relay through its bridge gateway, while the relay-only image carries no CLI.

How it behaves:

  • It does nothing on an install that already has an owner. Your account is never replaced, restarts do not repeat it, and none of the three checks below run.
  • On an install with no owner, set both variables together. One without the other stops the relay starting.
  • The password must be at least 8 characters. A shorter one also stops it starting.
  • If the account you asked for could not be created, the relay does not start. An ownerless relay is claimable by anything that reaches loopback, so stopping is safer than serving.

Leave both unset and nothing changes.

There are two places to keep the values and they do not combine. Compose looks for what it interpolates in your shell or in the .env beside your compose file. The relay reads .tapflow/data/.env inside the volume. Different files.

In your compose file

yaml
services:
  relay:
    image: tapflow/tapflow:latest
    environment:
      - TAPFLOW_ADMIN_EMAIL=admin@yourteam.com
      - TAPFLOW_ADMIN_PASSWORD=${TAPFLOW_ADMIN_PASSWORD:?set this before starting}

${...:?} makes Compose refuse to start when the value is missing — a literal here would be copied unchanged and become a known password. Supply it as a shell environment variable, or in the .env next to your compose file.

In the relay's own .env

Leave both lines out of environment: and write them inside the volume you already mount. That keeps the password out of your compose file and your shell history.

ini
# Paste your own password after the =. Left empty, the relay does not start.
TAPFLOW_ADMIN_EMAIL=admin@yourteam.com
TAPFLOW_ADMIN_PASSWORD=

Narrow the permissions on a file you create yourself.

sh
chmod 600 .tapflow/data/.env

tapflow init creates that file with mode 0600, but the relay-only image has no CLI — so a container operator writes it under their own umask. The relay checks the mode at startup and warns when other users can read it.

text
.tapflow/data/.env is readable by other users (mode 644). Run: chmod 600 .tapflow/data/.env

It is a warning rather than a refusal.

Streaming tuning (agent)

These variables are set on the agent process (tapflow agent start / tapflow start), not the relay, and tune the video stream's LAN bandwidth ↔ fidelity trade-off. Diagnostic flags for measuring the stream (TAPFLOW_STREAM_METRICS, the ?perf=1 panel) are a contributor tool — see measurement.md.

VariableDefaultDescription
TAPFLOW_IOS_CODECh264iOS stream codec — h264 (default) or jpeg. H.264 also needs browser support; unsupported browsers fall back to JPEG automatically.
TAPFLOW_IOS_H264_BITRATE8000000iOS H.264 target bitrate (bits/s, soft cap). Lower = fewer LAN drops, more motion blockiness.
TAPFLOW_JPEG_QUALITY0.8iOS JPEG quality (0–1), JPEG path only. Lower = fewer drops, more artifacts.
TAPFLOW_MAX_SIZE(native)Downscale cap for the longest side (px), both platforms. Lower = less bandwidth and viewer decode load, lower fidelity.
TAPFLOW_IOS_MAX_SIZE / TAPFLOW_ANDROID_MAX_SIZE(native)Per-platform override of TAPFLOW_MAX_SIZE.
TAPFLOW_ANDROID_FPS30Android emulator capture frame rate (gRPC path).
TAPFLOW_ANDROID_BACKEND(auto)Force the Android backend — grpc or scrcpy. Auto-selected by device type when unset.

HTTPS (secure context)

Hardware-accelerated video decode (WebCodecs) only runs in a secure context (HTTPS). Over HTTP the dashboard falls back to software decode, so to give teammates on the LAN a smoother stream, terminate the relay over HTTPS. With tls set, the relay terminates HTTPS and WSS on the same port.

There are two issuance modes.

Auto-issue with your own DNS account (byo-api-token)

With your own domain and a DNS provider API token, the relay auto-issues and renews a Let's Encrypt certificate over DNS-01.

json
{
  "local": { "port": 4000 },
  "tls": {
    "mode": "byo-api-token",
    "domain": "tap.yourcompany.com",
    "dnsProvider": "cloudflare"
  }
}
KeyDescription
tls.modebyo-api-token (auto-issue via Let's Encrypt DNS-01) or import-cert (your own files).
tls.domainDomain the certificate is issued for. Teammates open https://[domain]:[port].
tls.dnsProvidercloudflare or vercel. The matching API token is read from the environment.
tls.publishAddressAuto-publish the domain's A record to this machine's LAN IP. Default true; set false to manage DNS yourself.
tls.addressIP to use instead of the auto-detected LAN IP, for multi-NIC or VPN overrides.

API tokens go in the .tapflow/data/.env file that tapflow init scaffolds, not in the config file. Cloudflare uses TAPFLOW_CLOUDFLARE_TOKEN and Vercel uses TAPFLOW_VERCEL_TOKEN, plus TAPFLOW_VERCEL_TEAM_ID for a team domain. The file stays out of git because .tapflow/data/ is gitignored. A value set directly in the environment takes precedence over the file. See Configuring tapflow for how the file is scaffolded and read.

When publishAddress is on, the relay publishes its LAN IP to the domain's A record on boot and refreshes it periodically, so teammates just open the domain without touching DNS.

Bring your own certificate (import-cert)

To use an internal PKI or a wildcard certificate you already hold, point to the files. You manage renewal yourself.

json
{
  "tls": {
    "mode": "import-cert",
    "certPath": "/path/to/fullchain.pem",
    "keyPath": "/path/to/privkey.pem"
  }
}
KeyDescription
tls.certPathPath to the fullchain certificate PEM.
tls.keyPathPath to the private key PEM.

At startup, tapflow advertises the first concrete DNS SAN other than localhost; when the SAN extension is absent, it uses a concrete subject CN. If DNS SANs are present but none is usable — for example, a wildcard-only certificate — it advertises localhost and prints a warning. IP-only SANs and malformed certificates also fall back to localhost, without that DNS-SAN warning. The advertised name must resolve to the relay's LAN address for teammates to use it.

Access and known limits

  • The certificate is bound to the domain, so open https://[domain]:[port]. Connecting via localhost or an IP raises a name-mismatch warning.
  • Some routers block responses where a public domain points to a private IP (DNS rebinding). Add a router exception, or map the domain to the LAN IP via local DNS.
  • On networks with WiFi client isolation, device-to-device traffic is blocked and LAN access is impossible. Use a normal home or office LAN.
  • A staging certificate (TAPFLOW_ACME_STAGING=1) is untrusted, so browsers warn. Right after switching the same domain from staging to production, the browser may cache the old certificate error — re-check in a private window or after clearing history.

Data directory

The relay creates these files in the working directory on first run:

text
your-directory/
  tapflow.config.json   ← relay configuration (run tapflow init to generate)
  .tapflow/
    data/               ← relay runtime state (gitignored)
      tapflow.db        ← SQLite database
      uploads/
        builds/         ← .app.zip and .apk files
        avatars/
        comments/
    flows/              ← committed YAML flows
    artifacts/          ← flow failure screenshots (gitignored)

To change the data directory location, set TAPFLOW_DATA_DIR or local.dataDir. Back up .tapflow/data/ to preserve all data.

Upgrading from a version that used .tapflow-data/? Nothing breaks: if your tapflow.config.json pins local.dataDir (older tapflow init wrote .tapflow-data), the relay honors it; a config-less default install keeps reading a pre-existing .tapflow-data/. To adopt the unified layout, run tapflow migrate data-dir once — it atomically renames .tapflow-data/.tapflow/data/ (no copy, no data loss), repoints local.dataDir when it pinned the old default, and updates .gitignore.

SMTP

Without SMTP, invitation emails and password reset emails will not be sent. In that case, Admins can copy and share the invite link directly.

To send invitation emails, configure smtp.host, smtp.user, and smtp.pass.

Webhooks

tapflow POSTs to registered URLs when a build's review status changes to Done or Rejected. Declare endpoints in the webhooks array; the REST API can register more at runtime. The full payload, signature verification, and firing rules are in Webhooks.

KeyDescription
webhooks[].urlDestination that receives the POST (required).
webhooks[].secretEnvName of the env var holding the HMAC signing secret. Secrets never go in config.json.
webhooks[].enabledWhether the endpoint is active. Defaults to true.

Changes to webhooks take effect after a relay restart.

Released under the MIT License.