Pre-alpha software. Clock Relay is under active development and is not ready for production use. APIs, config formats, and storage schemas will change without notice. Use it to explore and experiment, but expect breaking changes.
Clock Relay is a small self-hosted scheduler for infrastructure jobs. It owns schedule timing, run history, visibility, and trigger delivery; your applications or worker systems own the actual work.
The first prototype supports:
- YAML-defined schedules.
- UI-managed schedule creation, editing, deletion, and pause/resume.
- Explicit one-time, rate-based, and cron-based schedules.
- IANA timezone dropdowns for schedule timezone selection.
- HTTP webhook targets.
- Native Faktory enqueue targets.
- UI create/edit support for HTTP and Faktory jobs.
- Durable local run history with bbolt.
- A minimal web UI with a job list, run log, and dedicated add/edit screens.
- Lightweight live refresh for the job list and run log.
- Run log clearing from the UI or API.
- Configurable run log retention by record count and age.
- JSON APIs for health, schedules, and runs.
go run ./cmd/clock-relay --config clock-relay.example.yamlOpen http://localhost:9808.
Run tests with:
go test ./...For local development:
docker compose up --buildThe compose file mounts clock-relay-data at /app/data so schedules and run history survive container restarts. The app writes process logs to stdout/stderr and does not create its own application log file.
Published release images are built by GitHub Actions and pushed to GitHub Container Registry:
ghcr.io/johnnycon/clock-relay:<version>
Install a pinned release image. See GitHub Releases for the current version:
docker pull ghcr.io/johnnycon/clock-relay:0.0.1Use exact version tags for Docker Compose, Docker Swarm, Kamal, and other downstream deploys:
services:
clock-relay:
image: ghcr.io/johnnycon/clock-relay:0.0.1
ports:
- "9808:9808"
volumes:
- clock-relay-data:/app/data
command: ["clock-relay", "--config", "/app/clock-relay.yaml"]
volumes:
clock-relay-data:For Docker Swarm, use the same image reference:
services:
clock-relay:
image: ghcr.io/johnnycon/clock-relay:0.0.1
ports:
- target: 9808
published: 9808
protocol: tcp
mode: ingress
volumes:
- clock-relay-data:/app/data
command: ["clock-relay", "--config", "/app/clock-relay.yaml"]
volumes:
clock-relay-data:For quick local trials, latest points at the newest published release:
docker pull ghcr.io/johnnycon/clock-relay:latestDo not use latest in deployment config where repeatable rollbacks matter.
Release images are published from git tags. A git tag with a leading v publishes a container tag without the v. For example, to publish 0.0.1:
git tag v0.0.1
git push origin v0.0.1The resulting image tag is ghcr.io/johnnycon/clock-relay:0.0.1.
Release builds embed their version metadata:
docker run --rm ghcr.io/johnnycon/clock-relay:0.0.1 clock-relay --versionDiscovery links:
- Container package: https://github.com/Johnnycon/clock-relay/pkgs/container/clock-relay
- Release tags: https://github.com/Johnnycon/clock-relay/tags
- GitHub releases: https://github.com/Johnnycon/clock-relay/releases
For agents and automation, derive the pinned image from the newest semantic version tag:
latest_tag="$(git ls-remote --tags --refs --sort='v:refname' https://github.com/Johnnycon/clock-relay.git 'v*.*.*' | awk -F/ 'END {print $NF}')"
image="ghcr.io/johnnycon/clock-relay:${latest_tag#v}"
printf '%s\n' "$image"Clock Relay is licensed under the MIT License. See LICENSE for the project
license and THIRD_PARTY_NOTICES.md for dependency license notes.
Clock Relay has one Go binary in cmd/clock-relay. The product implementation lives under internal because the repo ships an app/container, not a public Go library API:
internal/config: YAML/API config structs, validation, and defaults.internal/model: run model and statuses.internal/store: store interface plus memory and bbolt implementations.internal/target: HTTP and native Faktory targets.internal/engine: schedule registration, manual triggers, runtime job CRUD, and run execution.internal/server: JSON API routes plus the server-rendered UI.
Persisted jobs and runs are stored in bbolt by default. YAML schedules can be used to seed a fresh store, but the bundled local configs start with no jobs. After jobs are created through the UI/API, bbolt is the source of truth.
schedules:
- name: heartbeat
description: Calls a local app endpoint every minute.
schedule_type: rate
starts_at: "2026-05-08T10:30"
timezone: UTC
rate_interval: 1
rate_unit: minutes
timeout: 10s
allow_concurrent_runs: false
target:
type: http
url: http://host.docker.internal:3000/internal/heartbeat
method: POSTClock Relay can enqueue native Faktory jobs. A successful Clock Relay run means Faktory accepted the job; Faktory and its workers own execution, retries, and failure handling after enqueue. Clock Relay records the Faktory JID in the run's structured_output.
Faktory jobs can be created and edited in the UI. Args are entered as a JSON array so workers can receive structured values. Use [] when the worker does not need arguments; Clock Relay still sends an empty Faktory args array because Faktory requires the field.
Example Faktory job types use lower snake_case names such as smoke_job, say_hello, and meal_reminder.
faktory:
- name: default
url: tcp://faktory:7419
schedules:
- name: faktory-smoke
schedule_type: cron
cron: "0 0 1 1 *"
timezone: UTC
timeout: 10s
target:
type: faktory
instance: default
queue: default
job_type: smoke_job
args:
- account_id: acct_123password_env is optional and is commonly omitted in local development. If your Faktory server requires a password, keep the password out of YAML and point Clock Relay at the environment variable that contains it. Do not embed the password in the Faktory URL; Clock Relay reads Faktory passwords from password_env.
faktory:
- name: staging
url: tcp://faktory:7419
password_env: FAKTORY_PASSWORDSee examples/faktory for a Docker Compose smoke test with a real Faktory server and worker. The bundled Faktory config starts empty; the smoke test creates its temporary faktory-smoke schedule through the API before triggering it.
The example runner intentionally uses separate Faktory worker managers for default and reminders so queue isolation and per-worker concurrency can be tested manually with jobs you create in the UI.
Runs store target results in structured_output. This is the canonical result field for both machine-readable provider details and unstructured target text.
HTTP targets store the response body under structured_output.raw:
{
"target_type": "http",
"structured_output": {
"raw": "ok",
"status_code": 200
}
}Faktory stores the enqueue details as structured fields and also includes raw for simple UI display:
{
"target_type": "faktory",
"structured_output": {
"raw": "faktory jid=abc123 queue=default job_type=smoke_job",
"provider": "faktory",
"jid": "abc123",
"queue": "default",
"job_type": "smoke_job"
}
}Jobs are persisted without a built-in count limit, but run history is bounded by run_retention:
run_retention:
max_records: 10000
max_age_days: 30Clock Relay prunes completed run records on startup and then periodically after finalized runs. Running records are preserved and may temporarily exceed the configured limits. The bbolt store maintains run indexes so routine pruning, recent-run reads, and running-run checks do not need to scan the full run history. Deleting records bounds active run data, but bbolt does not guarantee the database file immediately shrinks on disk. Clearing the run log deletes all run records.
Clock Relay writes process logs to stdout/stderr. Completed and skipped runs are also emitted as structured stdout events by default:
run_logging:
stdout: summarySupported values are off, summary, and full. summary includes run identity, status, timing, errors, and safe provider metadata such as HTTP status codes or Faktory JIDs. full also includes the complete structured_output, including raw HTTP response bodies; use it only when your deployment intentionally ships that potentially sensitive or large data to external logs.
GET /healthzGET /v1/schedulesPOST /v1/schedulesGET /v1/runs?limit=100POST /v1/runs/clearPOST /v1/schedules/{name}/runPOST /v1/schedules/{name}/pausePOST /v1/schedules/{name}/deleteGET /v1/faktory/queues?instance={name}
/lists jobs and the recent run log. Use?job={name}to filter the run log by job./schedules/newopens the add job screen./schedules/{name}/editopens the edit job screen.
The index page refreshes the Jobs and Run Log sections every few seconds while the tab is visible. This is intentionally simple polling for a single-user operational screen, not websocket/SSE infrastructure.
Clock Relay stores schedule intent explicitly with schedule_type:
once: run one time atrun_atin the selected IANAtimezone. After Clock Relay creates the run attempt, it persistscompleted_atand keeps the job visible.rate: run everyrate_intervalrate_unitfromstarts_at. Units areminutes,hours, ordays;daysmeans a fixed 24-hour interval.cron: run when a five-field cron expression matches intimezone.
Existing schedules without schedule_type are treated as cron.
The web UI has a "My timezone" control that defaults to the browser's IANA timezone when available. New jobs default to that saved timezone, including their initial date/time fields, and the selected schedule timezone is stored on each job. Config/API-created jobs default to UTC unless timezone is supplied. UI timestamps are shown in the user's selected timezone, with schedule timezone context shown when it differs.
Clock Relay starts as a trigger layer, not a complete job queue. That means it can schedule and observe work for many ecosystems: River, Faktory, Sidekiq, custom HTTP workers, shell scripts, and future agent/skill runners.
The current durable store is bbolt because it works inside one container with a mounted volume. YAML config can seed a fresh store, but the bundled local config starts empty; after that, schedule edits made through the UI are stored in bbolt. Redis is a natural next store for production deployments that need shared state, leases, and eventually multiple Clock Relay replicas.
For native queue targets, a successful Clock Relay run currently means the provider accepted the job. It does not mean the downstream worker finished the job. End-to-end worker tracking may become an SDK/decorator-style capability later, but workers should not receive Clock Relay metadata by default right now.
Likely next work includes richer run detail pages, failure integration tests, retries/backoff for trigger failures, run retention, authentication, Redis/shared-state support, and additional native providers such as River.