Deployment

Run Duckle on your own cloud

You draw a pipeline on a laptop and it runs, on a schedule, on a server you own. Here is the whole path.

YOUR MACHINE YOUR SERVER Duckle Desktop draw it, try it on a sample UI PENDING Console, in a browser switch it on, then watch it Workspace pipelines and schedules a new schedule lands OFF Scheduler every 15s, takes what is due It runs on that box, unattended What it leaves behind run history and logs metrics for Prometheus alerts when it fails an audit log naming who did what DEPLOY TURN IT ON you watch it here

Three crossings, and the boundary is drawn only where something crosses it. Deploying needs the admin role and turning the schedule on needs only operator, because shipping code to a host and deciding when trusted code runs are different sizes of decision.

Two server modes, and only one of them schedules

This is the single most common surprise, so it goes first.

CommandWhat it isScheduler
duckle-runner serve The management console. Pipelines, runs, schedules, catalog, batches, audit log. This is what you deploy to operate Duckle. Yes. Cron fires in-process on a 15s tick.
duckle-runner web The full editor in a browser: the same canvas as the desktop app, for authoring on a remote box. No. Nothing fires on a timer.
duckle-runner work A worker that drains queued batch items. Run as many as you like, on as many hosts as you like. N/A. Pulls work, does not schedule it.
The published image defaults to web, not serve.

ghcr.io/slothflowlabs/duckle-web has an entrypoint of duckle-runner web. Run it unchanged and you get the editor with no scheduler, and every cron you configure will sit there and never fire. If you want scheduled pipelines, override the command to serve. Every example on this page does.

Three things that will stop you

1. It refuses to start without a credential

On 127.0.0.1 the console is open, because reaching it means already being on the machine. Bind to any other address and it will not start until you pass --token, set DUCKLE_CONSOLE_TOKEN, or create accounts. That is deliberate: a bind that cannot be authenticated should fail rather than serve and warn.

2. Your proxy terminates TLS

Duckle serves plain HTTP on 8080 and expects something in front of it to hold the certificate, which is how most self-hosted services work: an ALB, Application Gateway, a Google load balancer, or an ingress controller. There is nothing to configure in Duckle for this, and nothing that renews. Keep 8080 off the public internet.

3. The workspace must be persistent

Everything lives in the workspace directory: pipelines, runs/, logs/, state/ watermarks, batches/, and the encryption keys under .duckle/keys/. Lose it and you lose incremental state and every saved secret. It is a volume, not scratch space.

Probe /healthz, and nothing else.

GET /healthz is the one route that needs no credential. It answers ok and says nothing about the workspace, so an orchestrator can check liveness without holding a token. Every other route returns 401 without one, so pointing a probe at / marks the pod unhealthy forever.

Reference

Image and ports

ghcr.io/slothflowlabs/duckle-web:latest    # runner + DuckDB CLI + editor bundle
port 8080                                 # plain HTTP, TLS lives in your proxy
volume /workspace                         # pipelines, state, logs, keys

Environment

VariableMeaning
DUCKLE_CONSOLE_TOKENShared sign-in token. Required for any non-loopback bind unless you have created accounts.
DUCKLE_THREADSCaps engine parallelism. Unset means every core on the box, which is usually what you want.
DUCKLE_MAX_CONCURRENT_RUNSHow many pipelines run at once. Defaults to 1, so an unattended server stays predictable.
DUCKLE_TICK_INTERVALScheduler poll cadence in seconds. Default 15.
DUCKLE_TEMP_DIRSpill directory for queries larger than memory. Point it at fast local disk.
DUCKLE_DUCKDB_BINDuckDB CLI path. Already set inside the image.

Credentials

A shared token gets you running. Accounts and API keys are covered in Who can do what.

Who can do what

Start from where you actually are.

Running it on your own machine

Nothing to do. On 127.0.0.1 with no accounts the console is open, because anyone who can reach it is already sitting at the machine. Asking them for a password would guard against an attacker who has already won.

You put it on a server and it refused to start

That is the feature, not a fault. The console can run any pipeline in the workspace, and a pipeline runs shell and SQL, so reaching it equals running code on that host. A bind it cannot authenticate fails instead of serving anyone. Give it DUCKLE_CONSOLE_TOKEN and it starts.

A machine needs in

CI, a metrics scraper, or your own laptop deploying. None of them has a browser or anyone to rotate a password, so they get an API key rather than borrowing somebody's account.

How a request is decided

Session cookie a browser, 12 hours API key a machine Account token a person Nothing no credential presented Identity a name and a role 401 sign in first Enough for this route? viewer, operator or admin yes no It happens 403 refused Audit log who, what, when, and whether it was allowed

A refusal is recorded as carefully as a success, which is what makes duckle-runner audit --outcome denied worth running: it answers who is reaching for what they do not have. And a route with no entry in the permission table requires admin, so one added later is locked down rather than accidentally left open.

The three roles

The split follows what an action can destroy, not which screen it lives on. Each role includes the ones above it.

viewer Read the dashboard, run history, logs, schedules and the catalog. Changes nothing. The right role for a dashboard on a wall, or for a metrics scraper.
operator Everything a viewer can, plus run a pipeline and change when it runs. This is the role that turns a deployed schedule on, which is why enabling one is a smaller decision than deploying it.
admin Everything an operator can, plus deploy a pipeline, manage connections and credentials, and read the audit log. Deploying lands code that runs shell and SQL on that host, so it belongs here rather than with operating what is already trusted.

Giving people and machines access

# people, each with their own so the audit log can name them
duckle-runner console add-user alice --role admin    --workspace /workspace
duckle-runner console add-user bob   --role operator --workspace /workspace

# machines: a key of their own, with its own role and an optional expiry
duckle-runner console key-add ci-deployer --role admin --expires-days 90
duckle-runner console key-list      # role, state, and when each was last used
duckle-runner console key-revoke ci-deployer

A token or key is printed once and kept only as a hash, so a lost one is replaced rather than recovered. Revoking a key takes effect on a console that is already running, not at the next restart, and revoked keys are marked rather than deleted so one that turns up in an old log can still be named. Before revoking anything, key-list answers the question that actually matters: whether it is still in use.

Where credentials live

All three in <workspace>/.duckle/console.db. A copy of that file, or a backup of the workspace, admits nobody.

CredentialKept asWhy that one
Account tokenArgon2idA person may have chosen it, so it is treated as a password.
Session idSHA-256Generated here with 256 bits of entropy. No dictionary to defend against.
API keySHA-256Same, and an integration polling every few seconds must not pay for a password hash on every call.

The browser holds a random session id, never the credential. Its cookie is:

Upgrading from an older release carries console-users.json into the database on first start and renames it .migrated, so nobody is locked out and the only copy of a credential store is not destroyed.

AWS

EC2 for one box, ECS for a managed container, EKS when you already run Kubernetes

EC2, the straightforward one

A compute-optimised instance with local NVMe is the sweet spot: the engine is parallel, so cores turn directly into throughput, and spill wants fast disk. c7i.4xlarge or m7i.2xlarge is a sensible start; go wider before you go clever.

User data

#!/bin/bash
set -euo pipefail

dnf install -y docker && systemctl enable --now docker
mkdir -p /srv/duckle

# Token from Secrets Manager, never baked into the instance or the image.
TOKEN=$(aws secretsmanager get-secret-value \
  --secret-id duckle/console-token --query SecretString --output text)

cat >/etc/systemd/system/duckle.service <<UNIT
[Unit]
Description=Duckle console
After=docker.service
Requires=docker.service

[Service]
Restart=always
RestartSec=5
Environment=DUCKLE_CONSOLE_TOKEN=${TOKEN}
Environment=DUCKLE_MAX_CONCURRENT_RUNS=4
ExecStartPre=-/usr/bin/docker rm -f duckle
ExecStart=/usr/bin/docker run --rm --name duckle \\
  -p 127.0.0.1:8080:8080 \\
  -v /srv/duckle:/workspace \\
  -e DUCKLE_CONSOLE_TOKEN \\
  -e DUCKLE_MAX_CONCURRENT_RUNS \\
  --entrypoint duckle-runner \\
  ghcr.io/slothflowlabs/duckle-web:latest \\
  serve --host 0.0.0.0 --port 8080 --workspace /workspace

[Install]
WantedBy=multi-user.target
UNIT

systemctl daemon-reload && systemctl enable --now duckle

Note -p 127.0.0.1:8080:8080: the container is reachable from the instance only. Put an ALB in front with an ACM certificate and a target group on 8080, and keep the security group closed to everything else.

Storage

Put /srv/duckle on a dedicated gp3 EBS volume so the workspace survives instance replacement, and snapshot it. If you attach EFS instead, read the note on network filesystems under Scaling first.

ECS on Fargate

Managed containers with no instance to patch. Fargate has no local disk worth using, so the workspace goes on EFS.

{
  "family": "duckle",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "4096", "memory": "16384",
  "volumes": [{
    "name": "workspace",
    "efsVolumeConfiguration": { "fileSystemId": "fs-0abc123", "transitEncryption": "ENABLED" }
  }],
  "containerDefinitions": [{
    "name": "duckle",
    "image": "ghcr.io/slothflowlabs/duckle-web:latest",
    "entryPoint": ["duckle-runner"],
    "command": ["serve", "--host", "0.0.0.0", "--port", "8080", "--workspace", "/workspace"],
    "portMappings": [{ "containerPort": 8080 }],
    "mountPoints": [{ "sourceVolume": "workspace", "containerPath": "/workspace" }],
    "secrets": [{
      "name": "DUCKLE_CONSOLE_TOKEN",
      "valueFrom": "arn:aws:secretsmanager:eu-west-1:111122223333:secret:duckle/console-token"
    }]
  }]
}

Keep the service at desired count 1. See Scaling for why, and for how to add capacity instead.

EKS

A StatefulSet rather than a Deployment, because the workspace is state and the identity is stable.

apiVersion: v1
kind: Secret
metadata: { name: duckle-console, namespace: data }
stringData:
  token: "REPLACE_ME"        # or use External Secrets / IRSA + Secrets Manager
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: duckle, namespace: data }
spec:
  serviceName: duckle
  replicas: 1                # one scheduler. See Scaling.
  selector: { matchLabels: { app: duckle } }
  template:
    metadata: { labels: { app: duckle } }
    spec:
      containers:
        - name: duckle
          image: ghcr.io/slothflowlabs/duckle-web:latest
          command: ["duckle-runner"]
          args: ["serve", "--host", "0.0.0.0", "--port", "8080", "--workspace", "/workspace"]
          ports: [{ containerPort: 8080 }]
          env:
            - name: DUCKLE_CONSOLE_TOKEN
              valueFrom: { secretKeyRef: { name: duckle-console, key: token } }
            - name: DUCKLE_MAX_CONCURRENT_RUNS
              value: "4"
            - name: DUCKLE_TEMP_DIR
              value: /spill
          resources:
            requests: { cpu: "4", memory: 16Gi }
            limits:   { cpu: "8", memory: 32Gi }
          volumeMounts:
            - { name: workspace, mountPath: /workspace }
            - { name: spill,     mountPath: /spill }
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
            initialDelaySeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
            initialDelaySeconds: 20
      volumes:
        - name: spill
          emptyDir: { sizeLimit: 100Gi }
  volumeClaimTemplates:
    - metadata: { name: workspace }
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: gp3
        resources: { requests: { storage: 100Gi } }
---
apiVersion: v1
kind: Service
metadata: { name: duckle, namespace: data }
spec:
  selector: { app: duckle }
  ports: [{ port: 80, targetPort: 8080 }]

Front it with an ingress that terminates TLS. DUCKLE_TEMP_DIR pointed at an emptyDir keeps spill off the persistent volume, where it would otherwise compete with your data for space.

Azure

A VM, Container Apps for managed, AKS when you already run Kubernetes

Virtual Machine

Same shape as EC2. Standard_D8s_v5 or an F-series if the work is compute-heavy. Attach a Premium SSD for the workspace and use the ephemeral temp disk for spill.

# Token from Key Vault, using the VM's managed identity.
TOKEN=$(az keyvault secret show --vault-name my-vault \
  --name duckle-console-token --query value -o tsv)

docker run -d --restart=always --name duckle \
  -p 127.0.0.1:8080:8080 \
  -v /srv/duckle:/workspace \
  -v /mnt/spill:/spill \
  -e DUCKLE_CONSOLE_TOKEN="$TOKEN" \
  -e DUCKLE_TEMP_DIR=/spill \
  --entrypoint duckle-runner \
  ghcr.io/slothflowlabs/duckle-web:latest \
  serve --host 0.0.0.0 --port 8080 --workspace /workspace

Publish it through Application Gateway or an Azure Front Door origin for TLS.

Container Apps

Set minimum replicas to 1.

Container Apps scales to zero by default. The scheduler runs inside the process, so a scaled-to-zero app fires nothing and wakes only on an HTTP request. Pin minReplicas: 1 and maxReplicas: 1, or your crons quietly stop.

az containerapp create \
  --name duckle --resource-group data --environment data-env \
  --image ghcr.io/slothflowlabs/duckle-web:latest \
  --min-replicas 1 --max-replicas 1 \
  --cpu 4 --memory 16Gi \
  --target-port 8080 --ingress external \
  --secrets console-token=keyvaultref:https://my-vault.vault.azure.net/secrets/duckle-console-token,identityref:system \
  --env-vars DUCKLE_CONSOLE_TOKEN=secretref:console-token DUCKLE_MAX_CONCURRENT_RUNS=4 \
  --command duckle-runner \
  --args serve --host 0.0.0.0 --port 8080 --workspace /workspace

# Mount an Azure Files share at /workspace so state survives a revision.
az containerapp update --name duckle --resource-group data \
  --yaml volume-mount.yaml

AKS

The EKS manifest above works unchanged. Swap the storage class:

storageClassName: managed-csi-premium     # instead of gp3

Use Workload Identity with the Secrets Store CSI driver to bring DUCKLE_CONSOLE_TOKEN in from Key Vault rather than a Kubernetes Secret.

Google Cloud

Compute Engine, GKE, and a warning about Cloud Run

Compute Engine

c3-standard-8 upward, with a Local SSD for spill. A container-optimised image keeps the host minimal.

gcloud compute instances create-with-container duckle \
  --machine-type=c3-standard-8 \
  --container-image=ghcr.io/slothflowlabs/duckle-web:latest \
  --container-command=duckle-runner \
  --container-arg=serve --container-arg=--host --container-arg=0.0.0.0 \
  --container-arg=--port --container-arg=8080 \
  --container-arg=--workspace --container-arg=/workspace \
  --container-mount-disk=mount-path=/workspace,name=duckle-ws \
  --container-env=DUCKLE_MAX_CONCURRENT_RUNS=4 \
  --disk=name=duckle-ws,auto-delete=no \
  --no-address

Read the token from Secret Manager on boot rather than passing it on the command line, where it would land in instance metadata.

Cloud Run

Cloud Run is the wrong home for the scheduler.

Instances are reclaimed when idle and CPU is throttled between requests, so an in-process cron will not fire reliably whatever minimum instance count you set. Invert it instead: have Cloud Scheduler trigger a Cloud Run Job running duckle-runner --pipeline p.json, which exits with a real status code and NDJSON logs. For the console and its scheduler, use Compute Engine or GKE.

GKE

The EKS manifest works as-is. Swap the storage class:

storageClassName: premium-rwo             # instead of gp3

Bind the pod's service account to a Google service account with Workload Identity so the pipelines themselves can reach BigQuery or GCS without a key file.

Scaling

Duckle scales up before it scales out, and that goes a long way: the engine is parallel and uses every core you give it, so a larger instance is a faster pipeline with no change to the pipeline.

Run one console replica, not three.

The scheduler lives in the serve process and claims each due schedule so nothing else takes it. That claim relies on the filesystem behaving, which local disk does and some network filesystems do not. Two replicas over an NFS mount without working lock support means both believe they won the claim and every schedule runs twice. Keep replicas: 1 and add capacity the way below.

Adding capacity properly

You wantDo this
A single pipeline to finish fasterA bigger instance. The engine takes all cores by default.
More pipelines at the same timeRaise DUCKLE_MAX_CONCURRENT_RUNS on the console.
A wide fan-out spread over machinesQueue the batch, then run duckle-runner work on as many hosts as you like. Each worker claims items under a lock, so nothing runs twice.
To process more than one machine can holdTurn on pushdown. The query runs verbatim inside the source database and Duckle keeps the scheduling, lineage, data quality and alerting.

Workers as a Kubernetes Deployment, which can scale freely because they pull work rather than schedule it:

apiVersion: apps/v1
kind: Deployment
metadata: { name: duckle-workers, namespace: data }
spec:
  replicas: 6                # scale this freely
  selector: { matchLabels: { app: duckle-worker } }
  template:
    metadata: { labels: { app: duckle-worker } }
    spec:
      containers:
        - name: worker
          image: ghcr.io/slothflowlabs/duckle-web:latest
          command: ["duckle-runner"]
          args: ["work", "--workspace", "/workspace"]
          volumeMounts: [{ name: workspace, mountPath: /workspace }]
      volumes:
        - name: workspace
          persistentVolumeClaim: { claimName: duckle-shared }
If workers share a network filesystem, prove the locks work first.

Workers coordinate with OS advisory locks. On a filesystem that silently ignores them, every worker takes every item and the whole batch runs as many times as you have workers. duckle-runner work refuses to start when it detects this, and you can check deliberately with --check. Do that once per cluster before trusting a fan-out.

The one thing Duckle does not do is split a single query across a cluster. When a job needs that, push it down into the system that has it.

Before you call it production

Give people their own accounts

A shared token is fine on day one, but the audit log can only name a person if the person has an account. console add-user with viewer, operator or admin.

Terminate TLS in front

ALB, Application Gateway, a Google load balancer or an ingress controller. Port 8080 is plain HTTP and should never face the internet.

Back up the workspace

Snapshot the volume. It carries incremental watermarks and the encryption keys under .duckle/keys/: losing those means full reloads and re-entered secrets.

Make the proxy send X-Forwarded-Proto

The session cookie is marked Secure only when that header says the browser is on https, because always setting it would stop the cookie being stored during plain-HTTP local use. Most ingress controllers and load balancers send it by default; confirm yours does, or sessions travel without the flag.

Sessions last 12 hours

They are kept in .duckle/console-sessions.json as a hash of the id, so a restart or a rolling deploy does not sign everyone out and a copy of the workspace admits nobody. Expired ones are dropped at startup and at each sign-in. Back the workspace up, but keep that file out of version control.

Keep secrets out of the image

Secrets Manager, Key Vault or Secret Manager, injected at run time. Never bake a token into a layer and never commit .duckle/keys/.

Put the workspace in git

Pipelines are plain files. Review them in a pull request and roll them back like code, so a pipeline outlives whoever wrote it.

Point spill at fast local disk

DUCKLE_TEMP_DIR on NVMe or an emptyDir, not on the persistent volume where it competes with your data for space.

Something here not match what you hit? Open an issue or ask in Discord. Deployment notes age faster than code, and we would rather hear about it.