Installation
The production stack is orchestrated with docker-compose.prod.yml. The app is
served with FrankenPHP; Postgres, Meilisearch, Redis,
Reverb, a queue worker, and the scheduler all run as containers.
The stack pulls a prebuilt image by default, and can optionally build from
source — both driven by the same .env:
- Pull the published image from the GitHub
Container Registry (
ghcr.io/deskhq/the-desk) — the default, no build step. Every setting, including the browser-facing Reverb values, is read at runtime, so one published image works for any host. - Build from source at a release tag — layer a build overlay to compile the image locally.
Pull the published image
Section titled “Pull the published image”The image comes from the GitHub Container Registry (ghcr.io/deskhq/the-desk;
tags X.Y.Z, X.Y, and latest, with edge tracking the tip of master), and
up -d pulls and runs it with no build step. The release to run lives in
.env as APP_VERSION, and the compose file pins the image to it — so no git
repository is needed on the server. Because the app name and browser-facing
Reverb settings are served to the frontend at runtime — not baked into the
JavaScript bundle — the same image works for any operator’s host.
The installer fetches the compose file, the .env template, and the operational
scripts, generates your secrets, and pins APP_VERSION for you:
# 1. Download and run the installer (reads nothing, writes only into this dir).curl -fsSL https://raw.githubusercontent.com/deskhq/the-desk/master/docker/install.sh | sh
# 2. Edit .env — set APP_URL, mail credentials, and REVERB_*_PUBLIC.
# 3. Start the stack. up -d pulls the pinned image (no build step).docker compose up -dBy default the installer pins the latest release. Pass --version=X.Y.Z to pin a
specific one, or a target directory as the last argument (it installs into the
current directory otherwise):
curl -fsSL https://raw.githubusercontent.com/deskhq/the-desk/master/docker/install.sh \ | sh -s -- --version=1.19.0 /srv/the-desk # x-release-please-versionTo run a tag on another registry, an air-gapped mirror, or a floating tag like
edge, set APP_IMAGE=ghcr.io/deskhq/the-desk:<tag> in .env — it overrides
APP_VERSION entirely. Upgrades bump APP_VERSION and restart — see
Upgrading.
Verify the image, and pin it by digest
Section titled “Verify the image, and pin it by digest”Every published image is built by this repository’s CI, which attaches three things to it: a signed build provenance attestation, an in-registry provenance record, and an SBOM listing what is inside. You do not have to use any of them, but if your policy asks where a container came from, this is the answer.
Verify that an image really was built by this repository, using the GitHub CLI:
gh attestation verify oci://ghcr.io/deskhq/the-desk:$APP_VERSION \ --repo deskhq/the-deskA pass tells you the image was built by a workflow in deskhq/the-desk, from the
commit the release was cut at. A failure means the image did not come from here:
do not run it. If you set APP_IMAGE yourself, verify that reference instead,
since it is what the stack actually runs.
Inspect the attached SBOM and provenance without any extra tooling:
docker buildx imagetools inspect ghcr.io/deskhq/the-desk:$APP_VERSION \ --format '{{ json .SBOM }}'Pinning by digest
Section titled “Pinning by digest”Tags are mutable. latest moves every release by design, and even X.Y.Z is a
pointer that could in principle be repushed. A digest is content-addressed, so it
can never resolve to different bytes later:
APP_IMAGE=ghcr.io/deskhq/the-desk@sha256:0123456789abcdef...Set that in .env and it overrides APP_VERSION, exactly like a tag would.
Every stable release’s notes carry the digest for that version, ready to paste,
next to the tag-based pull reference. You can also read it back from a registry
at any time:
docker buildx imagetools inspect ghcr.io/deskhq/the-desk:$APP_VERSION \ --format '{{ .Manifest.Digest }}'You do not lose upgrade.sh by doing this. An upgrade becomes two steps instead
of one: set APP_IMAGE to the new release’s digest, then run
./docker/upgrade.sh --target=X.Y.Z /srv/backups naming that same release. The
script still takes the backup, still runs the migrations, and still verifies that
the instance came back reporting the version you asked for, because APP_VERSION
and the pinned digest describe the same image. Pin by digest if your
change-control process needs it; stay on APP_VERSION otherwise.
The COMPOSE_FILE variable
Section titled “The COMPOSE_FILE variable”Production commands on this site are a bare docker compose, with no
-f docker-compose.prod.yml. That works for the default workflow below because
.env.prod.example ships:
COMPOSE_FILE=docker-compose.prod.ymlCOMPOSE_FILE is read by the docker compose CLI itself, not by the app, and
gen-secrets.sh writes .env from that template before you run any compose
command. So the flag is redundant from the very first up -d, for every
subcommand: ps, logs, exec, pull, down.
This matters for more than typing. This repository also contains compose.yaml,
the Laravel Sail development stack. Without COMPOSE_FILE, a bare
docker compose in this directory would resolve that dev file instead of the
production one.
Upgrading an instance installed before this variable existed? Your .env
predates the template change, so nothing breaks: keep passing
-f docker-compose.prod.yml exactly as before, or add the COMPOSE_FILE line to
your .env to drop the flag.
Build from source
Section titled “Build from source”Building from source genuinely needs the source tree, so this is the one path
that still uses git: clone and check out the tag you want, then layer the build
overlay (docker-compose.build.yml) on top, which restores a local build for
every app-role service — app, reverb, queue, queue-broadcasts, and
scheduler (they share one image), so nothing is pulled from the registry:
# 1. Clone and check out the latest release tag.git clone https://github.com/deskhq/the-desk.gitcd the-deskgit fetch --tagsgit checkout v1.19.0 # x-release-please-version (the desired release tag)
# 2. Generate .env with all required secrets, then edit APP_URL, mail, and# REVERB_*_PUBLIC (see Configuration). The template's APP_VERSION matches the# checked-out tag and just tags the image you build locally../docker/gen-secrets.sh
# 3. Extend COMPOSE_FILE in .env so the build overlay stacks on the prod stack.# Both files, separated by a colon:# COMPOSE_FILE=docker-compose.prod.yml:docker-compose.build.yml
# 4. Build the image and start the stack.docker compose up -d --buildSetting COMPOSE_FILE once means every later command (up, down, logs,
exec) keeps both files layered, so you never have to remember to repeat the
overlay. If you would rather not edit .env, the explicit form still works and
overrides it:
docker compose -f docker-compose.prod.yml -f docker-compose.build.yml up -d --buildTo go back to the published image, drop :docker-compose.build.yml from
COMPOSE_FILE and run docker compose up -d again.
What happens on start
Section titled “What happens on start”- Migrations run automatically. The
appcontainer’s entrypoint runsphp artisan migrate --forceon boot. - The app and Reverb speak plain HTTP, so they publish to loopback by default
(
APP_BIND, default127.0.0.1) onAPP_PORT(default8000) andREVERB_PORT(default8080). Point a host-based reverse proxy at127.0.0.1:8000/127.0.0.1:8080; a proxy running inside the compose network reachesapp:8080/reverb:8080directly and needs no host publishing.
Required secrets
Section titled “Required secrets”APP_KEY, DB_PASSWORD, and MEILISEARCH_KEY have no defaults — the stack
refuses to start without them. ./docker/gen-secrets.sh generates all of these
for you; prefer it over setting them by hand.
If you would rather generate APP_KEY yourself, any base64:-encoded 32-byte
value works:
docker run --rm dunglas/frankenphp:1-php8.5-alpine \ php -r "echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;"Next, tune your instance in Configuration, then create the first user and workspace.