Skip to content
cronpad
Self-hosting / Installation

Installation

cronpad is distributed as a container image and as static binaries for Linux and macOS. Every method needs only a persistent local directory for cronpad’s files: the two databases and the rebuildable queue file. There is no other service to run. Run exactly one cronpad process per data directory.

# docker-compose.yml
services:
  cronpad:
    image: ghcr.io/gowthamgts/cronpad:1
    ports: ["8080:8080"]
    env_file: .env
    environment:
      CRONPAD_DATABASE__PATH: /data/cronpad.db
      CRONPAD_DATABASE__HISTORY_PATH: /data/history.db
    volumes: ["cronpad-data:/data"]   # also holds the queue, /data/cronpad-queue.db
    restart: unless-stopped

volumes:
  cronpad-data:
docker compose up -d

For a local-only demo including a SeaweedFS S3 server, bucket setup and development login, use:

docker compose --env-file deploy/demo.env -f deploy/docker-compose.yml --profile demo up --build

The demo uses fixed local credentials; replace them and configure OAuth for production. Without LITESTREAM_REPLICA_URL, cronpad runs SQLite only: fine for trying it out, but nothing is replicated. The repository’s deploy/docker-compose.yml also has optional profiles: --profile backup adds a SeaweedFS S3 server and bucket setup (set the Litestream URL and credentials, or use deploy/demo.env), and --profile observability adds an OpenTelemetry Collector, Prometheus and Grafana with a ready-made dashboard.

Docker

docker run -d --name cronpad -p 8080:8080 \
  -v cronpad-data:/data \
  -e CRONPAD_SERVER__PUBLIC_URL=https://cron.example.com \
  -e CRONPAD_AUTH__GITHUB__CLIENT_ID=… -e CRONPAD_AUTH__GITHUB__CLIENT_SECRET=… \
  -e CRONPAD_SECRETS__ENCRYPTION_KEY=… \
  ghcr.io/gowthamgts/cronpad:1

The published image is Alpine-based and includes /bin/sh and curl; enable the command sink with CRONPAD_SINKS__COMMAND__ENABLED=true, and add any other tools your jobs need in your own image.

Kubernetes

The essentials (a complete example is in deploy/kubernetes/):

apiVersion: apps/v1
kind: Deployment
metadata: {name: cronpad}
spec:
  replicas: 1                 # exactly one: SQLite has a single writer
  strategy: {type: Recreate}  # never run old and new pods at the same time
  selector: {matchLabels: {app: cronpad}}
  template:
    metadata: {labels: {app: cronpad}}
    spec:
      containers:
        - name: cronpad
          image: ghcr.io/gowthamgts/cronpad:1
          ports: [{containerPort: 8080}]
          envFrom: [{secretRef: {name: cronpad-env}}]
          volumeMounts: [{name: data, mountPath: /data}]
          livenessProbe: {httpGet: {path: /healthz, port: 8080}}
          readinessProbe: {httpGet: {path: /readyz, port: 8080}}
      terminationGracePeriodSeconds: 45   # > server.shutdown_grace
      volumes:
        - name: data
          persistentVolumeClaim: {claimName: cronpad-data}   # ReadWriteOnce, block storage

Binary

curl -fsSLO https://github.com/gowthamgts/cronpad/releases/latest/download/cronpad_linux_amd64.tar.gz
tar xzf cronpad_linux_amd64.tar.gz
./cronpad serve --config ./cronpad.toml

A systemd unit:

[Unit]
Description=cronpad
After=network-online.target

[Service]
ExecStart=/usr/local/bin/cronpad serve --config /etc/cronpad/cronpad.toml
EnvironmentFile=/etc/cronpad/env
User=cronpad
WorkingDirectory=/var/lib/cronpad
Restart=always
TimeoutStopSec=45

[Install]
WantedBy=multi-user.target

Upgrading from a Redis-based version

Earlier releases used Redis for the queue. Current releases ignore it: on the first start after upgrading, cronpad creates the queue file next to cronpad.db (cronpad-queue.db) and rebuilds it from your jobs, retries and pending manual runs, so nothing is lost. A leftover [redis] section or CRONPAD_REDIS__* variable only logs a warning; remove it, then stop the Redis service.

Behind a reverse proxy

Set server.public_url to the external URL and list the proxy’s addresses in server.trusted_proxies so client IPs are logged and rate-limited correctly. The proxy must pass through Authorization headers and allow request bodies up to 1 MiB. No WebSocket or special routing is needed: everything is plain HTTP on one port.

API-only mode

Set [ui] dashboard = false (CRONPAD_UI__DASHBOARD=false) to serve only the API, MCP server, sign-in and health endpoints from the same binary.