Configuration

Configuration Reference

Trellis is configured via an HJSON file (JSON with comments and relaxed syntax).

Config File Location

Trellis searches for configuration in this order:

  1. Path specified with -config flag
  2. trellis.hjson in current directory
  3. trellis.json in current directory

Complete Example

{
  version: "1.0"

  project: {
    name: "myapp"
    description: "My Application Development Environment"
  }

  server: {
    port: 1234
    host: "127.0.0.1"
  }

  // Reverse proxy (mirrors production routing)
  proxy: [
    {
      listen: ":443"
      tls_tailscale: true
      routes: [
        { path_regexp: "^/api/.+", upstream: "localhost:3001" }
        { upstream: "localhost:3000" }
      ]
    }
  ]

  // Worktree configuration
  worktree: {
    discovery: {
      mode: "git"
    }
    repo_dir: "/Users/dev/src/myapp"
    create_dir: "/Users/dev/src"
    binaries: {
      path: "/Users/dev/bin/{{if .Worktree.Name}}{{.Worktree.Name}}{{else}}myapp{{end}}"
    }
    lifecycle: {
      on_create: [
        { name: "npm-install", command: ["npm", "install"], timeout: "5m" }
        { name: "build", command: ["make", "build"], timeout: "10m" }
      ]
    }
  }

  watch: {
    debounce: "500ms"
  }

  terminal: {
    backend: "tmux"
    tmux: {
      history_limit: 50000
      shell: "/bin/zsh"
    }
    shortcuts: [
      { key: "cmd+l", window: "~prod-logs" }
    ]
    remote_windows: [
      { name: "prod (1)", ssh_host: "prod01", tmux_session: "main" }
      { name: "prod (2)", ssh_host: "prod02", tmux_session: "main" }
      { name: "db01", command: ["ssh", "-t", "db01", "screen", "-dR", "db"] }
    ]
    links: [
      { name: "admin", url: "http://localhost:8080/" }
      { name: "docs", url: "https://docs.example.com/" }
    ]
    vscode: {
      binary: "code-server"
      port: 8443
    }
  }

  crashes: {
    reports_dir: ".trellis/crashes"
    max_age: "7d"
    max_count: 100
  }

  cases: {
    dir: "trellis/cases"
  }

  trace: {
    reports_dir: "traces"
    max_age: "7d"
  }

  logging_defaults: {
    parser: {
      type: "json"
      timestamp: "time"
      level: "level"
      id: "trace_id"
      stack: "stack"
    }
    derive: {
      ts_short: {
        from: "time"
        op: "timefmt"
        args: { format: "15:04:05.000" }
      }
      file_line: {
        op: "fmt"
        args: { template: "{file}:{line}" }
      }
    }
    layout: [
      { field: "ts_short", min_width: 12, max_width: 12, timestamp: true }
      { field: "level", min_width: 5 }
      { field: "file_line", max_width: 40 }
      { field: "msg", max_width: 80 }
    ]
  }

  trace_groups: [
    {
      name: "web"
      log_viewers: ["web01-logs", "web02-logs"]
    }
    {
      name: "api"
      log_viewers: ["api01-logs", "api02-logs"]
    }
  ]

  log_viewers: [
    {
      name: "prod-logs"
      source: {
        type: "ssh"
        host: "prod01"
        path: "/var/log/myapp/"
        current: "current"
        rotated_pattern: "@*s"
        decompress: "zstd -dc"
      }
    }
    {
      name: "web01-logs"
      source: {
        type: "ssh"
        host: "web01"
        path: "/var/log/web/"
        current: "current"
        rotated_pattern: "@*s"
        decompress: "zstd -dc"
      }
    }
    {
      name: "web02-logs"
      source: {
        type: "ssh"
        host: "web02"
        path: "/var/log/web/"
        current: "current"
        rotated_pattern: "@*s"
        decompress: "zstd -dc"
      }
    }
    {
      name: "api01-logs"
      source: {
        type: "ssh"
        host: "api01"
        path: "/var/log/api/"
        current: "current"
        rotated_pattern: "@*s"
        decompress: "zstd -dc"
      }
    }
    {
      name: "api02-logs"
      source: {
        type: "ssh"
        host: "api02"
        path: "/var/log/api/"
        current: "current"
        rotated_pattern: "@*s"
        decompress: "zstd -dc"
      }
    }
  ]

  log_viewer_settings: {
    idle_timeout: "5m"
    disconnect_grace: "30s"
    auto_pause_rate: 30
  }

  services: [
    // Infrastructure (external binaries)
    {
      name: "redis"
      command: ["redis-server"]
      watching: false
    }

    // Core services
    {
      name: "api"
      command: ["{{.Worktree.Binaries}}/api", "/etc/myapp/api.json"]
    }
    {
      name: "web"
      command: ["{{.Worktree.Binaries}}/web", "/etc/myapp/web.json"]
    }
    {
      name: "worker"
      command: ["{{.Worktree.Binaries}}/worker", "/etc/myapp/worker.json"]
    }
  ]

  workflows: [
    {
      id: "test"
      name: "Run All Tests"
      command: ["go", "test", "-json", "-count=1", "./..."]
      output_parser: "go_test_json"
      timeout: "10m"
    }
    {
      id: "build"
      name: "Build All"
      command: ["make", "build"]
      timeout: "10m"
      output_parser: "go"
    }
    {
      id: "db-reset"
      name: "Reset Database"
      commands: [
        ["./bin/dbutil", "reset"]
        ["./bin/dbutil", "seed"]
      ]
      confirm: true
      confirm_message: "This will delete all data. Continue?"
      restart_services: true
    }
    {
      id: "deploy"
      name: "Deploy"
      inputs: [
        { name: "environment", type: "select", label: "Environment", options: ["staging", "production"], default: "staging", required: true }
        { name: "deploy_date", type: "datepicker", label: "Deploy Date" }
        { name: "dry_run", type: "checkbox", label: "Dry run", default: false }
      ]
      confirm: true
      confirm_message: "Deploy to {{ .Inputs.environment }}?"
      command: ["./deploy.sh", "--env={{ .Inputs.environment }}", "--date={{ .Inputs.deploy_date }}", "{{ if .Inputs.dry_run }}--dry-run{{ end }}"]
    }
  ]

  ui: {
    theme: "auto"
    notifications: {
      enabled: true
      events: ["service.crashed", "workflow.finished", "notify.done", "notify.error"]
      failures_only: false
    }
  }
}

Section Reference

project

project: {
  name: "myapp"           // Project name (shown in UI)
  description: "..."      // Optional description
}

server

server: {
  host: "100.80.99.38"    // Bind address (use 0.0.0.0 for all interfaces)
  port: 1234              // Server port
  tls_tailscale: true      // Automatic Tailscale HTTPS certificates
  // Or use a static certificate pair instead:
  // tls_cert: "path"
  // tls_key: "path"
  public_url: "https://mybox.tailnet.ts.net:1234"  // External URL and TLS SNI name
  allowed_origins: [      // Extra cross-origin browser origins permitted
    "https://review.example.com"
  ]
}
Field Default Description
host "127.0.0.1" Bind address. Use "0.0.0.0" to allow remote access.
port 1234 Server port
tls_tailscale false Fetch and automatically renew HTTPS certificates through the local Tailscale daemon.
tls_cert (none) Path to TLS certificate for HTTPS
tls_key (none) Path to TLS private key
public_url (none) External URL the UI is reachable at (e.g., behind a reverse proxy). Automatically permitted as a browser origin.
allowed_origins [] Additional cross-origin browser origins permitted to call the API and open WebSockets. Loopback (localhost, 127.0.0.1, ::1) is always allowed.

When host is loopback-only the server runs in DNS-rebinding-safe mode: requests are rejected unless the Host header is loopback or appears in allowed_origins/public_url. Binding to 0.0.0.0 (or any non-loopback address) is treated as opt-in to wide network access — the Host gate is relaxed, but Origin-based CORS still blocks browser-driven cross-origin attacks. List your external hostname in public_url (or allowed_origins) so a browser loading the UI from that address gets an Origin match.

tls_tailscale and tls_cert/tls_key are mutually exclusive. With tls_tailscale: true, Trellis obtains certificates on demand from the local Tailscale daemon and renews them automatically; no certificate files are needed. Clients must connect using the machine’s *.ts.net hostname so the TLS handshake includes the correct SNI name. Set public_url to that HTTPS URL when host is an IP address or wildcard bind address.

proxy

Configures reverse proxy listeners for routing requests to backend services. Useful for mirroring production routing (e.g., Caddy/nginx) in development. WebSocket upgrades are handled automatically.

proxy: [
  {
    listen: ":1001"
    tls_tailscale: true
    routes: [
      { upstream: "localhost:1000" }
    ]
  }
  {
    listen: ":443"
    tls_tailscale: true
    routes: [
      { path_regexp: "askws", upstream: "localhost:3000" }
      { path_regexp: "^/g/.+/chatws", upstream: "localhost:3002" }
      { path_regexp: "^/api/.+", upstream: "localhost:3001" }
      { upstream: "localhost:3000" }
    ]
  }
]

Proxy listener fields:

Field Required Description
listen yes Address to bind (e.g., ":443", "0.0.0.0:8080", ":1001")
tls_tailscale no Use Tailscale daemon for automatic TLS certificates.
tls_cert no Path to TLS certificate. Supports ~ expansion.
tls_key no Path to TLS private key. Supports ~ expansion.
routes yes Ordered list of route rules. First match wins.

tls_tailscale and tls_cert/tls_key are mutually exclusive. When tls_tailscale is true, certificates are fetched automatically from the local Tailscale daemon — no cert files needed. This matches Caddy’s built-in Tailscale TLS behavior.

Route fields:

Field Required Description
path_regexp no Regex to match against request path. Omit for catch-all.
upstream yes Target address (host:port). http:// prefix is optional.

Routes are evaluated in order — the first matching route handles the request. A route without path_regexp matches all requests (catch-all). Place catch-all routes last.

Template variables ({{.Worktree.*}}) are supported in listen and upstream values.

worktree

worktree: {
  repo_dir: "."                     // Directory for git worktree discovery
  create_dir: ".."                  // Directory where new worktrees are created
  discovery: {
    mode: "git"                     // Discovery mode
  }
  binaries: {
    path: "{{.Worktree.Root}}/bin"  // Where binaries are built
  }
  lifecycle: {
    on_create: [                    // Run once when worktree is created
      { name: "setup", command: ["make", "setup"], timeout: "5m" }
    ]
    pre_activate: [                 // Run before each activation
      { name: "build", command: ["make", "build"], timeout: "2m" }
    ]
  }
}
Field Default Description
repo_dir "." (current directory) Root directory for git worktree discovery
create_dir ".." (parent directory) Directory where new worktrees are created
discovery.mode "git" Discovery mode. Currently only "git" is supported.
binaries.path "{{.Worktree.Root}}/bin" Path to compiled binaries

watch

watch: {
  debounce: "100ms"       // Wait for rapid file changes to settle
}
Field Default Description
debounce "100ms" Time to wait for rapid file changes to settle before triggering a restart

logging

Configures Trellis application logging (not service logs):

logging: {
  level: "info"           // "debug", "info", "warn", "error"
  format: "json"          // "json", "text"
}

services

services: [
  {
    // Required
    name: "service-name"
    command: "./bin/app"  // String or array

    // Optional
    args: ["-port", "8080"]       // Arguments (if command is string)
    work_dir: "{{.Worktree.Root}}" // Working directory
    env: { KEY: "value" }         // Environment variables
    watch_binary: "path"          // Binary to watch for restarts
    watch_files: ["config.yaml"]  // Additional files to watch
    enabled: true                 // Enable/disable the service
    watching: true                // Include in binary watching
    depends_on: ["postgres"]      // Services that must start first

    // Restart policy (top-level fields)
    restart_policy: "on-failure"  // "always", "on-failure", "never"
    max_restarts: 3               // Give up after N attempts
    restart_delay: "1s"           // Wait between restarts

    // Or use nested restart block for policy only
    restart: {
      policy: "on-failure"
    }

    // Graceful shutdown
    stop_signal: "SIGTERM"        // Signal to send (default: SIGTERM)
    stop_timeout: "10s"           // Wait before SIGKILL

    // Log buffer size (default: 1000)
    log_buffer_size: 10000

    // Log parsing and display
    logging: {
      parser: {
        type: "json"
        timestamp: "ts"
        level: "level"
        message: "msg"
        id: "request_id"
        stack: "stack"
      }
      // Derived fields computed from parsed fields
      derive: {
        short_time: { from: "timestamp", op: "timefmt", args: { format: "15:04:05" } }
      }
      // Column layout (overrides logging_defaults)
      layout: [
        { field: "short_time", min_width: 8 }
        { field: "level", min_width: 5 }
        { field: "message", max_width: 0 }
      ]
    }
  }
]

workflows

workflows: [
  {
    // Required
    id: "workflow-id"
    name: "Workflow Name"
    description: "Description for CLI help"  // Shown in trellis-ctl workflow list/describe
    command: ["make", "build"]    // Single command

    // Or multiple commands (run sequentially)
    commands: [
      ["make", "clean"],
      ["make", "build"]
    ]

    // Optional
    timeout: "10m"
    output_parser: "go"           // "go", "go_test_json", "generic", "html", "none"
    confirm: false                // Require confirmation
    confirm_message: "Are you sure?"
    requires_stopped: ["api"]     // Services to stop first
    restart_services: false       // Restart watched services after

    // Input parameters (prompts user before execution)
    inputs: [
      {
        name: "environment"       // Variable name for templates
        type: "select"            // "text", "select", "checkbox", or "datepicker"
        label: "Target Environment"
        description: "Target deployment environment"
        options: ["staging", "production"]
        default: "staging"
        required: true
      }
      {
        name: "version"
        type: "text"
        label: "Version Tag"
        description: "Semantic version tag"
        placeholder: "e.g., v1.2.3"
        pattern: "^v[0-9]+\\.[0-9]+\\.[0-9]+$"  // Validation pattern
      }
      {
        name: "deploy_date"
        type: "datepicker"
        label: "Deploy Date"
        description: "Scheduled deployment date"
        // default: "2026-01-15"  // Optional, defaults to today
      }
      {
        name: "dry_run"
        type: "checkbox"
        label: "Dry run (don't actually deploy)"
        description: "Preview changes without applying"
        default: false
      }
    ]
  }
]

Workflow Inputs

Workflows can define input parameters that prompt the user with a dialog before execution:

Input Type Description Fields
text Free-form text input placeholder, default, required, description, pattern, allowed_values
select Dropdown with predefined options options (array), default, required, description
checkbox Boolean toggle default (bool), description
datepicker Date selector default (YYYY-MM-DD string), required, description

Validation fields (for CLI/automation safety):

Field Description
description Description shown in trellis-ctl workflow describe output
pattern Regex pattern that values must match
allowed_values Whitelist of allowed values (rejects anything else)

Input values are available in command and confirm_message templates via {{ .Inputs.name }}:

{
  id: "deploy"
  name: "Deploy"
  inputs: [
    { name: "env", type: "select", options: ["staging", "prod"], required: true }
    { name: "scheduled_date", type: "datepicker", label: "Scheduled Date" }
    { name: "dry_run", type: "checkbox", label: "Dry run", default: false }
  ]
  confirm: true
  confirm_message: "Deploy to {{ .Inputs.env }} on {{ .Inputs.scheduled_date }}?"
  command: ["./deploy.sh", "--env={{ .Inputs.env }}", "--date={{ .Inputs.scheduled_date }}", "{{ if .Inputs.dry_run }}--dry-run{{ end }}"]
}

The datepicker defaults to today’s date if no default is specified. Date values are passed as YYYY-MM-DD strings (e.g., 2026-01-15).

terminal

terminal: {
  backend: "tmux"

  tmux: {
    history_limit: 50000
    shell: "/bin/sh"              // Default shell
  }

  remote_windows: [
    // Option 1: SSH host + tmux session (auto-builds command)
    {
      name: "admin(1)"
      ssh_host: "admin.example.com"
      tmux_session: "main"
    }
    // Option 2: Explicit command
    {
      name: "prod"
      command: ["ssh", "-t", "prod.example.com", "tmux", "attach"]
    }
  ]

  // Custom keyboard shortcuts to terminals
  shortcuts: [
    { key: "cmd+1", window: "#api" }           // Jump to service
    { key: "cmd+2", window: "~nginx-logs" }    // Jump to log viewer
    { key: "cmd+3", window: "!admin(1)" }      // Jump to remote
  ]

  vscode: {
    binary: "code-server"
    port: 8443
    user_data_dir: "~/.config/code-server"
  }

  links: [
    { name: "Grafana", url: "http://localhost:3000/" }
  ]
}

log_viewers

log_viewers: [
  {
    name: "nginx-logs"
    mode: "live"                 // "live" (default) or "explore" — see Modes below
    timezone: "America/New_York" // IANA zone the remote host writes timestamps in
                                  // (inherits logging_defaults.timezone; see Timezones below)

    source: {
      type: "ssh"                 // "file", "ssh", "command", "docker", "kubernetes"
                                  // Note: "service" sources are auto-generated for services with parsers
      host: "web01.example.com"
      path: "/var/log/nginx"      // Log directory
      current: "access.log"       // Active log file name
      rotated_pattern: "access.log.*"  // Pattern for rotated logs
      decompress: "zcat"          // Command to decompress rotated files
      follow: true                // Follow log output (default: true)
      since: "1h"                 // How far back to start
    }

    parser: {
      type: "json"                // "json", "logfmt", "regex", "syslog", "none"
      timestamp: "time"
      level: "status"
      message: "request"
      id: "request_id"
    }

    // Derived fields computed from parsed fields
    derive: {
      short_time: { from: "timestamp", op: "timefmt", args: { format: "15:04:05" } }
    }

    // Column layout (overrides logging_defaults)
    layout: [
      { field: "short_time", min_width: 8 }
      { field: "level", min_width: 5 }
      { field: "message", max_width: 0 }
    ]

    buffer: {
      max_entries: 10000          // Max entries in memory
    }
  }
]

// Global log viewer settings
log_viewer_settings: {
  idle_timeout: "5m"              // Stop viewers not accessed at all after this duration ("0" disables)
  disconnect_grace: "30s"         // Stop the tail this long after the last watcher disconnects ("0" disables)
  auto_pause_rate: 30             // Lines/sec that triggers UI auto-pause in the browser (0 disables)
}

Modes:

Mode Behavior
live (default) Opening the viewer starts tailing the source immediately and follows new entries.
explore For high-volume logs (e.g. nginx access logs). Opening the viewer does not start the tail — the server loads a static snapshot of the ~200 most recent lines read directly from the end of the file (a byte-offset backward read), and the UI opens paused with search/scrollback as the primary workflow. A Go live button in the header starts the tail and switches to streaming. Scrolling up to page back through history, and history search, work the same as in live mode.

explore mode requires a source that supports backward reads — file and ssh. For docker, kubernetes, and command sources, an explore-mode viewer falls back to starting the tail immediately but still opens paused, so the UI behaves the same even though the tail is already running underneath. mode is validated at config load; the only accepted values are "live", "explore", or unset.

Log viewer defaults:

Field Default Description
mode "live" "live" or "explore" — see Modes above
timezone trellis host’s local zone IANA name of the zone the remote host writes its log timestamps in; inherits logging_defaults.timezone — see Timezones below
source.follow true Follow log output in real-time
source.since "1h" How far back to start reading when connecting
buffer.max_entries 10000 Maximum entries to keep in memory
log_viewer_settings.idle_timeout "5m" Stop viewers that haven’t been accessed at all in this long ("0" disables)
log_viewer_settings.disconnect_grace "30s" Stop the tail this long after the last watcher (WebSocket subscriber) disconnects — e.g. when you navigate away from the page. Reconnecting within the grace period keeps the tail warm. REST API polling also counts as activity and keeps the tail alive. ("0" disables)
log_viewer_settings.auto_pause_rate 30 Lines/sec above which the UI automatically drops out of following in the browser, to avoid rendering every line of a burst (0 disables)

trace

trace: {
  reports_dir: "traces"
  max_age: "7d"
}

trace_groups: [
  {
    name: "api-flow"
    log_viewers: ["nginx-logs", "api-logs", "db-logs"]
  }
]

Auto-generated services group: When services have logging.parser configured (directly or via logging_defaults), Trellis automatically creates svc:* log viewers and a services trace group. Use trellis-ctl trace <id> services -since 1h to search across dev service logs with no additional configuration. If you define a services trace group in config, the auto-generated viewers are appended to it.

crashes

crashes: {
  reports_dir: ".trellis/crashes"
  max_age: "7d"
  max_count: 100
}

cases

cases: {
  dir: "trellis/cases"        // Cases directory relative to worktree root
}
Field Default Description
dir "trellis/cases" Directory for case storage, relative to worktree root. Archived cases are stored in a sibling -archived directory (e.g., trellis/cases-archived/).

agent

agent: {
  install_skill: true         // Install the trellis skill file for coding agents
}
Field Default Description
install_skill true Whether Trellis installs its skill file at .claude/skills/trellis/SKILL.md in the repo and each worktree (on startup and on worktree creation), teaching coding agents to use trellis-ctl. Installed copies carry a managed-by: trellis marker and are refreshed when the bundled skill changes; copies without the marker (user-edited) are never touched.

logging_defaults

logging_defaults: {
  timezone: "America/New_York"  // Default production log timezone (IANA name)
                                // for every viewer that doesn't set its own
  parser: {
    type: "json"
    timestamp: "ts"
    level: "level"
    message: "msg"
    id: "request_id"
    stack: "stack"
    file: "source"
    line: "lineno"
  }
  // Derived fields computed from parsed fields
  derive: {
    short_time: { from: "timestamp", op: "timefmt", args: { format: "15:04:05" } }
  }
  // Default column layout
  layout: [
    { field: "short_time", min_width: 8 }
    { field: "level", min_width: 5 }
    { field: "message", max_width: 0 }  // 0 = fill remaining
  ]
}

Timezones

Historical log search — scrollback paging and /trace — filters remote log files by a time window. For ssh sources, Trellis narrows the search by grepping the remote files for the hours/days in range, and it must express those in the timezone the remote host writes its timestamps in. Set that with timezone (an IANA name like America/New_York or UTC), per-viewer or as a logging_defaults.timezone default.

When timezone is unset, Trellis assumes the logs are in the trellis host’s own local timezone — correct only when trellis runs in the same zone as the production hosts. If it doesn’t (e.g. trellis on a UTC server, logs in US/Eastern), traces come back empty or with the wrong hours until you set timezone. The value also interprets log timestamps that carry no offset of their own; timestamps that already include an offset or Z are unaffected. Invalid IANA names are rejected at config load.

events

events: {
  // Event history settings
  history: {
    max_events: 10000           // Maximum events to keep in memory
    max_age: "1h"               // Maximum age of events to keep
  }

  // Webhooks to notify on events
  webhooks: [
    {
      id: "slack"
      url: "https://hooks.slack.com/services/..."
      events: ["service.crashed", "workflow.finished"]
    }
  ]
}

ui

ui: {
  theme: "auto"                   // "light", "dark", "auto"
  log_terminal: "dev"             // Default terminal window for Cmd+L jump

  terminal: {
    font_family: "Monaco, monospace"
    font_size: 14
    cursor_blink: true
  }

  notifications: {
    enabled: true
    events: ["service.crashed", "workflow.finished"]
    failures_only: true
    sound: false
  }

  editor: {
    // For remote Trellis, enables vscode-remote:// URLs
    remote_host: "devbox.example.com"
  }
}

Template Variables

Variable Description
{{.Project.Name}} Project name from config
{{.Project.Root}} Project root directory
{{.Worktree.Root}} Worktree root directory
{{.Worktree.Branch}} Current branch name
{{.Worktree.Binaries}} Configured binaries path
{{.Worktree.Name}} Worktree directory name
{{.Service.Name}} Current service name
{{.Inputs.<name>}} Workflow input value (in workflow commands/confirm_message only)

Template Functions

Function Description Example
slugify Convert to slug {{.Branch | slugify}}
replace String replace {{.Name | replace "-" "_"}}
upper Uppercase {{.Name | upper}}
lower Lowercase {{.Name | lower}}
default Default value {{.Port | default 8080}}
quote Shell quote {{.Path | quote}}