Skip to content

Sample files

The example files jack ships with, rendered straight from the repo's examples/ folder. Getting started walks through adapting them.

Fetch them all:

bash
mkdir jack && cd jack
curl -LO https://raw.githubusercontent.com/roziscoding/jack/main/examples/docker-compose.yml
curl -L -o .env https://raw.githubusercontent.com/roziscoding/jack/main/examples/.env.example
mkdir config
curl -L -o config/config.jsonc https://raw.githubusercontent.com/roziscoding/jack/main/examples/config.jsonc

docker-compose.yml

The standard deployment: the jack backend plus the management UI.

yml
services:
  jack:
    # Pre-built image published to GitHub Container Registry on every push to main.
    image: ghcr.io/roziscoding/jack:main
    container_name: jack
    restart: unless-stopped
    ports:
      - '${JACK_PORT:-5225}:5225'
    environment:
      # production disables pino-pretty and emits structured JSON logs
      ENVIRONMENT: production
      LOG_LEVEL: info
      PORT: 5225
      APP_CONFIG_PATH: /config/config.jsonc
      # Enables the management API (on MANAGEMENT_PORT, default 5226) that the
      # jack-ui service talks to. The UI injects this same value as its
      # X-Management-Key, so both read one variable and always match. To run jack
      # headless, drop the jack-ui service below and this line.
      JACK_MANAGEMENT_KEY: ${JACK_MANAGEMENT_KEY:?set JACK_MANAGEMENT_KEY in a .env file next to this compose (any long random string)}
      # *arr API keys, forwarded from .env so config.jsonc can reference them
      # as { "env": "RADARR_API_KEY" } instead of inlining the secret. Add one
      # line per env-referenced secret; unused ones can stay empty.
      RADARR_API_KEY: ${RADARR_API_KEY:-}
      SONARR_API_KEY: ${SONARR_API_KEY:-}
    volumes:
      # App config. Drop your config.jsonc in a ./config dir next to this file
      # (start from the config.jsonc template in this examples/ folder).
      - ./config:/config
      # Media library. jack streams files to peers using the absolute path each
      # Radarr/Sonarr stores for the file (i.e. the path INSIDE the *arr
      # container), so jack must see those files at that SAME path. The
      # `/data/media` target below is a placeholder — change it to match your
      # *arr's media path(s), and add one mount per path if they use several
      # (e.g. /movies and /tv).
      - ${MEDIA_PATH:-./data/media}:/data/media
      # Completed downloads dir — must match `downloads.completedPath` in
      # config.jsonc. IMPORTANT: mount this SAME folder into your Radarr and
      # Sonarr containers at the SAME path (e.g. /data/torrents). jack writes
      # finished files here and *arr resolves that literal path in its own
      # filesystem to import them — if they don't match, *arr can't import.
      # (No watch folder: *arr hands grabs to jack over the qBittorrent API.)
      - ${TORRENTS_PATH:-./data/torrents}:/data/torrents
    # If Radarr / Sonarr are defined in this same compose file, wait for them to
    # be healthy before starting jack. Auto-registration only runs at startup, so
    # this makes the Torznab indexer + qBittorrent client register on first boot
    # instead of needing a jack restart. (Needs `healthcheck` blocks on those
    # services — the linuxserver.io images ship with them.)
    # depends_on:
    #   radarr: {condition: service_healthy}
    #   sonarr: {condition: service_healthy}
    # If Radarr / Sonarr run in their own compose network, attach this service
    # to it so `jack.internalUrl` / server URLs resolve by container name.
    # Otherwise reach them via host IP.
    # networks:
    #   - media

  # Management console — a Nuxt BFF that proxies jack's management API. Published
  # alongside the backend on every push to main. Open it at http://localhost:3000.
  jack-ui:
    image: ghcr.io/roziscoding/jack-ui:main
    container_name: jack-ui
    restart: unless-stopped
    depends_on:
      - jack
    ports:
      - '${JACK_UI_PORT:-3000}:3000'
    environment:
      # The UI reaches jack's management API over the compose network by service
      # name. Must match jack's MANAGEMENT_PORT (default 5226). No host port for
      # 5226 is published — the UI is the only thing that needs it.
      JACK_MANAGEMENT_API_URL: http://jack:5226
      # Inject mode: the BFF adds X-Management-Key on every call, so the browser is
      # never prompted. Same variable as jack's JACK_MANAGEMENT_KEY above, so they match.
      JACK_MANAGEMENT_KEY: ${JACK_MANAGEMENT_KEY:?set JACK_MANAGEMENT_KEY in a .env file next to this compose (any long random string)}
      # Only used in cookie-prompt mode (when JACK_MANAGEMENT_KEY is unset): seals
      # the session cookie and must be >= 32 chars. Left unset here because this
      # compose runs in inject mode, where sessions aren't used. If you switch to
      # cookie mode (remove JACK_MANAGEMENT_KEY above), uncomment this so a real
      # key is required — an empty value would break cookie sealing.
      # JACK_SESSION_KEY: ${JACK_SESSION_KEY:?set a >= 32-char random string for cookie mode}
# networks:
#   media:
#     external: true

config.jsonc

The configuration template — see the configuration reference for every key.

jsonc
{
  // Every "apiKey" below, and every value under a "headers" object, can be
  // written in THREE ways:
  //   1. a plain string:                  "apiKey": "my-secret"
  //   2. a reference to an env variable:  "apiKey": { "env": "MY_SECRET" }
  //   3. a reference to a secret file:    "apiKey": { "file": "/run/secrets/my-secret" }
  // Env/file forms keep secrets out of this file (recommended for deployments).
  // File paths must be absolute. All forms resolve to the same string.

  // Identity of THIS jack instance. Required for the Torznab indexer.
  // `internalUrl` must be reachable from your own *arr apps; peers use the
  // separate URL they configure for this instance in their `peers` list.
  "jack": {
    "internalUrl": "http://jack:5225"
    // Peers authenticate with per-peer API keys (managed in the UI's "API keys"
    // section); your Radarr/Sonarr authenticate with the managed key jack
    // registers for them automatically. See README → "API keys".
  },

  // Downloads: jack registers itself in your *arr as a qBittorrent download
  // client, so grabs are handed to jack directly (no watch folder). Finished
  // files are written here for your *arr to import. Path is *inside the container*.
  "downloads": {
    "completedPath": "/data/torrents/completed",
    // Optional download hardening knobs — values below are the defaults, so you
    // can delete any line to keep the default. (Note the comma after
    // "completedPath" above is required once any field follows it.)
    "maxConcurrentDownloads": 3,
    "maxDownloadAttempts": 13,
    "retryBaseDelayMs": 1000,
    "retryMaxDelayMs": 1800000
  },

  // Your Radarr/Sonarr servers. apiKey is the *arr API key: exactly 32 hex
  // chars (Settings -> General). Each server can be a:
  //   - "source":      its library is shared with your peers (read via /peer).
  //   - "destination": jack registers itself there as a Torznab indexer +
  //                    qBittorrent download client, and triggers imports.
  //   - both:          share your library AND search your friends', symmetric.
  // "autoregister" controls the indexer/client registration (destinations only):
  //   { "enable": true, "priority": 1 }
  // The qBittorrent client is always registered at *arr's lowest priority so real
  // torrents from your other indexers never get routed to jack (Jack grabs still
  // reach it via the indexer binding, which *arr resolves before priority).
  "servers": [
    // {
    //   "name": "Main Radarr",
    //   "type": "radarr",
    //   "url": "http://radarr:7878",
    //   "apiKey": { "env": "RADARR_API_KEY" },
    //   "headers": { "X-Forwarded-User": "jack" },
    //   "source": true,
    //   "destination": true,
    //   "autoregister": { "enable": true, "priority": 1 }
    // },
    // { "name": "Main Sonarr", "type": "sonarr", "url": "http://sonarr:8989", "apiKey": "0123456789abcdef0123456789abcdef" }
  ],

  // Other jack instances you search (peer-to-peer). Sources only.
  // Prefer https:// — the API key travels in the X-Api-Key header, so a plain
  // http:// peer exposes it in transit (jack warns at startup for http:// peers).
  "peers": [
    // {
    //   "name": "friend",
    //   "url": "https://their-jack.example.com",
    //   "apiKey": "their-shared-secret",
    //   "headers": {
    //     "CF-Access-Client-Secret": { "env": "FRIEND_CF_SECRET" },
    //     "CF-Access-Client-Id": { "file": "/run/secrets/friend_cf_client_id" }
    //   }
    // }
  ]
}

.env

Secrets and optional overrides read by the compose file. Ships as .env.example; save it as .env next to the compose file.

ini
# Copy to ".env" (same folder) and fill in.
# The real .env is gitignored — never commit your actual secrets.

# Management API key shared by the backend and UI containers.
# Generate one with: openssl rand -base64 32
JACK_MANAGEMENT_KEY=replace-with-a-long-random-string

# *arr API keys (Settings → General → API Key), referenced from config.jsonc
# as { "env": "RADARR_API_KEY" } / { "env": "SONARR_API_KEY" } so the secrets
# stay out of the config file. Unused ones can stay empty.
RADARR_API_KEY=
SONARR_API_KEY=

# Optional overrides:
# JACK_PORT=5225
# JACK_UI_PORT=3000
# MEDIA_PATH=./data/media
# TORRENTS_PATH=./data/torrents

# ── Only for compose-with-otel.yml ──────────────────────────────────────────

# OpenObserve root login (also what you use to log into the UI at :5080).
O2_USER=root@example.com
O2_PASS=change-me-please

# Basic-auth header jack sends to OpenObserve's OTLP endpoint.
# Generate it from the SAME creds above:
#   printf '%s:%s' "$O2_USER" "$O2_PASS" | base64 -w0
# then paste the output below, keeping the "Authorization=Basic%20" prefix
# (%20 is the space after "Basic"):
OTLP_AUTH=Authorization=Basic%20REPLACE_WITH_BASE64_OF_USER_COLON_PASS

# O2_PORT=5080

compose-with-otel.yml

An alternative compose that adds an OpenObserve service and wires jack's OpenTelemetry tracing to it.

yml
# jack + OpenTelemetry, all in one compose.
#
# Adds an OpenObserve instance (single image, no external DB — stores to disk)
# and points jack at it over OTLP. You get request traces AND logs, correlated,
# in one UI at http://localhost:5080.
#
# Secrets are NOT committed here — put them in a .env file next to this compose
# (copy .env.example). You need O2_PASS and OTLP_AUTH; generate OTLP_AUTH with:
#   printf 'EMAIL:PASSWORD' | base64 -w0
#   # -> OTLP_AUTH=Authorization=Basic%20<that output>   (must match O2_USER/O2_PASS)
#
#   docker compose -f compose-with-otel.yml up -d
#
# Then open http://localhost:5080 (login = O2_USER / O2_PASS) and look at the
# "Logs" and "Traces" sections, stream "default".
services:
  # Observability backend. OTLP in, UI out — everything in a single container.
  openobserve:
    image: public.ecr.aws/zinclabs/openobserve:latest
    container_name: openobserve
    restart: unless-stopped
    ports:
      - '${O2_PORT:-5080}:5080'
    environment:
      ZO_ROOT_USER_EMAIL: ${O2_USER:-root@example.com}
      # Required — set in .env (no default, so no password is baked into the repo).
      ZO_ROOT_USER_PASSWORD: ${O2_PASS:?set O2_PASS in a .env file (see .env.example)}
      ZO_DATA_DIR: /data
    volumes:
      # Traces/logs persist here across restarts.
      - ./data/openobserve:/data

  jack:
    # Pre-built image published to GitHub Container Registry on every push to main.
    image: ghcr.io/roziscoding/jack:main
    container_name: jack
    restart: unless-stopped
    depends_on:
      - openobserve
    ports:
      - '${JACK_PORT:-5225}:5225'
    environment:
      # production disables pino-pretty and emits structured JSON logs
      ENVIRONMENT: production
      # Request logs ("Request completed") are emitted at trace level; set this
      # to trace if you want them showing up in OpenObserve too.
      LOG_LEVEL: info
      PORT: 5225
      APP_CONFIG_PATH: /config/config.jsonc
      # Enables the management API (on MANAGEMENT_PORT, default 5226) that the
      # jack-ui service talks to. The UI injects this same value as its
      # X-Management-Key, so both read one variable and always match. To run jack
      # headless, drop the jack-ui service below and this line.
      JACK_MANAGEMENT_KEY: ${JACK_MANAGEMENT_KEY:?set JACK_MANAGEMENT_KEY in a .env file next to this compose (any long random string)}
      # --- OpenTelemetry ---
      # Telemetry turns on as soon as an OTLP endpoint is set. The exporter
      # appends /v1/traces and /v1/logs to this base. "default" is the org.
      OTEL_EXPORTER_OTLP_ENDPOINT: http://openobserve:5080/api/default
      OTEL_SERVICE_NAME: jack-backend
      # Required — set in .env. OpenObserve's OTLP endpoint uses basic auth:
      # base64("email:password"), which MUST match O2_USER/O2_PASS. The value
      # looks like:  Authorization=Basic%20<base64>   (%20 is the space).
      OTEL_EXPORTER_OTLP_HEADERS: ${OTLP_AUTH:?set OTLP_AUTH in a .env file (see .env.example)}
    volumes:
      # App config. Drop your config.jsonc in a ./config dir next to this file
      # (start from the config.jsonc template in this examples/ folder).
      - ./config:/config
      # Media library. jack streams files to peers using the absolute path each
      # Radarr/Sonarr stores for the file (i.e. the path INSIDE the *arr
      # container), so jack must see those files at that SAME path. The
      # `/data/media` target below is a placeholder — change it to match your
      # *arr's media path(s), and add one mount per path if they use several
      # (e.g. /movies and /tv).
      - ${MEDIA_PATH:-./data/media}:/data/media
      # Completed downloads dir — must match `downloads.completedPath` in
      # config.jsonc. IMPORTANT: mount this SAME folder into your Radarr and
      # Sonarr containers at the SAME path (e.g. /data/torrents). jack writes
      # finished files here and *arr resolves that literal path in its own
      # filesystem to import them — if they don't match, *arr can't import.
      # (No watch folder: *arr hands grabs to jack over the qBittorrent API.)
      - ${TORRENTS_PATH:-./data/torrents}:/data/torrents
    # If Radarr / Sonarr are defined in this same compose file, wait for them to
    # be healthy before starting jack. Auto-registration only runs at startup, so
    # this makes the Torznab indexer + qBittorrent client register on first boot
    # instead of needing a jack restart. (Needs `healthcheck` blocks on those
    # services — the linuxserver.io images ship with them.)
    # depends_on:
    #   radarr: {condition: service_healthy}
    #   sonarr: {condition: service_healthy}
    # If Radarr / Sonarr run in their own compose network, attach this service
    # to it so `jack.internalUrl` / server URLs resolve by container name.
    # Otherwise reach them via host IP.
    # networks:
    #   - media

  # Management console — a Nuxt BFF that proxies jack's management API. Published
  # alongside the backend on every push to main. Open it at http://localhost:3000.
  jack-ui:
    image: ghcr.io/roziscoding/jack-ui:main
    container_name: jack-ui
    restart: unless-stopped
    depends_on:
      - jack
    ports:
      - '${JACK_UI_PORT:-3000}:3000'
    environment:
      # The UI reaches jack's management API over the compose network by service
      # name. Must match jack's MANAGEMENT_PORT (default 5226). No host port for
      # 5226 is published — the UI is the only thing that needs it.
      JACK_MANAGEMENT_API_URL: http://jack:5226
      # Inject mode: the BFF adds X-Management-Key on every call, so the browser is
      # never prompted. Same variable as jack's JACK_MANAGEMENT_KEY above, so they match.
      JACK_MANAGEMENT_KEY: ${JACK_MANAGEMENT_KEY:?set JACK_MANAGEMENT_KEY in a .env file next to this compose (any long random string)}
      # Only used in cookie-prompt mode (when JACK_MANAGEMENT_KEY is unset): seals
      # the session cookie and must be >= 32 chars. Left unset here because this
      # compose runs in inject mode, where sessions aren't used. If you switch to
      # cookie mode (remove JACK_MANAGEMENT_KEY above), uncomment this so a real
      # key is required — an empty value would break cookie sealing.
      # JACK_SESSION_KEY: ${JACK_SESSION_KEY:?set a >= 32-char random string for cookie mode}
# networks:
#   media:
#     external: true

Released under the GPL-3.0 License.