Galley

Galley

Docker app from Kevin Cardwell's Repository

Overview

Galley is a self-hosted home for the work around a project: what needs doing, what it will say, who you are hiring, when it happens, and the files that go with it. Each project gets its own space with tasks, a copy editor with approval states and version history, a calendar of deadlines and scheduled entries, a shared supplier directory, and an asset library with thumbnails and colour palettes. You can hand a client a read-only link, or let them comment on and approve individual sections without an account. Everything lives in one SQLite file and one uploads folder inside the appdata path, so backing it up is a folder copy. Access is invite only: the first account you create is the admin, and everyone else sees only the projects they are added to. After the container starts, open the WebUI and create the admin account. Tick the box on that screen to load a worked example so nothing starts empty. If you put this behind a reverse proxy, set the Public URL variable to the address people will actually use. Installing it to a phone home screen needs https. Galley sets up its own appdata folder on first start and then runs as nobody:users (99:100), the Unraid convention, so the share stays browsable. Change PUID and PGID under advanced settings if you want it to run as someone else.

Galley showing a project overview: open tasks, copy approval progress, files and recent activity

Galley

Write your client's website copy, get it signed off, and keep the tasks and files next to it.
Self-hosted, one container, one file to back up.

CI Self-hosted Next.js 16 SQLite MIT licence


A galley proof is the first typeset pull of a page, laid out so the words can be checked before anything goes to print. Galley does that for a website: the copy is written page by page and section by section, it moves from draft to in review to approved, and the client signs it off through a link — no account, nothing to install.

Website projects rarely stall on the build. They stall waiting for the words, the logo and a decision. So the client's side of the link is not read-only: they can approve, suggest their own wording, comment, send files in, and write the sections you hand to them, and once a week they get a note saying what changed and what is still waiting on them.

The rest of the project lives in the same place — tasks, a calendar, the suppliers you book and the files you collect — because it is the same project. It is built for the person who runs the work: a web designer with six client sites on the go, a studio lead, someone organising an event. One screen per project, no seats to buy, no data leaving your server.

mkdir galley && cd galley
curl -O https://raw.githubusercontent.com/kevincardwell/galley/main/compose.yml
docker compose up -d

Open http://localhost:3000, create the admin account, and tick the box to load a worked example so nothing starts empty.


What is inside

Every project on one page

Open a project and you can see where it has got to: what is overdue, how much of the copy is signed off, how many files there are, and what happened recently.

The workspace index with three projects, each showing status, copy approval progress and open task counts

The link you send the client

One link, no account, nothing to install. The top of it lists what is waiting on them; they give their name once.

  • Approve, section by section or a whole page at once. You are told the moment they do, and an approval lapses automatically if the wording changes afterwards — so it always refers to the words they actually agreed to.
  • Suggest their own wording instead of describing it in a comment. They edit the words, you see exactly what they changed, and one click on Accept puts it in the section — headings and lists kept, the old wording kept in the version history.
  • Comment, for everything that is not a rewrite.
  • Send files in. Their logo, photographs, the old brochure. Files land in the project's library labelled with who sent them.
  • Write the sections you hand over. Tick "they write this one" and the client gets a box for their words. It arrives in the version history like any other edit.
  • A weekly note, if you want one: what you changed, what is still waiting on them, and the link back.
The client share view: a list of sections waiting on the client, and their suggested rewording of the hero line shown as a word-level diff

Copy written where it belongs

Pages and sections, each with its own draft, in review and approved state. Several people can write in the same section at once and see each other's cursors. Every save is kept, so you can compare any version with a word-level diff and put it back. One click copies clean Markdown or HTML for whatever the site is built in.

The copy editor with a page outline, the text in a serif column, and a details pane showing status, word count, versions and comments

Tasks that behave like a to-do list, not a ticketing system

Sections you name yourself, a list or a board, drag to reorder, due dates that go amber then red, assignees, checklists, comments and attached files.

The task list grouped into Design, Build, Content and Launch sections with due dates and assigneesThe same tasks as a board in dark mode

A calendar that already knows your deadlines

Task due dates appear automatically next to the things you schedule: site visits, photography, the print deadline, go-live. Month, week and agenda views, drag an entry to move it, and a subscribable feed so a client can follow the plan in their own calendar.

A month calendar showing task deadlines and timed schedule entries

The people you hire, kept once

A shared directory of printers, photographers, copywriters and freelancers, with what you booked them for on each project and what it cost.

The supplier directory showing five suppliers with categories, ratings, contact details and booked totals

Files with the detail you actually need

Drag anything in. Thumbnails, dimensions, duration for video, a colour palette pulled from each image, tags, folders, and a record of which task or paragraph each file belongs to.

The asset grid with filter chips, storage total and a details pane showing palette and tags

On your phone, as an app

Add it to the home screen from Safari or Chrome and it opens full screen with its own icon.

Galley on a phone showing the task list and the add to home screen prompt


Everything else it does

Invite only No public sign-up. The first account is the admin; everyone else is invited and sees only the projects they are added to. Each invite link lasts a day, a week, a month, three months, or forever — you pick when you make it.
Two-factor Optional TOTP on any account, from Account → Two-factor: scan the QR with Google Authenticator, 1Password, Aegis or anything else that speaks TOTP. Ten one-shot recovery codes are shown once, when you turn it on.
Email Your own SMTP server, or Resend, Postmark, SendGrid or Mailgun when your host blocks SMTP ports. Invites send themselves.
Notifications An inbox for mentions, assignments and client feedback, with email when a provider is set up.
Search ⌘K across tasks, copy, files and projects, scoped to what you are allowed to see.
Export A zip of the whole project: copy as Markdown, tasks as CSV, every original file.
Backups One click, or nightly with retention, from the admin panel, and the database is copied aside before any upgrade migrates it.
Project templates Start a project from an existing one and keep the task list and page structure, without last client's content.
Repeating tasks Weekly, monthly, quarterly. Tick one off and the next appears, which is how a care plan lives here alongside a build.
Currency Set a three-letter code in Admin → Settings; costs are stored as minor units so nothing rounds twice.
Themes Light by default; switch to dark, or let it follow the system.
Keyboard ? lists every shortcut.

Running it

With Docker

mkdir galley && cd galley
curl -O https://raw.githubusercontent.com/kevincardwell/galley/main/compose.yml
# set GALLEY_URL in compose.yml
docker compose up -d

Images are published for linux/amd64 and linux/arm64 on every release:

Registry Image
GitHub ghcr.io/kevincardwell/galley:latest
Docker Hub kevincardwell/galley:latest

Pin a version with a tag such as :0.1.0 if you would rather upgrade deliberately.

Data lives in the galley-data volume at /data. To keep it in a folder instead, change the volume to - ./data:/data: Galley takes ownership on start, and PUID/PGID choose who it runs as.

Behind a reverse proxy: forward to port 3000, set GALLEY_URL to the public https address, and raise the proxy's body limit to at least MAX_UPLOAD_MB. The https part matters, because home screen installation needs it. The compose file binds the port to 127.0.0.1 for exactly this setup; if you reach Galley directly instead, publish 3000:3000 and set TRUSTED_PROXY_HOPS=0.

To build the image yourself, uncomment build: . in compose.yml.

On Unraid

Galley ships an Unraid template. Until it appears in Community Applications, add it by hand:

  1. In Unraid, open Docker, then Add Container.
  2. Paste this into Template: https://raw.githubusercontent.com/kevincardwell/galley/main/unraid/galley.xml
  3. Check the appdata path and the port, then Apply.

The data path defaults to /mnt/user/appdata/galley and holds the database, uploads and backups, so that one folder is your backup. Set Public URL to the address you actually reach it on, especially behind a reverse proxy.

Without Docker

Node 22 or newer, plus ffmpeg on the PATH if you want poster frames for video.

git clone https://github.com/kevincardwell/galley.git
cd galley
npm ci
npm run build
GALLEY_URL=http://localhost:3000 GALLEY_DATA_DIR=/srv/galley PORT=3000 npm start

GALLEY_URL is not just for invite links: without it, production builds assume they are behind TLS and mark the session cookie Secure, so a plain-http address (a LAN IP, say) would not keep you signed in.

Settings

Variable Default What it does
GALLEY_URL none Public address, used for invite links and to mark cookies secure. Set it whenever the instance is reachable on anything but localhost.
GALLEY_DATA_DIR ./data, /data in Docker Where the database and uploads live.
MAX_UPLOAD_MB 500 Largest single upload.
TRUSTED_PROXY_HOPS 1 How many reverse proxies sit in front, so the real client address can be found for rate limiting. Set 0 when Galley is the exposed server: forwarded headers are then ignored rather than trusted. A chain shorter than this number is ignored too.
PORT 3000 Port to listen on.
TZ UTC Server time zone. Decides when the nightly backup runs and how dates are grouped.
PUID / PGID 1000 / 1000 Who Galley runs as. It takes ownership of the data directory on start, so a bind mount does not need chowning first. The Unraid template sets 99 / 100.
GALLEY_AUTH_HEADERS off Accept sign-ins from a reverse proxy that has already authenticated the caller. See below.
GALLEY_AUTH_SECRET none Shared secret the proxy must send in the x-galley-auth header, proving the request really came through it. Strongly recommended.
GALLEY_AUTH_EMAIL_HEADER Remote-Email Which header carries the address.
GALLEY_AUTH_NAME_HEADER Remote-Name Which header carries the display name, if any.
GALLEY_AUTH_AUTO_CREATE off Create an account the first time someone new arrives, instead of requiring an invite.

GALLEY_URL and MAX_UPLOAD_MB are read on every start and override whatever the settings page stored, so changing them in compose or the Unraid template always takes effect.

Email and the backup schedule are set in the app, under Admin, not with environment variables.

Signing in through a reverse proxy

If everything on your server already sits behind Authelia, Authentik, oauth2-proxy or Tailscale, Galley can trust that and skip its own login.

environment:
  GALLEY_AUTH_HEADERS: "1"
  GALLEY_AUTH_SECRET: "a-long-random-string"   # the proxy must send this in x-galley-auth
  GALLEY_AUTH_AUTO_CREATE: "1"                 # optional: invite nobody, let the proxy decide

With GALLEY_AUTH_SECRET set, configure the proxy to add x-galley-auth: <secret> on the requests it forwards and to strip any inbound copy of that header. Galley compares it in constant time and refuses anything without it — so an exposed port is no longer a way in. Without the secret, Galley logs a warning at startup and refuses to create the first (admin) account through the proxy, though accounts that already exist can still sign in.

[!WARNING] The proxy is the entire security model. These are ordinary HTTP headers, so anyone who can reach Galley without going through the proxy can set Remote-Email to whatever they like and become that person. A GALLEY_AUTH_SECRET closes that door, but only if the proxy sets it and strips inbound copies — belt and braces: keep Galley unreachable except through the proxy. Bound to localhost, or on an internal network the proxy can see and nobody else can. Check it from another machine before you trust it.

Defaults suit Authelia. For others, point Galley at their headers:

Proxy GALLEY_AUTH_EMAIL_HEADER GALLEY_AUTH_NAME_HEADER
Authelia Remote-Email (default) Remote-Name (default)
Authentik X-authentik-email X-authentik-name
oauth2-proxy X-Forwarded-Email X-Forwarded-Preferred-Username
Tailscale Serve Tailscale-User-Login Tailscale-User-Name

With GALLEY_AUTH_AUTO_CREATE off — the default — people still have to be invited first, and the proxy simply saves them typing a password. With it on, the first person through becomes the admin and everyone after them is an ordinary member; creating that first admin this way needs GALLEY_AUTH_SECRET, so restrict who reaches Galley in the proxy's own rules either way.

An account that an admin has deactivated stays out whatever the proxy says. The password login form stays available for anyone who was set up that way, so you are never locked out if the proxy misbehaves.

Backing up and upgrading

  • Backup: Admin → Backups writes a zip of the database and every upload, on demand or nightly. Turn the nightly schedule on the first time you open that page.
  • Copying the data directory works too, but only with Galley stopped. The database is in WAL mode, so a copy taken while it is running misses whatever is still in galley.db-wal and is not a backup you can trust. The zip does not have this problem: it snapshots the database properly (VACUUM INTO) and is safe to take at any time.
  • Treat backups as secrets. The database holds your SMTP password or provider API key in plain text, along with password hashes, session tokens and share links. Store backups somewhere you would store a password.
  • Restore: stop Galley, unzip a backup into an empty data directory, start it again.
  • Upgrade: docker compose pull && docker compose up -d. Migrations run on start, and Galley copies the database to backups/pre-migration-<date>.db first. If an upgrade goes wrong, stop the container, put that file back as galley.db (deleting any galley.db-wal and galley.db-shm beside it) and run the previous image tag.

How it is built

One process, one SQLite file, one uploads folder. No Postgres, no Redis, no object store, no worker container, because a tool for a handful of people should not need a fleet.

  • Next.js 16 with the App Router and server actions
  • SQLite through Drizzle, in WAL mode, with FTS5 for search
  • Yjs for live collaborative editing, carried over server-sent events so there is no second port to proxy
  • sharp and ffmpeg for thumbnails, palettes and poster frames
  • Tailwind v4 with tokens for the light and dark palettes
npm run dev          # development server
npm test             # unit tests
npm run test:e2e     # end-to-end, after npm run build
npm run lint
npm run db:generate  # after editing src/db/schema.ts

CHANGELOG.md is what changed between releases. docs/DESIGN.md is the design direction, docs/PLAN.md the original plan, and docs/STATUS.md the running log of what is done and what is next.

What it is not

Worth knowing before you install it, rather than after.

  • It is sized for a studio, not a company. One SQLite file, one process, no job queue. A handful of people and a few dozen projects is the shape it is built for. If you need fifty concurrent editors or horizontal scaling, this is the wrong tool and will stay the wrong tool.
  • One instance is one organisation. Workspaces are projects, not tenants. Everyone invited shares one supplier directory, one set of instance settings and one admin panel. It is not built to host several unrelated businesses.
  • Recovery codes are the only way back in. If someone turns two-factor on, loses their phone and loses their recovery codes, no admin screen can let them back in: clear totp_secret for that row in galley.db with Galley stopped.
  • There is no OIDC or LDAP. Accounts are invite-only email and password. Galley will trust a reverse proxy that has already signed someone in (Authelia, Authentik, oauth2-proxy, Tailscale — see Running it), but it does not speak either protocol itself.
  • Mail credentials are stored in plain text in the database, so treat a backup like a password. This is called out again under Backing up.
  • The container starts as root for a moment, to take ownership of the data directory, then drops to PUID:PGID and runs the application unprivileged. Pass --user if you would rather it never ran as root at all — you then own chowning the directory yourself.
  • Dates are formatted en-GB — "14 Sept", not "Sep 14". Currency is a setting; the date format is not one yet. If that grates, say so in an issue.

Licence

MIT. See LICENCE.

Download Statistics

587
Total Downloads

Related apps

Details

Repository
kevincardwell/galley:latest
Last Updated2026-09-12
First Seen2026-09-13

Runtime arguments

Web UI
http://[IP]:[PORT:3000]/
Network
bridge
Shell
sh
Privileged
false

Template configuration

WebUI PortPorttcp

Port for the web interface.

Target
3000
Default
3000
Value
3000
DataPathrw

Holds the database, uploads and backups. Back this folder up.

Target
/data
Default
/mnt/user/appdata/galley
Value
/mnt/user/appdata/galley
Public URLVariable

The address people use to reach Galley, used for invite links and to mark cookies secure. Use your https address if it sits behind a reverse proxy.

Target
GALLEY_URL
Default
http://[IP]:[PORT:3000]
Max upload (MB)Variable

Largest single file upload. Raise your reverse proxy body limit to match.

Target
MAX_UPLOAD_MB
Default
500
Value
500
Trusted proxy hopsVariable

How many reverse proxies sit in front, so login rate limiting can find the real client address. Use 0 when nothing proxies it.

Target
TRUSTED_PROXY_HOPS
Default
1
Value
1
PUIDVariable

User id Galley runs as. 99 is nobody, the Unraid default, which keeps appdata browsable over SMB.

Default
99
Value
99
PGIDVariable

Group id Galley runs as. 100 is users, the Unraid default.

Default
100
Value
100
Time zoneVariable

Used for due dates, the calendar and the nightly backup time.

Target
TZ
Default
Europe/London
Value
Europe/London