Skip to content

Deploy with Docker Compose

This guide runs curral on a server, serving a Cloudflare R2 Data Catalog, behind Caddy with automatic HTTPS from Let’s Encrypt.

client ──HTTPS:443──▶ caddy ──HTTP:8080 (internal network)──▶ curral ──▶ R2 Data Catalog

Only Caddy publishes ports. curral is reachable only on the Compose network.

  • Docker from Docker’s repository (get.docker.com). The snap Docker breaks container DNS, port publishing and running the binary. Check with which docker: it must be /usr/bin/docker.
  • A domain with an A/AAAA record pointing to the server. On Cloudflare, leave the proxy off (grey cloud) so Let’s Encrypt can validate.
  • Ports 80 and 443 open for inbound traffic.
  • An R2 bucket with Data Catalog enabled, and an API token with R2 Data Catalog and R2 Storage permissions.
  • Directorycurral/
    • .env credentials and domain (chmod 600)
    • docker-compose.yml
    • Caddyfile
    • Directoryconfig/ mounted read-only at /etc/curral
      • catalog.yaml
      • users.yaml
      • policy.rego
  1. Environment. The values are in the bucket’s Data Catalog settings.

    .env
    CURRAL_DOMAIN=curral.example.com
    R2_CATALOG_URI=https://catalog.cloudflarestorage.com/<account_id>/<bucket>
    R2_WAREHOUSE=<account_id>_<bucket>
    R2_TOKEN=<cloudflare-api-token>
    R2_SCHEMA=<namespace>
    R2_ALLOWED_PATH=s3://<bucket>/
    R2_CACHE_TTL=30s
  2. Catalog.

    config/catalog.yaml
    extensions: [httpfs, avro, iceberg]
    secrets:
    - name: r2_catalog
    type: iceberg
    params:
    TOKEN: ${R2_TOKEN}
    databases:
    - name: lake
    path: ${R2_WAREHOUSE}
    schema: ${R2_SCHEMA:-default}
    cache_ttl: ${R2_CACHE_TTL:-0s}
    options:
    TYPE: iceberg
    SECRET: r2_catalog
    ENDPOINT: ${R2_CATALOG_URI}
    default: lake
  3. Admin user. Generate a strong password and its hash:

    Terminal window
    openssl rand -base64 24
    docker run --rm -it lucasapassos/curral:v0.4.1 hash-password
    config/users.yaml
    users:
    - name: admin
    password_hash: "<bcrypt hash>"
    roles: [admin]
  4. Policy. Start with an admin who can do anything, then add roles.

    config/policy.rego
    package curral
    import rego.v1
    default allow := false
    allow if "admin" in input.roles

    The container runs as uid 65532, which must be able to read the files:

    Terminal window
    sudo chown -R 65532:65532 config && sudo chmod 600 config/*
  5. Caddy.

    Caddyfile
    {$CURRAL_DOMAIN} {
    reverse_proxy curral:8080
    header {
    Strict-Transport-Security "max-age=31536000"
    -Server
    }
    }
  6. Compose.

    docker-compose.yml
    services:
    curral:
    image: lucasapassos/curral:v0.4.1
    environment:
    CURRAL_CATALOG: /etc/curral/catalog.yaml
    CURRAL_USERS: /etc/curral/users.yaml
    CURRAL_POLICY: /etc/curral/policy.rego
    CURRAL_ALLOWED_PATH: ${R2_ALLOWED_PATH:?}
    CURRAL_MAX_CONCURRENCY: 8
    CURRAL_MEMORY_LIMIT: 2GB
    CURRAL_QUERY_TIMEOUT: 600s
    CURRAL_AUDIT_LOG: /var/log/curral/audit.jsonl
    CURRAL_TRUSTED_PROXY: 172.31.247.10 # only Caddy may set X-Forwarded-For
    R2_CATALOG_URI: ${R2_CATALOG_URI:?}
    R2_WAREHOUSE: ${R2_WAREHOUSE:?}
    R2_TOKEN: ${R2_TOKEN:?}
    R2_SCHEMA: ${R2_SCHEMA:?}
    R2_CACHE_TTL: ${R2_CACHE_TTL:-30s}
    volumes:
    - ./config:/etc/curral:ro
    - audit:/var/log/curral
    read_only: true
    tmpfs:
    - /var/lib/curral/tmp:uid=65532,gid=65532,mode=0700
    cap_drop: [ALL]
    security_opt: ["no-new-privileges:true"]
    mem_limit: 3g
    restart: unless-stopped
    networks: [curral]
    caddy:
    image: caddy:2
    ports: ["80:80", "443:443"]
    environment:
    CURRAL_DOMAIN: ${CURRAL_DOMAIN:?}
    volumes:
    - ./Caddyfile:/etc/caddy/Caddyfile:ro
    - caddy_data:/data
    depends_on: [curral]
    restart: unless-stopped
    networks:
    curral:
    ipv4_address: 172.31.247.10
    networks:
    curral:
    ipam:
    config:
    - subnet: 172.31.247.0/24
    volumes:
    audit:
    caddy_data:

    Caddy has a fixed IP so curral can trust X-Forwarded-For from it alone. Never trust the whole subnet: it includes the Docker gateway.

  7. Start and test.

    Terminal window
    docker compose up -d
    docker compose logs -f caddy # wait for "certificate obtained successfully"

    From your machine (curl prompts for the password):

    Terminal window
    curl -u admin https://curral.example.com/v1/query \
    -d '{"sql":"SELECT count(*) FROM <table>","format":"csv"}'
Task Command
Edit users or the policy edit config/*, then docker compose kill -s HUP curral
Change the catalog or .env docker compose up -d
Upgrade change the image tag, then docker compose up -d
Read the audit log docker run --rm -v curral_audit:/a alpine tail /a/audit.jsonl

The image has no shell, so docker compose exec does not work.

Symptom Cause
exec /usr/local/bin/curral: operation not permitted snap Docker; install Docker from get.docker.com
rego_parse_error: package expected a copied ``` fence on the first line; check head -2 config/*
Caddy 502, lookup curral ... server misbehaving curral is not running; check docker compose logs curral
No certificate DNS not pointing to the server, ports closed, or Cloudflare proxy on
External access error reading a table R2_ALLOWED_PATH does not cover the files’ path
Permission denied reading config/ run the chown 65532:65532 from step 4
Port 80 already allocated another proxy runs on the host; remove the caddy service and point that proxy at 127.0.0.1:8080