# Keel for a NAS app screen, using the published images: every setting is in # this file, so it needs no .env next to it. Published as # zimaos-docker-compose.yml and nas-docker-compose.yml (the same file). # # Install either way (see the Keel website): # * Over SSH, as root: the one-line installer from the Keel website (fills in # passwords and your NAS's address, keeps settings in an .env file). # * In the NAS's app screen: paste this file, then replace every CHANGE_ME # value below before installing. # ZimaOS, CasaOS: App Store > "+" > Install a customized app > Import # Unraid: Docker > Compose (Compose Manager plugin) > Add new stack # TrueNAS SCALE 24.10+: Apps > Discover Apps > Install via YAML # Synology DSM 7.2: Container Manager > Project > Create # Portainer, Dockge: a new stack # # Data lives in /DATA/AppData/keel (ZimaOS). On any other system, replace that # path everywhere in this file with your own folder first, for example # /mnt/user/appdata/keel (Unraid), /mnt/tank/apps/keel (TrueNAS) or # /volume1/docker/keel (Synology). name: keel # Replace the seven CHANGE_ME values: four long random values (letters and # digits), two 64-character hex keys and your NAS's address. Generate them with: # docker run --rm ghcr.io/niklan1110/keel:latest generate-env x-secrets: postgres-password: &postgres-password CHANGE_ME_POSTGRES_PASSWORD service-password: &service-password CHANGE_ME_SERVICE_PASSWORD keel-secret: &keel-secret CHANGE_ME_KEEL_SECRET # Studio login (user: admin). dashboard-password: &dashboard-password CHANGE_ME_DASHBOARD_PASSWORD # Sign image-resize URLs, so only keel-storage can ask for resized images # (IMGPROXY_KEY and IMGPROXY_SALT from generate-env; the resizer refuses to # start until these are hex). imgproxy-key: &imgproxy-key CHANGE_ME_IMGPROXY_KEY imgproxy-salt: &imgproxy-salt CHANGE_ME_IMGPROXY_SALT # Settings every Keel service reads (no superuser or service password: only # keel-migrate and keel-studio get those). keel-migrate derives a password for # each service role and writes it to /DATA/AppData/keel/credentials/; # every other service mounts only its own role's folder. x-keel-env: &keel-env # How apps reach the API: your NAS's IP address (or hostname) and port 8000. KEEL_PUBLIC_URL: http://CHANGE_ME_NAS_IP:8000 KEEL_DB_HOST: keel-db KEEL_PUBLIC_DB_PORT: "5432" KEEL_LOG_LEVEL: info KEEL_LOG_PII: "off" # What counts as your network: private ranges, Tailscale and this machine. KEEL_LOCAL_NETWORKS: private # Proxies whose X-Forwarded-For is believed besides Keel's own (gateway, tunnel). KEEL_TRUSTED_PROXIES: "" TZ: UTC # Optional mail server for auth emails (can also be set per project in Studio). x-smtp: &smtp SMTP_HOST: "" SMTP_PORT: "587" SMTP_USER: "" SMTP_PASS: "" SMTP_FROM: "" SMTP_SECURE: "false" # Docker keeps at most 30 MB of logs per container. x-logging: &logging driver: json-file options: max-size: "10m" max-file: "3" x-keel-service: &keel-service image: ghcr.io/niklan1110/keel:latest init: true restart: unless-stopped logging: *logging security_opt: ["no-new-privileges:true"] cap_drop: [ALL] environment: *keel-env depends_on: keel-migrate: condition: service_healthy # Starts Postgres with a pg_hba.conf written on every start: # - inside the keel-db container: no password (as in the stock image) # - from Keel's own Docker networks ("samenet"): password (SCRAM). This also # covers this machine connecting to the published port, which Docker # forwards from the network's gateway address. # - from anywhere else: TLS and password only, never as one of Keel's own # service roles, and never as the postgres superuser unless # KEEL_DB_REMOTE_SUPERUSER=on. # KEEL_DB_LOCAL_NETWORKS replaces "samenet" with your own list of CIDRs. # KEEL_DB_SETTINGS adds Postgres settings (see docker-compose.small.yml). x-db-start: &db-start | set -eu # Refuses to start while the password is still the example value from this file. case "$$POSTGRES_PASSWORD" in CHANGE_ME*|'') echo 'keel: set POSTGRES_PASSWORD in the compose file first (it still says CHANGE_ME)' >&2; exit 1;; esac hba=/var/run/postgresql/keel_hba.conf service_roles=authenticator,keel_auth_admin,keel_storage_admin,keel_realtime_admin,keel_functions_admin,keel_egress_admin # The host's ends of this container's Docker networks, from the routing # table (hex, lowest byte first): every gateway, and the first address of # every directly connected network. host_ends() { awk 'function b(s) { return index("0123456789abcdef", tolower(substr(s, 1, 1))) * 16 + index("0123456789abcdef", tolower(substr(s, 2, 1))) - 17 } function ip(x, add) { return b(substr(x, 7, 2)) "." b(substr(x, 5, 2)) "." b(substr(x, 3, 2)) "." (b(substr(x, 1, 2)) + add) } NR > 1 && $$3 != "00000000" { print ip($$3, 0) } NR > 1 && $$2 != "00000000" && $$3 == "00000000" { print ip($$2, 1) }' /proc/net/route | sort -u } tls=/var/lib/postgresql/tls ssl=off if [ -s "$$tls/server.crt" ] && [ -s "$$tls/server.key" ]; then ssl=on; fi { echo 'local all all trust' echo 'host all all 127.0.0.1/32 trust' echo 'host all all ::1/128 trust' echo 'local replication all trust' echo 'host replication all 127.0.0.1/32 trust' echo 'host replication all ::1/128 trust' # Docker Desktop, rootless Docker and Podman hand Postgres every outside # connection from the Docker network's end on the host, which "samenet" # includes. With KEEL_DB_GATEWAY=remote those addresses count as remote. if [ "$${KEEL_DB_GATEWAY:-local}" = remote ]; then for gw in $$(host_ends); do echo "hostnossl all all $$gw/32 reject" echo "host all $$service_roles $$gw/32 reject" if [ "$${KEEL_DB_REMOTE_SUPERUSER:-off}" != on ]; then echo "host all $${POSTGRES_USER:-postgres} $$gw/32 reject"; fi done fi for net in $$(echo "$${KEEL_DB_LOCAL_NETWORKS:-samenet}" | tr ',' ' '); do echo "host all all $$net scram-sha-256" done # Keel's own roles only ever connect from its containers. echo "host all $$service_roles all reject" if [ "$${KEEL_DB_REMOTE_SUPERUSER:-off}" != on ]; then echo "host all $${POSTGRES_USER:-postgres} all reject" fi if [ "$$ssl" = on ]; then echo 'hostssl all all all scram-sha-256'; fi } > "$$hba" set -- "$$@" -c "hba_file=$$hba" # Extra Postgres settings, e.g. KEEL_DB_SETTINGS="shared_buffers=64MB work_mem=2MB". for setting in $${KEEL_DB_SETTINGS:-}; do set -- "$$@" -c "$$setting"; done if [ "$$ssl" = on ]; then set -- "$$@" -c ssl=on -c "ssl_cert_file=$$tls/server.crt" -c "ssl_key_file=$$tls/server.key" else echo "keel: no TLS certificate in $$tls, so only Keel's own containers can connect" >&2 fi exec docker-entrypoint.sh "$$@" # Makes a self-signed TLS certificate for Postgres on first start (valid for # 10 years). Put your own server.crt and server.key in its folder to use those. x-db-tls: &db-tls | set -eu cd /tls if [ ! -s server.crt ] || [ ! -s server.key ]; then if command -v openssl >/dev/null 2>&1; then umask 077 openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 3650 -subj /CN=keel-db \ -addext subjectAltName=DNS:keel-db,DNS:localhost,IP:127.0.0.1 \ -keyout server.key.new -out server.crt.new 2>/dev/null mv -f server.key.new server.key mv -f server.crt.new server.crt echo "keel: created a TLS certificate for Postgres" else echo "keel: this image has no openssl, so Postgres runs without TLS" >&2 fi fi # Postgres (uid 70 in its Alpine image) wants a key it owns and nobody else can read. if [ -s server.key ]; then chown 70:70 server.key server.crt chmod 600 server.key chmod 644 server.crt fi touch /tmp/ready # Stays up (healthy) so panels that expect running containers are happy. exec sleep 2147483647 x-db-tls-service: &db-tls-service init: true restart: unless-stopped logging: *logging entrypoint: ["/bin/sh", "-c", *db-tls] # Root to hand the key to Postgres' user; no network, read-only filesystem. user: "0:0" security_opt: ["no-new-privileges:true"] cap_drop: [ALL] cap_add: [CHOWN, DAC_OVERRIDE, FOWNER] read_only: true tmpfs: [/tmp] network_mode: none healthcheck: test: ["CMD", "test", "-f", "/tmp/ready"] interval: 3s timeout: 3s retries: 100 labels: keel.role: Database TLS # Health checks: Docker, ZimaOS, Portainer and uptime monitors see a service # that stops answering, not only one that exits. x-health: &health interval: 30s timeout: 5s retries: 3 start_period: 30s services: keel-db-tls: <<: *db-tls-service image: ghcr.io/niklan1110/keel:latest volumes: - /DATA/AppData/keel/db-tls:/tls # Postgres 17 with pgvector and PostGIS. keel-db: image: ghcr.io/niklan1110/keel-postgres:latest restart: unless-stopped logging: *logging security_opt: ["no-new-privileges:true"] entrypoint: ["/bin/sh", "-c", *db-start, "keel-db"] command: ["postgres", "-c", "wal_level=logical", "-c", "max_replication_slots=32", "-c", "max_wal_senders=32", "-c", "shared_preload_libraries=pg_stat_statements", "-c", "log_min_duration_statement=200", "-c", "log_parameter_max_length=0", "-c", "log_parameter_max_length_on_error=0"] environment: POSTGRES_PASSWORD: *postgres-password TZ: UTC # "on" lets the postgres superuser sign in from other machines (with TLS). KEEL_DB_REMOTE_SUPERUSER: "off" volumes: - /DATA/AppData/keel/db:/var/lib/postgresql/data - /DATA/AppData/keel/db-tls:/var/lib/postgresql/tls:ro depends_on: keel-db-tls: condition: service_healthy # Postgres only on the NAS itself. To reach it from other machines, change # this to "0.0.0.0:5432:5432" (connections from your network then need TLS, # and the postgres superuser is refused unless KEEL_DB_REMOTE_SUPERUSER is on). ports: - "127.0.0.1:5432:5432" networks: [default, functions, egress] healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -h 127.0.0.1"] interval: 5s timeout: 5s retries: 20 labels: keel.role: Database # Roles, schema upgrades, first project and folder permissions; then stays up # (healthy) so ZimaOS shows the app as running. keel-migrate: image: ghcr.io/niklan1110/keel:latest init: true command: ["migrate", "--stay"] restart: unless-stopped logging: *logging security_opt: ["no-new-privileges:true"] # Root only to hand the folders below to the keel user (uid 1000); it # switches to that user right after. user: "0:0" # Just enough for that: change owners, read and enter any folder, switch user. cap_drop: [ALL] cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID] environment: <<: *keel-env POSTGRES_PASSWORD: *postgres-password KEEL_SERVICE_PASSWORD: *service-password KEEL_DEFAULT_PROJECT: my-app volumes: - /DATA/AppData/keel/storage:/var/lib/keel/storage - /DATA/AppData/keel/backups:/var/lib/keel/backups - /DATA/AppData/keel/functions:/var/lib/keel/functions - /DATA/AppData/keel/tunnel:/var/lib/keel/tunnel - /DATA/AppData/keel/credentials:/var/lib/keel/credentials healthcheck: test: ["CMD-SHELL", "test -f /tmp/keel-migrated"] interval: 3s timeout: 3s retries: 100 depends_on: keel-db: condition: service_healthy labels: keel.role: Migrations keel-rest: <<: *keel-service command: ["rest"] # Writes only to its volumes; everything else is read-only. read_only: true tmpfs: [/tmp] volumes: - /DATA/AppData/keel/credentials/authenticator:/var/lib/keel/credentials/authenticator:ro healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/health"] <<: *health labels: keel.role: REST API keel-auth: <<: *keel-service command: ["auth"] # Writes only to its volumes; everything else is read-only. read_only: true tmpfs: [/tmp] environment: <<: [*keel-env, *smtp] volumes: - /DATA/AppData/keel/credentials/keel_auth_admin:/var/lib/keel/credentials/keel_auth_admin:ro healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:9999/health"] <<: *health labels: keel.role: Auth keel-realtime: <<: *keel-service command: ["realtime"] # Writes only to its volumes; everything else is read-only. read_only: true tmpfs: [/tmp] volumes: - /DATA/AppData/keel/credentials/keel_realtime_admin:/var/lib/keel/credentials/keel_realtime_admin:ro healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4000/health"] <<: *health labels: keel.role: Realtime keel-storage: <<: *keel-service command: ["storage"] # Writes only to its volumes; everything else is read-only. read_only: true tmpfs: [/tmp] environment: <<: *keel-env KEEL_STORAGE_MAX_FILE_SIZE: "524288000" IMGPROXY_KEY: *imgproxy-key IMGPROXY_SALT: *imgproxy-salt volumes: - /DATA/AppData/keel/storage:/var/lib/keel/storage - /DATA/AppData/keel/credentials/keel_storage_admin:/var/lib/keel/credentials/keel_storage_admin:ro healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:5000/health"] <<: *health labels: keel.role: Storage # Runs your edge functions, on a network with only the gateway, the database # and Studio, so function code can't reach the other services. keel-functions: <<: *keel-service image: ghcr.io/niklan1110/keel-functions:latest command: ["functions"] environment: <<: *keel-env volumes: - /DATA/AppData/keel/functions:/var/lib/keel/functions - /DATA/AppData/keel/credentials/keel_functions_admin:/var/lib/keel/credentials/keel_functions_admin:ro networks: [functions] healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:9000/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] <<: *health labels: keel.role: Edge functions # Sends the HTTP requests projects queue from SQL (net.http_post, database # webhooks). It logs in with a role that can only take requests off those # queues and write the responses, holds no superuser password and can't # reach the Docker socket proxy: on a network of its own with the database # and the gateway (for calls to a project's own functions), and the internet. keel-egress: <<: *keel-service command: ["egress"] read_only: true tmpfs: [/tmp] mem_limit: 256m environment: <<: *keel-env volumes: - /DATA/AppData/keel/credentials/keel_egress_admin:/var/lib/keel/credentials/keel_egress_admin:ro networks: [egress] healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:9100/health"] <<: *health labels: keel.role: Outgoing requests keel-imgproxy: image: darthsim/imgproxy:v3@sha256:73c5dda13199745b0d2d00e4149d4a589c5b2e205dbf335ca05eca8276b54712 # Writes only to its volumes; everything else is read-only. read_only: true tmpfs: [/tmp] restart: unless-stopped logging: *logging security_opt: ["no-new-privileges:true"] cap_drop: [ALL] environment: IMGPROXY_LOCAL_FILESYSTEM_ROOT: /storage # Only files from storage, never URLs from elsewhere. IMGPROXY_ALLOWED_SOURCES: "local://" IMGPROXY_USE_ETAG: "true" IMGPROXY_MAX_SRC_RESOLUTION: "50" # Signed requests only (keel-storage signs them). IMGPROXY_KEY: *imgproxy-key IMGPROXY_SALT: *imgproxy-salt volumes: - /DATA/AppData/keel/storage:/storage:ro healthcheck: test: ["CMD", "imgproxy", "health"] <<: *health labels: keel.role: Image resize keel-gateway: <<: *keel-service command: ["gateway"] # Writes only to its volumes; everything else is read-only. read_only: true tmpfs: [/tmp] volumes: - /DATA/AppData/keel/credentials/authenticator:/var/lib/keel/credentials/authenticator:ro ports: - "0.0.0.0:8000:8000" networks: [default, functions, egress, public] healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8000/health"] <<: *health labels: keel.role: Gateway keel-studio: <<: *keel-service command: ["studio"] environment: <<: [*keel-env, *smtp] POSTGRES_PASSWORD: *postgres-password KEEL_SERVICE_PASSWORD: *service-password KEEL_SECRET: *keel-secret KEEL_DASHBOARD_USERNAME: admin KEEL_DASHBOARD_PASSWORD: *dashboard-password KEEL_HOST_LABEL: ZimaOS # Studio sign-ins from the internet: 2fa (only with two-factor), on or off. KEEL_STUDIO_INTERNET_LOGIN: 2fa # Extra host names Studio answers to (it always does to IP addresses and local names). KEEL_STUDIO_HOSTS: "" KEEL_BACKUP_SCHEDULE: "03:00" KEEL_BACKUP_RETENTION: "7" # Full backups (every database and the uploaded files), encrypted with # this passphrase (12 characters or more). Empty: off. Keep a copy of it # somewhere else. Off-machine copies are set up in Studio > Backups. KEEL_BACKUP_PASSPHRASE: "" KEEL_BACKUP_KEEP_LOCAL: "2" # Studio checks GitHub once a day for a newer release; "off" turns it off. KEEL_UPDATE_CHECK: "on" DOCKER_HOST: tcp://keel-docker:2375 volumes: - /DATA/AppData/keel/backups:/var/lib/keel/backups - /DATA/AppData/keel/tunnel:/var/lib/keel/tunnel # Read-only: full backups copy the uploaded files. - /DATA/AppData/keel/storage:/var/lib/keel/storage:ro # Studio on your network, so the ZimaOS dashboard tile opens it. Change to # "127.0.0.1:3000:3000" to keep it on the NAS only (reach it over a tunnel). ports: - "0.0.0.0:3000:3000" networks: [default, functions, docker-api, public] healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/health"] <<: *health labels: keel.role: Studio # Cloudflare Tunnel, run once you paste a tunnel token in Studio (Connect > # Internet access); idle until then. It only joins keel-public. keel-tunnel: <<: *keel-service command: ["tunnel"] read_only: true tmpfs: [/tmp] environment: KEEL_LOG_LEVEL: info TZ: UTC volumes: - /DATA/AppData/keel/tunnel:/var/lib/keel/tunnel networks: [public] labels: keel.role: Tunnel # Lets Studio list Keel's containers, read their logs and restart them. # Every other Docker call is refused, and other apps' containers (and all # environment variables) stay hidden. keel-docker: image: ghcr.io/niklan1110/keel:latest command: ["docker-proxy"] init: true restart: unless-stopped logging: *logging security_opt: ["no-new-privileges:true"] cap_drop: [ALL] read_only: true # Root only to open Docker's socket. user: "0:0" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro networks: [docker-api] healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2375/_ping"] <<: *health labels: keel.role: Docker access networks: default: functions: # The database, the gateway and keel-egress: outgoing requests from SQL. egress: docker-api: internal: true # Only the gateway, Studio and keel-tunnel join this network, so a tunnel can # reach the API and Studio and nothing else. public: name: keel-public x-casaos: architectures: - amd64 - arm64 main: keel-studio index: / port_map: "3000" scheme: http category: Developer author: Keel developer: Keel icon: https://www.keeldb.name/icon.svg title: en_us: Keel tagline: en_us: Self-hosted backend for your apps (Supabase-compatible) description: en_us: >- Postgres with an instant REST API, auth (email, SMS, OAuth, MFA), file storage, realtime, edge functions and a dashboard, compatible with @supabase/supabase-js. Studio runs on port 3000 and the API on port 8000; Postgres (5432) listens on the NAS only. tips: before_install: en_us: >- Replace the seven CHANGE_ME values first: four long random values (letters and digits), the two image-resize keys (64 hex characters each) and your NAS's IP address in KEEL_PUBLIC_URL.