Skip to content
Configuration

Configuration

ephemerd is configured with a single TOML file at <data-dir>/config.toml:

  • Linux / macOS: /var/lib/ephemerd/config.toml
  • Windows: C:\ProgramData\ephemerd\config.toml

Override the data directory with --data-dir:

ephemerd serve --data-dir /opt/ephemerd

Provider auto-detection

Currently only one provider can be configured. ephemerd detects the active provider based on which section has credentials set, in this order:

  1. Forgejo – if forgejo.instance_url is set
  2. Gitea – if gitea.instance_url is set
  3. GitLab – if gitlab.instance_url is set
  4. Woodpecker – if woodpecker.server_url is set
  5. GitHub – default when none of the above are configured

Complete annotated example

# =============================================================================
# ephemerd configuration
# =============================================================================

# --- GitHub Actions (default provider) ----------------------------------------
[github]
owner = "your-org"
# repos = ["repo1", "repo2"]        # optional — omit for org-level runners

# Authentication: PAT or GitHub App (choose one)
# token = "ghp_..."                  # or set GITHUB_TOKEN env var
# app_id = 12345
# installation_id = 67890
# private_key_path = "/etc/ephemerd/app.pem"

# poll_interval = "30s"             # how often to poll for queued jobs

# --- Forgejo Actions ---------------------------------------------------------
# [forgejo]
# instance_url = "https://codeberg.org"
# token = "runner-registration-token"
# owner = "your-org"                 # optional — omit for instance-level runners
# repos = ["repo1"]                  # optional — omit for all repos
# job_image = "gitea/runner-images:ubuntu-24.04"

# --- Gitea Actions -----------------------------------------------------------
# [gitea]
# instance_url = "https://gitea.example.com"
# token = "runner-registration-token"
# owner = "your-org"
# repos = ["repo1"]
# job_image = "gitea/runner-images:ubuntu-24.04"

# --- GitLab CI ----------------------------------------------------------------
# [gitlab]
# instance_url = "https://gitlab.com"
# token = "glrt-xxxxxxxxxxxxxxxxxxxx"  # runner auth token (GitLab 16+)
# tags = ["linux", "docker"]

# --- Woodpecker CI -----------------------------------------------------------
# [woodpecker]
# server_url = "woodpecker.example.com:9000"
# agent_secret = "shared-secret"

# --- Webhook delivery --------------------------------------------------------
[webhook]
# tunnel = "none"                    # "none" (polling), "localtunnel", or "ngrok"
# tunnel_url = ""                    # localtunnel: self-hosted server URL
# ngrok_authtoken = ""               # ngrok auth token (or NGROK_AUTHTOKEN env)
# secret = ""                        # HMAC secret (auto-generated if tunnel is active)
# port = 8080                        # listen port for webhook/health endpoint
# tls_cert = ""                      # TLS cert path (direct TLS, no tunnel)
# tls_key = ""                       # TLS key path

# --- Runner -------------------------------------------------------------------
[runner]
max_concurrent = 4                   # max simultaneous jobs
# extra_labels = ["gpu", "large"]    # additional labels for runner registration
# default_image = ""                 # override default container image per platform
# job_timeout = "2h"                 # max duration per job
# shutdown_timeout = "5m"            # grace period for running jobs on shutdown

# --- Linux VM (Windows/macOS hosts only) --------------------------------------
[vm.linux]
# enabled = false                    # spin up a Linux VM for cross-OS Linux jobs
# cpus = 2                           # virtual CPUs
# memory_mb = 2048                   # memory in MB
# disk_size_gb = 50                  # sparse disk size in GB

# --- macOS VM (macOS hosts only) ----------------------------------------------
[vm.macos]
# disk_image = ""                    # path to pre-installed macOS VM disk, or
#                                    # auto-pulled from Tart OCI registry
# cpus = 4                           # CPUs per VM
# memory_mb = 8192                   # memory per VM in MB
# max_concurrent = 0                 # max simultaneous macOS VMs (0 = auto-detect)

# --- Networking ---------------------------------------------------------------
[network]
# subnet = ""                        # container subnet (auto-selected if empty)
# mtu = 0                            # bridge MTU (auto-detected from host if 0)

# --- Docker-in-Docker --------------------------------------------------------
[dind]
# enabled = false                    # mount fake Docker socket into containers
# cache_prune_interval = "24h"       # how often the per-repo image cache pruner runs
# cache_max_age        = "168h"      # evict cached image records inactive longer than this (7 days)

# --- Metrics ------------------------------------------------------------------
[metrics]
# enabled = false                    # expose Prometheus /metrics endpoint
# port = 9090                        # metrics listen port
# path = "/metrics"                  # metrics endpoint path

# --- Logging ------------------------------------------------------------------
[log]
level = "info"                       # debug, info, warn, error
format = "text"                      # text or json
# log_retention = "7d"               # max age for job log files (e.g. "7d", "24h")

Section reference

[github]

GitHub Actions provider configuration. This is the default provider.

FieldTypeDefaultDescription
ownerstringrequiredGitHub organization or user name
reposstring array[]Limit to specific repos. Omit for org-level runners.
tokenstring$GITHUB_TOKENPersonal access token. Falls back to GITHUB_TOKEN env var.
app_idintegerGitHub App ID (alternative to PAT auth)
installation_idintegerGitHub App installation ID (required with app_id)
private_key_pathstringPath to GitHub App private key PEM file (required with app_id)
poll_intervalstring"30s"How often to poll for queued jobs

Authentication requires either token (or GITHUB_TOKEN env var) or all three GitHub App fields (app_id, installation_id, private_key_path).

[forgejo]

Forgejo Actions provider. Setting instance_url activates this provider.

FieldTypeDefaultDescription
instance_urlstringForgejo instance URL (e.g., https://codeberg.org)
tokenstringrequiredRunner registration token from Forgejo admin
ownerstring""Organization or user. Empty for instance-level runners.
reposstring array[]Limit to specific repos. Empty for all repos.
job_imagestring"gitea/runner-images:ubuntu-24.04"Default job execution image

[gitea]

Gitea Actions provider. Setting instance_url activates this provider.

FieldTypeDefaultDescription
instance_urlstringGitea instance URL (e.g., https://gitea.example.com)
tokenstringrequiredRunner registration token from Gitea admin
ownerstring""Organization or user. Empty for instance-level runners.
reposstring array[]Limit to specific repos. Empty for all repos.
job_imagestring"gitea/runner-images:ubuntu-24.04"Default job execution image

[gitlab]

GitLab CI provider. Setting instance_url activates this provider.

FieldTypeDefaultDescription
instance_urlstringGitLab instance URL (e.g., https://gitlab.com)
tokenstringrequiredRunner authentication token (glrt-xxx format for GitLab 16+)
tagsstring array[]Runner tags for job matching

[woodpecker]

Woodpecker CI provider. Setting server_url activates this provider.

FieldTypeDefaultDescription
server_urlstringWoodpecker server gRPC URL (e.g., woodpecker.example.com:9000)
agent_secretstringrequiredShared secret for agent authentication

[webhook]

Webhook delivery and tunnel configuration. By default, ephemerd polls for jobs. Enable a tunnel for instant webhook delivery.

FieldTypeDefaultDescription
tunnelstring"none""none" (polling), "localtunnel", or "ngrok"
tunnel_urlstring""Self-hosted localtunnel server URL
ngrok_authtokenstring""ngrok auth token (or use NGROK_AUTHTOKEN env var)
secretstringauto-generatedWebhook HMAC secret. Auto-generated when a tunnel is active.
portinteger8080Listen port for webhook and health endpoint
tls_certstring""TLS certificate path (for direct TLS without a tunnel)
tls_keystring""TLS private key path

[runner]

Job execution settings.

FieldTypeDefaultDescription
max_concurrentinteger4Maximum simultaneous jobs
extra_labelsstring array[]Additional labels for runner registration (e.g., ["gpu"])
default_imagestringplatform-specificOverride the default container image
job_timeoutstring"2h"Maximum duration per job
shutdown_timeoutstring"5m"Grace period for running jobs during shutdown

Default images when default_image is not set:

  • Linux: ghcr.io/actions/actions-runner:latest
  • Windows: mcr.microsoft.com/windows/servercore:ltsc20XX (auto-detected from host build)

VM resource planning (Windows and macOS): On Windows and macOS, max_concurrent applies to the entire ephemerd instance — Linux container jobs and native OS jobs share the same concurrency pool. All Linux jobs run inside a single VM (Hyper-V Linux VM on Windows, Virtualization.framework on macOS), so if max_concurrent = 4, that VM could be running 4 jobs simultaneously. Size the VM’s CPU and memory ([vm.linux]) accordingly, or jobs will compete for resources and slow each other down.

[vm.linux]

Linux VM for running Linux jobs on Windows or macOS hosts.

FieldTypeDefaultDescription
enabledbooleanfalseEnable the Linux VM
cpusinteger2Virtual CPUs assigned to the VM
memory_mbinteger2048Memory in MB
disk_size_gbinteger50Sparse disk size in GB

On Windows, this creates a Hyper-V Linux VM via the HCS (Host Compute Service) API, booted from an embedded kernel + initrd onto a persistent VHDX. On macOS, it uses Virtualization.framework.

[vm.macos]

macOS VM configuration for running macOS jobs (macOS hosts only). macOS jobs always run in per-job VMs – there is no toggle to disable this on darwin hosts.

FieldTypeDefaultDescription
disk_imagestring""Path to a pre-installed macOS VM disk, or auto-pulled from Tart OCI registry
cpusinteger4CPUs per VM
memory_mbinteger8192Memory per VM in MB
max_concurrentintegerauto-detectedMaximum simultaneous macOS VMs. Defaults to auto-detection from host CPU count.

[network]

Container networking configuration.

FieldTypeDefaultDescription
subnetstringauto-selectedContainer subnet CIDR. Auto-selected from a private range if empty.
mtuintegerauto-detectedBridge MTU. Auto-detected from the host’s default interface if 0.

[dind]

Docker-in-Docker support. When enabled, every job sees /var/run/docker.sock and the runner’s containerd serves a fake Docker Engine API on it. Image pulls from inside the job (e.g. kind create cluster pulling kindest/node) are mirrored into a long-lived per-repo namespace so the next job in the same repo gets a content-store hit instead of re-pulling.

FieldTypeDefaultDescription
enabledbooleanfalseMount a fake Docker socket (/var/run/docker.sock) into job containers
cache_prune_intervalduration"24h"How often the per-repo image cache pruner runs. Set to "0" to disable pruning.
cache_max_ageduration"168h" (7d)Evict cached image records whose ephemerd.io/last-accessed label is older than this. Containerd’s content GC reclaims the now-unreferenced blobs.

Per-repo image cache. Each (provider, repo) pair gets its own long-lived containerd namespace named ephemerd-dind-cache-<provider>-<sanitized-repo>. Examples:

ephemerd-dind-cache-github-ephpm_ephpm
ephemerd-dind-cache-gitea-ephpm_ephpm        ← distinct from the github one
ephemerd-dind-cache-gitlab-acme_platform_api ← nested GitLab groups OK

The cache namespace persists across jobs and across ephemerd restarts. Per-job state lives in a separate namespace (ephemerd-dind-<runner-name>) which is deleted when each job exits.

Privacy boundary. Containerd namespace isolation prevents one repo’s cached image blobs from being resolved by any other namespace. Two forges with identically-named repos (github/foo vs gitea/foo) do not share a cache. Two repos within the same forge do not share a cache. Auth credentials are scoped to the per-job namespace’s in-memory auth cache and are never copied into the long-lived cache namespace.

Pruning. Every cache_prune_interval, dind walks each ephemerd-dind-cache-* namespace and evicts Image records whose ephemerd.io/last-accessed label is older than cache_max_age. Cache namespaces left empty after eviction are removed entirely. Records pre-dating the label fall back to the record’s UpdatedAt timestamp so a deploy that introduces the cache feature doesn’t nuke pre-existing records on first prune.

Disabling caching. Setting cache_max_age = "0" disables eviction (the cache grows unbounded — useful for debugging but not recommended in production). Setting cache_prune_interval = "0" disables the pruner goroutine entirely; equivalent to “keep everything forever, even empty namespaces.”

[metrics]

Prometheus metrics endpoint.

FieldTypeDefaultDescription
enabledbooleanfalseEnable the /metrics endpoint
portinteger9090Metrics listen port
pathstring"/metrics"Metrics endpoint path

[log]

Logging configuration.

FieldTypeDefaultDescription
levelstring"info"Log level: debug, info, warn, error
formatstring"text"Log format: text or json
log_retentionstring"7d"Max age for job log files. Supports Go durations ("168h") and day shorthand ("7d").