# Zoomies on a single VM, behind Cloudflare. # # 1. cp .env.example .env and fill in the three values it asks for: # ZOOMIES_EXTERNAL_URL the https address GitHub and you reach this at # ZOOMIES_ENCRYPTION_KEY openssl rand -base64 32 # DOCKER_GID stat -c '%g' /var/run/docker.sock # 2. mkdir -p data && sudo chown 65532:65532 data # sudo mkdir -p /var/lib/zoomies/shared && sudo chown 65532:65532 /var/lib/zoomies/shared # 3. docker compose up -d # 4. docker compose logs zoomies | grep 'setup token' # 5. open ZOOMIES_EXTERNAL_URL, paste that token, and create the first admin # # Step 4 is the gate on step 5. This origin is reachable the moment the # container starts, and an empty database is not proof of ownership: the setup # token, which only somebody who can read this container's log has, is. It # changes on every restart and stops being printed once an account exists. # # Steps 4 and 5 are for a person. Where nobody is watching -- this file driven # by a Terraform module or a configuration-management run -- set the # ZOOMIES_BOOTSTRAP_* lines and the secrets block below instead: the first # account is created from a file at startup, /readyz reports # bootstrap_required false once it exists, and nothing has to be read out of # the log. docs/configuration.md has the details. # # Compose refuses to start with any of the three missing, on purpose: each one # left to a default produces a container that is healthy and useless. A wrong # external URL sends GitHub's webhooks and its App-creation redirect to the # wrong site; a missing key means nothing secret can be stored; and a wrong # DOCKER_GID leaves the agent unable to open the socket, so every pool queues # for ever. The Hosts page diagnoses that last one and names the line to # change, but it is quicker not to have to. # # Step 2 is not optional, and the chown needs root: handing a directory to # another uid is not something the docker group lets you do. The database # lives in ./data next to this file, bind # mounted into the container, and a bind mount keeps the host directory's # ownership -- unlike a named volume, it does not inherit anything from the # image. The container runs as uid 65532, so a ./data owned by anyone else (and # Docker creates a missing one as root) gives you: # # store: connecting to /var/lib/zoomies/zoomies.db: unable to open database # file (14) # # Cloudflare terminates TLS and proxies to this origin over plain HTTP on port # 80, so the container serves HTTP and Zoomies is told its external URL is # https. That is what makes the session cookie Secure, the webhook URL correct, # and the UI's links right. # # The controller runs an embedded agent, so this is one container plus the # runners it creates. name: zoomies services: zoomies: image: ghcr.io/eyupio/zoomies:latest # Or build locally: # build: # context: . # dockerfile: deploy/Dockerfile # target: controller restart: unless-stopped # Finish admitted runner creation before replacing the service. stop_grace_period: 20m # The embedded agent registers this host under the container's hostname. # Without one that is the container's random twelve-hex-digit ID, which # then follows the host into every scheduler reason and problem message. hostname: zoomies command: ["controller"] # Compose already reads .env to interpolate the ${...} below; this passes it # into the container as well, so any of the ZOOMIES_* settings in # docs/configuration.md can be set by putting it in .env, with no matching # line needed here. The explicit environment: entries below still win. # # required: false keeps a deployment that supplies its own environment -- # systemd, a secrets manager, CI -- from failing on a missing .env. It needs # Compose v2.24 or newer; on older versions use `env_file: .env`. env_file: - path: .env required: false environment: # How GitHub and your browser reach this controller. Webhooks are # delivered to $ZOOMIES_EXTERNAL_URL/webhooks/github, the GitHub App # created from the Installations page redirects back to it, and the # https here is what makes Zoomies mark its session cookie Secure. ZOOMIES_EXTERNAL_URL: ${ZOOMIES_EXTERNAL_URL:?set this in .env to the https address this controller is reached at} # 32 bytes, base64. Generate with: openssl rand -base64 32 # Back it up: without it the stored GitHub App key cannot be decrypted. ZOOMIES_ENCRYPTION_KEY: ${ZOOMIES_ENCRYPTION_KEY:?set this in .env} # Inside the container we bind to all interfaces; the published port # below is what actually controls exposure. TLS is off here because # Cloudflare terminates it -- Zoomies will say so at startup, and that # warning is expected in this deployment. ZOOMIES_BIND: "0.0.0.0:8080" ZOOMIES_TLS_MODE: "off" # Without this every audit row and every login rate-limit decision sees # Cloudflare's address instead of the real client's, so the whole # internet is throttled as one caller and the audit log is useless. # Refresh from https://www.cloudflare.com/ips/ when Cloudflare publishes # a change. ZOOMIES_TRUSTED_PROXIES: ${ZOOMIES_TRUSTED_PROXIES} ZOOMIES_DB_PATH: /var/lib/zoomies/zoomies.db ZOOMIES_AGENT_EMBEDDED: "true" ZOOMIES_AGENT_BACKEND: docker ZOOMIES_DOCKER_HOST: unix:///var/run/docker.sock # Empty on purpose: the agent works its own capacity out from the # machine it measures, which is the only figure here that has seen it. A # constant would pin every deployment of this file to the same number of # slots however large the host is -- and the capacity is no longer only # a slot count: a pool that leaves its runners' size to the host is # given the machine divided by it. Set this to override. ZOOMIES_AGENT_CAPACITY: ${ZOOMIES_AGENT_CAPACITY} # The network runner containers are attached to: the one declared at the # bottom of this file. Without this line the agent leaves them on the # daemon's default bridge, whatever the comment down there promises. ZOOMIES_AGENT_NETWORK: zoomies ZOOMIES_WORK_DIR: /var/lib/zoomies/work ZOOMIES_LOG_FORMAT: json # The gid group_add below is given, echoed into the container so that # when it is wrong the Hosts page can say "the container holds 999 and # the socket is owned by 987; change DOCKER_GID in .env" rather than # leaving you to work out which file the number came from. DOCKER_GID: ${DOCKER_GID:?set this in .env to the gid that owns /var/run/docker.sock, which stat -c %g /var/run/docker.sock prints} # The unattended first account. Unset, the setup token above is the way # in. Set the username and exactly one of the two files; each is read # once, on a database with no accounts, and ignored with a warning ever # after. The token file holds a token you generated # (openssl rand -hex 32) that becomes a platform API token; the password # file holds a password for a person's account instead. # ZOOMIES_BOOTSTRAP_ADMIN: platform # ZOOMIES_BOOTSTRAP_TOKEN_FILE: /run/secrets/zoomies_bootstrap_token # ZOOMIES_BOOTSTRAP_PASSWORD_FILE: /run/secrets/zoomies_bootstrap_password # The file behind it, if you set one. The long syntax matters: compose # mounts a secret 0444 by default, and Zoomies refuses a credential file # that group or other can read. The uid is the one the image runs as. # secrets: # - source: zoomies_bootstrap_token # uid: "65532" # mode: 0400 ports: # Cloudflare connects here over HTTP. Nothing but Cloudflare should be # able to: put the origin behind a firewall that allows only Cloudflare's # ranges, or use a Cloudflare Tunnel and publish no port at all. - "80:8080" volumes: # The database, in ./data beside this file -- so backing Zoomies up is # copying a directory, and inspecting it does not need `docker volume # inspect`. See step 2 above: this directory must be owned by 65532. # (Runner containers keep their scratch space in their own filesystem; # nothing of theirs lands here.) - type: bind source: ./data target: /var/lib/zoomies bind: # Compose would otherwise helpfully create a missing ./data -- as # root, since the daemon is root -- and hand you error 14 at startup. # Failing with "bind source path does not exist" is far easier to act # on than a SQLite error code. create_host_path: false # The shared folder: runners' caches, one folder per purpose, and where # later releases add folders of their own -- which is why it has to be # owned by 65532 (step 2), and why Zoomies creates what it needs inside # it as it starts. It is mounted at the path it has on the host, because # the host's Docker daemon binds its subfolders into runner containers, # and a path that meant something else inside this container would point # a runner at a folder the daemon cannot see. - type: bind source: /var/lib/zoomies/shared target: /var/lib/zoomies/shared bind: create_host_path: false # The embedded agent needs the Docker socket to create runner containers. # This is Zoomies' own access to Docker -- it is NOT the same thing as # pool.docker_mode=host-socket, which would hand the socket to your jobs. - /var/run/docker.sock:/var/run/docker.sock # The image runs as uid 65532; that user must be able to use the socket, # which means joining the group that owns it. That group is read from the # socket, not assumed to be called docker: it is 987 on one distribution, # 999 or 998 on another, and something else again for a rootless daemon. # A container is given its groups when it is created, so after changing # DOCKER_GID run `docker compose up -d` again and compose recreates it. group_add: - ${DOCKER_GID} networks: [zoomies] healthcheck: test: ["CMD", "/usr/local/bin/zoomies", "healthcheck", "--url", "http://127.0.0.1:8080"] interval: 30s timeout: 5s retries: 3 start_period: 20s # The secret the commented bootstrap lines above name, kept out of .env and out # of `docker inspect`. # secrets: # zoomies_bootstrap_token: # file: ./bootstrap-token networks: # Runner containers are attached to this network by the embedded agent, which # keeps them off the host network and off the default bridge. zoomies: name: zoomies