QuackTail Docker Compose example

May 30, 2026 · View on GitHub

Two-node Headscale + QuackTail demo on Linux: server joins the tailnet and serves Quack; client ATTACHes via tailscale_quack_forward.

Integration guide: docs/GUIDE.md · DuckLake demo: ducklake/README.md

Requires: Linux, Docker Compose v2, /dev/net/tun, outbound HTTPS.

Architecture

  quacktail-client                         quacktail-server
  ─────────────────                        ──────────────────
  CALL tailscale_up                        CALL tailscale_up
  CALL tailscale_quack_forward ──tsnet──►  quack_serve(127.0.0.1:9494)
       │                                   tailscale_serve_local(:9494)

  ATTACH 'quack:127.0.0.1:19494'

Quack HTTP uses kernel TCP. Embedded tsnet does not route that traffic. tailscale_quack_forward listens on 127.0.0.1:19494 and dials quacktail-server:9494 via tailscale_dial for each connection.

Two secrets (do not confuse):

VariableDefaultPurpose
QUACK_TAILNET_TOKENquackscale-demo-tokenQuack HTTP auth (CREATE SECRET, quack_token())
Headscale preauth keygenerated → /work/authkeyCALL tailscale_up(authkey => ...)

Run the demo

Source build is required for the DuckLake demo (attach_ducklake, tailscale_down).

git pull
git submodule update --init --recursive
cd examples
docker compose build --no-cache quacktail-server quacktail-client
docker compose run --rm --entrypoint /usr/local/bin/quacktail-verify-image.sh quacktail-client
docker compose up -d --force-recreate headscale quacktail-server
docker compose --profile test run --rm quacktail-client

Use --force-recreate on the server after script or SQL changes (otherwise the old DuckDB process keeps running).

Refresh stale /work SQL without running the client demo (one container, no DuckDB session):

docker compose run --rm -e QUACKTAIL_ROLE=bootstrap quacktail-client
docker compose --profile test run --rm quacktail-client

Do not use quacktail-client true — compose sets QUACKTAIL_ROLE=client, so that still runs the full demo.

Release binary instead of source build:

BUILD_FROM_SOURCE=0 docker compose build quacktail-server quacktail-client

Default QUACKTAIL_RELEASE_TAG is v1.0.2 (must include tailscale_quack_forward).

Expect:

✓ Demo passed — two-node QuackTail cluster is working

Expected client output

→ waiting for quacktail-server on tailnet ...
✓ quacktail-server on tailnet

QuackTail cluster demo
======================
→ join tailnet, forward, attach_ducklake, ATTACH quack:127.0.0.1:19494 ...

CALL tailscale_up(...);              → running true
CALL tailscale_quack_forward(...);  → quack:127.0.0.1:19494
CALL tailscale_ping(...);            → reachable true
FROM quack_query(...);               → probe 1
CALL attach_ducklake(...);           → lake.inventory view created
SELECT * FROM lake.inventory ...;
SELECT 'LAKE_PASSED' ...;
ATTACH 'quack:127.0.0.1:19494' AS remote (TYPE quack);
SELECT 'PASSED' ...;
SELECT 'CLIENT_DEMO_DONE' ...;
CALL tailscale_down();

✓ Demo passed — QuackTail cluster + DuckLake over tailnet

The client runs one DuckDB session (duckdb -batch -echo -f /work/client_session.sql). Compose waits for quacktail-server healthy (server.log shows quack_serve + tailscale_serve_local) before starting the client.

Set QUACKTAIL_QUIET=0 to print full SQL. Server libtailscale logs: /work/server.log.

Services

ServiceProfileRole
headscaledefaultControl server (http://127.0.0.1:8080 on host)
quacktail-serverdefaultLong-lived DuckDB + Quack serve
quacktail-clienttestOne-shot e2e
tailscale-probedebugVanilla tailscale/tailscale — ping + TCP to :9494 (no DuckDB)

Connect from host DuckDB

With the stack running, a host DuckDB can join the same tailnet and use the same forwarder pattern as the client container.

Option A — helper script (repo root):

docker compose exec -T quacktail-server cat /work/authkey > examples/headscale/demo.authkey  # gitignored
export HEADSCALE_CONTROL_URL=http://127.0.0.1:8080
export QUACK_TAILNET_TOKEN=quackscale-demo-token
bash scripts/local_remote_headscale_test.sh   # uses build/release/duckdb or release binary

Option B — manual SQL (after eval "$(bash scripts/ci_download_release_duckdb.sh v1.0.2)"):

LOAD quackscale;
CALL tailscale_up(hostname => 'local-duckdb', control_url => 'http://127.0.0.1:8080',
    authkey => '…', state_dir => '…', ephemeral => true);
CALL tailscale_quack_forward(host => 'quacktail-server', port => 9494, local_port => 19494);
LOAD quack;
CREATE SECRET (TYPE quack, TOKEN 'quackscale-demo-token', SCOPE 'quack:127.0.0.1:19494');
ATTACH 'quack:127.0.0.1:19494' AS remote (TYPE quack);
SELECT * FROM remote.e2e_payload;

Environment

VariableDefault
QUACK_TAILNET_TOKENquackscale-demo-token
QUACK_PORT9494
QUACK_FORWARD_LOCAL_PORT19494
QUACKTAIL_QUIET1
HEADSCALE_USERquackscale-demo
BUILD_FROM_SOURCE1 (build-time)
QUACKTAIL_RELEASE_TAGv1.0.2 (when BUILD_FROM_SOURCE=0)

Copy .env.example to .env to override.

Teardown

docker compose --profile test down --remove-orphans -v

Troubleshooting

Tailnet vs DuckDB? Run the vanilla probe while the server is up:

docker compose --profile debug run --rm tailscale-probe
ProbeClientLikely cause
passfailDuckDB tsnet / tailscale_quack_forward / Quack ATTACH
ping failHeadscale / DERP / routing
ping OK, TCP :9494 failServer quack_serve / tailscale_serve_local

Client fails after quack_query probe succeeds — ensure images include tailscale_quack_forward and client SQL uses ATTACH 'quack:127.0.0.1:19494' (not direct quack:quacktail-server:9494). Rebuild and recreate:

docker compose build quacktail-client
docker compose up -d --force-recreate quacktail-server
docker compose --profile test run --rm quacktail-client

Stale /work volume — reset: docker compose down --remove-orphans -v

Server logs: docker compose exec quacktail-server tail -50 /work/server.log

Client logs: docker compose exec quacktail-server cat /work/client.out (last run, shared volume)

See also docs/GUIDE.md (integration patterns) and docs/AUTHENTICATION.md (credentials).