Skip to content
Galley

Galley — self-hosted preview environments

A URL for every pull request.
Your stack. Your servers.

Open a PR, get an isolated environment with your Postgres, your Redis, your services. Push a commit, it rebuilds. Close the PR, it's gone. Runs on a box you own.

Why this exists

Shared staging is a queue.

How it is today
pr 1 pr 2 pr 3 pr 4 pr 5 pr 6 staging

Six PRs share one staging. The reviewer is looking at whichever branch deployed last, and the database state is whatever the last person left it in.

With Galley
pr 1 pr-1.preview pr 2 pr-2.preview pr 3 pr-3.preview pr 4 pr-4.preview pr 5 pr-5.preview pr 6 pr-6.preview

Each PR gets its own subdomain, its own containers, its own database. Webhook closes, environment goes away.

How it's different

Three things the hosted previews can't do.

01 · Whole stack

Not just the frontend.

If it runs in Docker, Galley runs it. The same Postgres, Redis, queue, and worker images you boot in dev come up in the preview, on an isolated network, named exactly the way your code expects.

Compatible stacks ↗

02 · Self-hosted

Runs on your hardware.

One docker compose up on a box you own. Source, secrets, and database snapshots stay on your network. No telemetry, no license server, no phone-home.

Self-hosting guide ↗

03 · Real config

It's your compose file.

Galley reads galley.yml or your existing docker-compose.yml. Build via your own Dockerfile or fall back to language autodetect — both run in unprivileged sandboxes. Services find each other by name on the env network.

galley.yml reference ↗

The config

One galley.yml per repo.

Already have a docker-compose.yml? Galley parses that too. TTL, preview domain, and access policy belong on the project, not in the repo, so they live in the dashboard.

version: 1

services:
  web:
    kind: web
    build:
      path: ./web
    expose: 3000
    depends_on: [api]
    env:
      API_URL: http://api:3001

  api:
    kind: api
    build:
      path: ./api
    expose: 3001
    depends_on: [postgres, cache]
    env:
      DATABASE_URL: postgres://app:pw@postgres:5432/app
      REDIS_URL: redis://cache:6379

  postgres:
    kind: database
    image: postgres:16
    expose: 5432
    env:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: pw
      POSTGRES_DB: app

  cache:
    kind: cache
    image: redis:7
    expose: 6379
  1. L4 kind: web gets a public route + screenshots
  2. L7 build.path: Dockerfile if present, else language autodetect
  3. L9 depends_on orders boot; siblings come up healthy first
  4. L11 reach the api as host "api" — services use galley.yml names
  5. L13 kind: api gets a public route, no screenshots
  6. L22 database, cache, queue, worker, other — internal-only kinds
  7. L28 image-only services skip the build step

Install

Three lines to a working server.

One compose file pulls every control-plane service as a published image. Point a wildcard DNS record at the host and you have previews.

curl -fsSL https://galley.sh/install/docker-compose.yml -o docker-compose.yml
echo "GALLEY_MASTER_KEY=$(openssl rand -hex 32)" > .env
docker compose up -d
# On a separate host, after generating a token in
# Admin → Agents → New agent.
sudo docker create --name x galleysh/agent:v1
sudo docker cp x:/usr/local/bin/galley-agent /usr/local/bin/
sudo docker rm x
sudo systemctl enable --now galley-agent

Full walk-through with DNS, TLS, and the master key in the quick start docs ↗.