You draw a pipeline on a laptop and it runs, on a schedule, on a server you own. Here is the whole path.
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.
This is the single most common surprise, so it goes first.
| Command | What it is | Scheduler |
|---|---|---|
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. |
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.
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.
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.
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.
/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.
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
| Variable | Meaning |
|---|---|
DUCKLE_CONSOLE_TOKEN | Shared sign-in token. Required for any non-loopback bind unless you have created accounts. |
DUCKLE_THREADS | Caps engine parallelism. Unset means every core on the box, which is usually what you want. |
DUCKLE_MAX_CONCURRENT_RUNS | How many pipelines run at once. Defaults to 1, so an unattended server stays predictable. |
DUCKLE_TICK_INTERVAL | Scheduler poll cadence in seconds. Default 15. |
DUCKLE_TEMP_DIR | Spill directory for queries larger than memory. Point it at fast local disk. |
DUCKLE_DUCKDB_BIN | DuckDB CLI path. Already set inside the image. |
A shared token gets you running. Accounts and API keys are covered in Who can do what.
Start from where you actually are.
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.
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.
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.
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 split follows what an action can destroy, not which screen it lives on. Each role includes the ones above it.
# 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.
All three in <workspace>/.duckle/console.db. A copy of that file, or a backup of the workspace, admits nobody.
| Credential | Kept as | Why that one |
|---|---|---|
| Account token | Argon2id | A person may have chosen it, so it is treated as a password. |
| Session id | SHA-256 | Generated here with 256 bits of entropy. No dictionary to defend against. |
| API key | SHA-256 | Same, 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:
HttpOnly so page scripts cannot read itSameSite=Strict so another site cannot ride itSecure whenever your proxy reports the browser is on httpsUpgrading 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.
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.
#!/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.
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.
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.
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.
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 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
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.
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.
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.
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.
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.
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.
| You want | Do this |
|---|---|
| A single pipeline to finish faster | A bigger instance. The engine takes all cores by default. |
| More pipelines at the same time | Raise DUCKLE_MAX_CONCURRENT_RUNS on the console. |
| A wide fan-out spread over machines | Queue 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 hold | Turn 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 }
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.
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.
ALB, Application Gateway, a Google load balancer or an ingress controller. Port 8080 is plain HTTP and should never face the internet.
Snapshot the volume. It carries incremental watermarks and the encryption keys under .duckle/keys/: losing those means full reloads and re-entered secrets.
X-Forwarded-ProtoThe 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.
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.
Secrets Manager, Key Vault or Secret Manager, injected at run time. Never bake a token into a layer and never commit .duckle/keys/.
Pipelines are plain files. Review them in a pull request and roll them back like code, so a pipeline outlives whoever wrote it.
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.