Everything GoodFolder needs is in docker-compose.yml. You need Docker and nothing else: no cloud account, no email provider, no AI key.
cp .env.example .env # replace every CHANGE_ME value
docker compose up -d # downloads the prebuilt images
Or build from the source you have: docker compose up -d --build (the first build takes a few minutes).
Then open http://localhost:4300 — the dashboard is part of the stack.
Command-line tool
Install the goodfolder command (Node 22 or newer):
npm install -g @goodfolder/cli
GF_API_URL=http://localhost:4100 goodfolder connect ~/some-folder
A folder remembers the server it was set up against, so you only need that variable when setting up a new one.
What comes up
| Service | Purpose |
|---|---|
goodfolder-postgres | Accounts, folders, saves. The schema is applied on first start. |
goodfolder-minio | Object storage for large files. Published on 9100 because uploads go straight from your machine to storage. |
goodfolder-gitea | Internal transport only. Never published, no SSH, registration off. |
goodfolder-api | The control plane, on 4100. |
goodfolder-lfs | Large-file transfers, on 4101. |
goodfolder-web | The dashboard, on 4300. Built for the PUBLIC_URL in your .env. |
Three one-shot containers run and then exit: one creates the storage bucket, one creates the service account the control plane signs in as, and one (goodfolder-migrate) brings the database schema up to the version of the release you are running. All are safe to re-run; the schema one runs on every up and does nothing when there is nothing new.
If a port is already taken
Compose reads docker-compose.override.yml next to the main file automatically, so that is where a port move lives. To shift MinIO — the port most likely to clash, since uploads go straight to it:
services:
goodfolder-minio:
ports: !override
- "127.0.0.1:9299:9000"
!override matters: without it Compose appends to the port list instead of replacing it, and the old 9100 mapping stays. Set PRESIGN_PUBLIC_ENDPOINT in .env to match (http://localhost:9299) so signed upload addresses point at the new port. The same file is where the Traefik labels in the reverse-proxy guide go. It is per-machine, so it is ignored.
Signing in without an email provider
Open the dashboard at http://localhost:4300 and enter your email address. Leave RESEND_API_KEY empty and the one-time sign-in link is written to the server log instead of being emailed:
docker compose logs goodfolder-api | grep magic-link
Open that link in a browser. For a single operator this is a perfectly good setup. Set RESEND_API_KEY when you want other people to be able to sign in.
Save labels without an AI key
Leave OPENROUTER_API_KEY empty. Saves still work; they get a plain generated summary instead of a written one. A label can never block a save.
The save gate
The control plane checks every push before the transport service sees it and refuses — as a whole — any save that adds files the folder's rules keep out. Two variables tune it:
GF_PUSH_GATE:enforce(default) refuses;observeforwards but logs and audits what it would have refused;offrestores plain streaming.GF_PUSH_MAX_BYTES: the largest push body checked in one go (default 2 GiB). Bigger saves get atoo-largeanswer before anything upstream is touched; saving in parts is the workaround.
The check spools each push to a temporary file under the API container's /tmp first, so that filesystem needs free space up to GF_PUSH_MAX_BYTES per concurrent push (at most four are checked at a time; the rest wait). Spool files are always removed when a push finishes or is refused, and any left behind are swept at startup.
One boundary matters: the gate — and the post-push check that raises save.flagged — only cover traffic that passes through the control plane. Publishing Gitea's port, enabling its SSH access, or pointing a client straight at Gitea bypasses both. The compose file publishes nothing for Gitea and enables no SSH; keep it that way.
Putting it on the internet
The compose file binds every published port to 127.0.0.1, so nothing is reachable from outside the machine as it stands. To host it for real, put a reverse proxy with TLS in front, then set:
PUBLIC_URLto the address people reach the control plane onWEB_URLto the address people open the dashboard on — invitation and review links are built from itPUBLIC_LFS_ORIGINandPRESIGN_PUBLIC_ENDPOINTto the addresses clients should upload toWEB_ORIGINSto the origin your dashboard is served from
The dashboard's API address is baked in when goodfolder-web is built, so changing PUBLIC_URL means rebuilding it:
docker compose up -d --build goodfolder-web
Leave MAGIC_LINK_DEBUG unset on anything reachable from outside: it returns sign-in links in the API response.
Upgrading
Get the release you want, then either set GOODFOLDER_VERSION in .env to its tag (for example GOODFOLDER_VERSION=0.2.0) and pull the images:
docker compose pull
docker compose up -d
or build from the new source with docker compose up -d --build.
Either way, the schema upgrade runs by itself before the services start — goodfolder-migrate applies any files in infra/migrations/ the database has not seen yet, in file-name order. Watch it with:
docker compose logs goodfolder-migrate
"schema up to date" means there was nothing to do.
If your install predates tracked upgrades (2026-09-13), the runner cannot tell which old files already ran, so it records them all as applied. Apply the ones dated after your install by hand first — the files are mounted into the database container:
docker compose exec goodfolder-postgres \
psql -U goodfolder -d goodfolder -f /goodfolder-migrations/2026-XX-XX-name.sql
docker compose exec goodfolder-postgres psql -U goodfolder -d goodfolder \
-c "INSERT INTO schema_migrations (name) VALUES ('2026-XX-XX-name.sql')"
Backing up
Everything the stack writes lives under ./data/ — Postgres, MinIO, and the transport store — plus your .env, which holds the secrets. Copy those and you have the whole install.
For a consistent database copy without stopping anything:
docker compose exec goodfolder-postgres pg_dump -U goodfolder goodfolder > backup.sql
Or stop the stack (docker compose stop) and copy ./data/ at rest.
Large files
Anything over 1 MB goes to object storage rather than into the folder's history, and the history keeps a small pointer to it. Nothing extra to configure; MinIO handles it.
One caveat if you have been running GoodFolder since before this worked: large files saved by an older version went into the history as whole copies, and they stay that way until the file is next modified. New saves route correctly.