Get Started
Self-hosting
Install, configure, upgrade, back up and restore a Consultainer instance
Consultainer is self-hosted only: no billing, no plan limits, no telemetry.
It ships as a multi-arch (amd64/arm64) image at
ghcr.io/armistice-group/retainer and runs from one Docker Compose file with
four services:
| Service | What it is |
|---|---|
consultainer-db | Postgres 16. Data lives in the db_data Docker volume. |
consultainer-app | The app. Listens on port 3000 in the container, published on the host as APP_PORT (3113 by default). Runs database migrations every time it starts. |
scheduler | A small loop that calls the app's scheduled jobs. |
backup | Dumps the database every night and keeps the last 14 dumps. See Backups. |
You need a Linux, macOS or Windows host with Docker Engine and the Compose
plugin (docker compose version should work), about 1 GB of free RAM, and
openssl to generate secrets.
Install
Download the compose file and settings template
In an empty directory (for example
~/consultainer):curl -O https://raw.githubusercontent.com/Armistice-Group/retainer/main/docker-compose.yml curl -o .env https://raw.githubusercontent.com/Armistice-Group/retainer/main/.env.exampleGenerate the three required secrets
Run each command and paste its output after the matching
=in.env:openssl rand -base64 32 # → AUTH_SECRET openssl rand -base64 32 # → INTEGRATION_ENCRYPTION_KEY openssl rand -hex 32 # → CRON_SECRETINTEGRATION_ENCRYPTION_KEYmust be base64 that decodes to exactly 32 bytes, which is whatopenssl rand -base64 32produces. Don't use a hex value or a password for it.Save a copy of
INTEGRATION_ENCRYPTION_KEYsomewhere outside this server (a password manager). It encrypts SSO client secrets, integration credentials and tokens, and calendar links. If you lose it, a database backup can't decrypt them, and every integration, SSO connection and calendar has to be set up again.Set a database password
Change
POSTGRES_PASSWORD=change-meto a long random value, for example the output ofopenssl rand -hex 24. LeavePOSTGRES_USERandPOSTGRES_DBas they are unless you have a reason to change them.Postgres only reads these values the first time it creates the
db_datavolume. Changing them later doesn't change the database; see Troubleshooting.Set a setup token if the server is reachable from the internet
Until someone completes first-run setup, whoever opens the app first can create the owner account. If the host is public, set a token that the setup page will ask for:
openssl rand -hex 16 # → SETUP_TOKENStart it
docker compose up -d docker compose logs -f consultainer-appWait for
Running database migrations...followed byStarting server, then press Ctrl+C to stop following the log (the app keeps running).Open the app and create your workspace
Go to
http://<this-host>:3113(or yourAPP_PORT). A new database sends every page to/setup. Fill in:- Setup token: the
SETUP_TOKENvalue (only shown if you set one). - Organization name: your company's name.
- Instance URL: pre-filled with the address in your browser. This is
the address used in links that get sent to people (invites, client
invoice links, share and review links, emails). If people will reach
the app at a different address, such as a domain you'll put a
reverse proxy in front of later,
enter that instead. Not shown when
AUTH_URLis set. - Your name, Email and Password (at least 10 characters, with a letter and a number). This becomes the organization's Owner.
Click Create workspace. You're signed in and taken to a checklist (
/welcome) with next steps: single sign-on, inviting your team, adding a client, and integrations. The Quickstart walks through them.- Setup token: the
Check it worked: docker compose ps lists all four services as
running, and opening /setup again now redirects to the login page. Setup
can only be completed once; after the first account exists, /setup is
closed for good.
The owner account always keeps password sign-in, even after you turn on "Require SSO". Keep its password somewhere safe as a break-glass login.
Try it with demo data
To look around with realistic sample data, run this before completing
/setup (it needs an empty database):
docker compose exec consultainer-app node_modules/.bin/tsx prisma/seed-demo.tsThen sign in as maya@northwind.example / password123. Don't do this on an
instance you plan to use for real: to start over, run
docker compose down -v (this deletes the database) and
docker compose up -d.
Build from source instead of pulling the image
From a checkout of the repository:
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --buildSettings (.env)
Every variable is described in .env.example. After changing .env, run
docker compose up -d again so the containers pick up the change (a plain
restart does not re-read .env).
Required
docker compose refuses to start without the first three (it prints
Set AUTH_SECRET in .env ... and so on).
| Variable | What it's for | How to get it |
|---|---|---|
AUTH_SECRET | Signs login sessions and OAuth state. Changing it signs everyone out. | openssl rand -base64 32 |
INTEGRATION_ENCRYPTION_KEY | Encrypts SSO secrets, integration credentials and tokens, and calendar links in the database. Back it up. | openssl rand -base64 32 |
CRON_SECRET | Lets the scheduler call /api/cron/*. | openssl rand -hex 32 |
POSTGRES_PASSWORD | Password of the database the compose file creates. | Any long random string, e.g. openssl rand -hex 24 |
POSTGRES_USER, POSTGRES_DB | Database user and name. | Keep the defaults from .env.example (app, consultainer). |
If you leave POSTGRES_USER, POSTGRES_PASSWORD or POSTGRES_DB out of
.env entirely, the compose file falls back to app / app /
consulthub. .env.example sets the database name to consultainer, so
keep whichever your .env actually has: the backup service reads the
same values, so it always dumps the database the app uses.
Recommended
| Variable | What it's for |
|---|---|
SETUP_TOKEN | Required on /setup when set, so nobody else can claim a fresh instance that's reachable from the internet. openssl rand -hex 16. Has no effect once setup is done. |
APP_PORT | Host port the app is published on (default 3113). Set 127.0.0.1:3113 to accept connections only from the same machine, which is what you want behind a reverse proxy. |
CONSULTAINER_VERSION | Image tag to run. See Pinning a version. |
Optional
| Variable | What it's for |
|---|---|
AUTH_URL | Forces one public URL for everything, including sign-in redirects. Usually leave it unset; see URLs. |
ALLOW_PRIVATE_FETCH | true lets calendar links, the Slack webhook URL and client websites (for logos) point at private-network addresses (for example Nextcloud on your LAN). Off by default so a user-entered URL can't be used to probe the server's own network. |
AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET | Shows Continue with Google on the login page. Create an OAuth client of type Web application in Google Cloud and add the redirect URI <your URL>/api/auth/callback/google. Can also be set under Settings → Integrations; see Google sign-in. Google sign-in never creates accounts on its own: a new Google user gets in only with an invite or through an organization's auto-join domain. |
EMAIL_PROVIDER, EMAIL_FROM, RESEND_API_KEY, SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD | Email through Resend or any SMTP server. Can also be set under Settings → Integrations. See Email. |
QUICKBOOKS_CLIENT_ID, QUICKBOOKS_CLIENT_SECRET, QUICKBOOKS_ENVIRONMENT | QuickBooks app credentials. |
LINEAR_CLIENT_ID, LINEAR_CLIENT_SECRET, LINEAR_WEBHOOK_SECRET | Linear OAuth app, and its webhook signing secret for live updates. |
DOCUSIGN_CLIENT_ID, DOCUSIGN_CLIENT_SECRET, DOCUSIGN_ENVIRONMENT | DocuSign app (integration key, secret key, demo or production) for pulling signed agreements. |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | Stripe, for collecting invoice payments. |
GOOGLE_DRIVE_*, DROPBOX_*, MICROSOFT_*, NOTION_* | File services people connect to link and file documents. See Documents & files. |
S3_BUCKET, S3_REGION, S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLE, S3_PREFIX | Keep uploads in S3-compatible storage instead of the database. See File storage. |
You can leave every integration variable out. The Owner can enter the
same credentials in the app, under Settings → Integrations and
Settings → Payments, and those cards show the exact callback and webhook
URLs to register. A variable set in .env takes precedence over anything
saved in the app and shows as read-only there.
Put it behind a domain with HTTPS
For anything beyond a LAN, put a reverse proxy with TLS in front of the app. The app needs to know the address the browser used, so the proxy must pass these headers:
| Header | Why it matters |
|---|---|
Host (or X-Forwarded-Host) | Sign-in, SSO and OAuth redirects are built from it, and form submissions are rejected if it doesn't match the browser's address. |
X-Forwarded-Proto | Without it the app assumes http, so SSO and OAuth redirect URIs come out as http://… and your identity provider rejects them. |
Both examples below assume the app is published on 127.0.0.1:3113 on the
same host (set APP_PORT=127.0.0.1:3113 in .env and run
docker compose up -d).
time.example.com {
reverse_proxy 127.0.0.1:3113
}server {
listen 443 ssl;
http2 on;
server_name time.example.com;
ssl_certificate /etc/letsencrypt/live/time.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/time.example.com/privkey.pem;
# Uploads can be up to 25 MB with object storage; nginx's default is 1 MB.
client_max_body_size 30m;
location / {
proxy_pass http://127.0.0.1:3113;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# The MCP server streams responses.
proxy_buffering off;
}
}
server {
listen 80;
server_name time.example.com;
return 301 https://$host$request_uri;
}Caddy gets a certificate automatically and already sends the right headers. Traefik also sends them by default.
Then:
Update the instance URL
Sign in as the owner on the new address, open Settings → General, and in the Instance URL card click Use this address (or type the URL) and Save.
Re-register callback URLs
Redirect and webhook URLs you registered with other services (SSO, Google sign-in, Linear, QuickBooks, Stripe, file services) contain the old address. Each settings card shows the current one; update them in the provider's console.
Check it worked: the app loads over https://, you can sign out and back
in, and Settings → Security shows a redirect URI starting with
https://time.example.com.
URLs
Consultainer uses two kinds of address:
- Redirects the browser follows straight away (sign-in, SSO, OAuth
connections, passkeys, pay links) use whatever address the request came in
on. So the app works at
localhost, a LAN name and a public domain at the same time. - Links that leave the browser (emails, invite links, client invoice and
share links, contractor review links, webhook URLs) use the instance
URL. It's set at
/setupand only the Owner can change it, under Settings → General → Instance URL. These links never trust the request'sHostheader, so a forged header can't put another domain into an email.
Set AUTH_URL (for example AUTH_URL=https://time.example.com) only if you
want to force one address for both. It then replaces the instance URL, and
the setting in the app becomes read-only.
Passkeys are tied to the domain they were created on. If you move the app to a new domain, people need to add their passkeys again under Profile.
Behind an authenticating proxy
If something in front of the app requires a login (Cloudflare Access, oauth2-proxy, an SSO gateway), let these paths through without it. Each has its own authentication or is meant to be public:
| Path | Why |
|---|---|
/api/v1/*, /api/mcp | REST API and MCP server (API keys). |
/api/webhooks/* | Stripe, Linear, Cal.com and Calendly webhooks (signed). |
/api/cron/* | Scheduled jobs (CRON_SECRET). Only needed if the scheduler doesn't call the app directly. |
/i/*, /share/*, /review/*, /api/share/* | Client invoice links, client and project share links, contractor reviews. |
/invite/*, /login/*, /api/auth/*, /api/sso/* | Joining and signing in. |
/api/branding/* | The logo and icon shown in emails. |
/api/calendar/* | Calendar subscription feeds (a secret address per person), fetched by calendar apps. |
Scheduled jobs
The scheduler service waits 60 seconds after it starts, then calls these
endpoints on the app with Authorization: Bearer <CRON_SECRET>. Every job is
idempotent: running one twice never sends or creates anything twice.
| Endpoint | Runs | What it does |
|---|---|---|
/api/cron/mercury-sync | Every hour | Emails draft invoices scheduled to send by now, then checks Mercury and marks invoices paid when the client has paid. |
/api/cron/linear-sync | Every hour | Syncs every linked project with Linear (issues and comments) and retries failed pushes. |
/api/cron/calendar-sync | Every hour | Pulls everyone's meetings from their connected calendars: the last two weeks to sort, and the next 30 days to show on the calendar. |
/api/cron/agreements-sync | Every hour | Pulls newly completed agreements from every connected DocuSign, Documenso and Ironclad account. |
/api/cron/scheduling-sync | Every hour | Refreshes Cal.com and Calendly event types, pulls new bookings for Calendly accounts without webhooks, and deletes draft clients discarded more than 30 days ago. |
/api/cron/filing | Every hour | Files invoices and documents that didn't reach the organization's filing folder yet, or changed since. |
/api/cron/recurring-invoices | Once a day | Creates retainer invoices and client billing-cycle invoices that are due, raises overdue alerts, emails overdue reminders to clients, sends deadline reminders (tasks due tomorrow, overdue tasks, milestones and deliverables), and on Monday (or Tuesday, if Monday's run was missed) sends the weekly digest. |
The daily job runs on the scheduler's first pass and then every 24 hours, so its time of day is whenever the scheduler last started. Restarting the scheduler runs it again straight away, which is harmless.
To check the jobs work, run one by hand:
docker compose exec scheduler sh -c 'curl -sS -X POST -H "Authorization: Bearer $CRON_SECRET" http://consultainer-app:3000/api/cron/recurring-invoices'You should get JSON back (for example {"ran":0,...}). {"error":"Unauthorized"}
means the scheduler's CRON_SECRET doesn't match the app's; run
docker compose up -d to recreate both from the same .env.
Running the app some other way (Kubernetes, a PaaS, systemd)? Send a POST
to the same endpoints on the same schedule with the same header.
File storage
Uploaded files (client and project documents, expense receipts, milestone and time entry evidence) are stored in the database by default, up to 5 MB each. To keep them in S3-compatible object storage instead (AWS S3, Cloudflare R2, MinIO, Backblaze B2, Wasabi and others), with a 25 MB limit per file:
Create a private bucket
Create a bucket (for example
consultainer) and leave public access off. Consultainer reads files back itself and serves them behind its own access checks; nothing in the bucket is ever made public.Create an access key for it
Create a key that can read, write, delete and list objects in that bucket, and note the access key ID and secret.
- AWS S3: an IAM user with a policy allowing
s3:GetObject,s3:PutObject,s3:DeleteObjectonarn:aws:s3:::consultainer/*ands3:ListBucketonarn:aws:s3:::consultainer, then an access key for that user. If the app runs on EC2 with an instance role, you can leave the key variables out and the role is used. - Cloudflare R2: R2 → Manage R2 API Tokens → Create API token with Object Read & Write on the bucket. Use the S3 endpoint shown for your account.
- MinIO: create an access key in the MinIO console with a policy for the bucket.
- AWS S3: an IAM user with a policy allowing
Add the S3 settings to .env
S3_BUCKET=consultainer S3_REGION=us-east-1 # R2: auto S3_ENDPOINT= # empty for AWS; R2: https://<account-id>.r2.cloudflarestorage.com; MinIO: http://minio:9000 S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... S3_FORCE_PATH_STYLE=false # true for MinIO and most self-hosted servers S3_PREFIX= # optional, e.g. consultainer/ to share a bucketApply it
docker compose up -dCheck it worked
Open Settings → Integrations and find the File storage card. It should say Uploads go to the consultainer bucket (S3-compatible), up to 25MB each and The bucket is reachable. If it says Can't reach the bucket followed by an error name, check the endpoint, region, keys and
S3_FORCE_PATH_STYLE.Move existing files (optional)
New uploads go to the bucket from now on. Files already in the database keep working where they are. To move them into the bucket too:
# Count what would move, without changing anything docker compose exec consultainer-app node_modules/.bin/tsx scripts/move-files-to-object-storage.mts --dry-run # Move them docker compose exec consultainer-app node_modules/.bin/tsx scripts/move-files-to-object-storage.mtsIt prints
In the database: N documents, N receipts, N evidence files.and, after moving,Moved N files to <bucket>.Each file's database copy is cleared once it's in the bucket. It's safe to run again; it only touches files still in the database.
Once files are in the bucket, keep the S3_* settings. If you remove
them, those files can't be opened (there's no script to move them back
into the database).
Documents linked from Google Drive, Dropbox, OneDrive or Notion are never stored by Consultainer, so they don't use storage at all.
Email is optional. Without it:
- invites and contractor review requests give you a link to share yourself;
- Email me a login link instead on the login page is hidden;
- email address changes apply right after a password check, without a confirmation email;
- emailing invoices to clients, overdue reminders, email alerts and the weekly digest are off. Invoices can still be shared with Copy client link.
To turn it on, the owner picks Resend or SMTP (Postmark,
SES, Mailgun, Google Workspace, your own server…) on the welcome page after
setup, or later under Settings → Integrations → Email. It can also be set
in .env; see Email. Send a test email checks it
end to end and shows the service's reason if it fails.
Google sign-in
Continue with Google on the login page is optional. The owner turns it on
under Settings → Integrations → Google sign-in (or with AUTH_GOOGLE_ID
and AUTH_GOOGLE_SECRET in .env, which wins over the settings):
- In the Google Cloud console, pick or create a project and set up Google Auth Platform (Audience: Internal for one Google Workspace, otherwise External, published).
- Create a client of type Web application with the redirect URI the card
shows:
<your URL>/api/auth/callback/google. - Paste its client ID and secret on the card and save. The login page shows the button straight away.
Google sign-in never creates accounts on its own: a new Google user gets in only with an invite or through an organization's auto-join domain.
Upgrading
docker compose pull
docker compose up -d
docker compose logs -f consultainer-appThe app applies any new database migrations when it starts
(Running database migrations... in the log). Migrations only go forward:
to go back to an older version after an upgrade, restore the backup you took
before it. So take a backup before upgrading.
The running version is shown at the bottom of the sidebar.
Pinning a version
CONSULTAINER_VERSION in .env picks the image tag:
| Tag | What you get |
|---|---|
latest (default) | Every change merged to main, once its tests have passed. Upgrades whenever you pull. |
latest-bleeding | Every change merged to main as soon as it's built, before the tests finish. For trying a fix right away; it can be broken. |
1.2.3 | Exactly that release. Never changes. |
1.2 | The newest 1.2.x patch release. |
1 | The newest 1.x release (not published for 0.x). |
To upgrade on your own schedule, set an exact release (for example
CONSULTAINER_VERSION=0.2.0), then change it and run the upgrade commands
when you're ready. Release notes are on the repository's
releases page.
Backups
Back up three things:
- The database. The
backupservice does this for you every night. Without object storage, the database also holds every uploaded file, so the dump includes them. - Your
.env, above allINTEGRATION_ENCRYPTION_KEY. A database backup restored with a different key loses every SSO, integration and calendar credential. Keep a copy somewhere other than this server, such as a password manager. - The bucket, if you use object storage. Uploads in the
bucket are not in the database dump. Use your provider's versioning or
replication, or copy the bucket with a tool like
rclone.
What runs
The backup service in docker-compose.yml is on by default. Every night at
BACKUP_HOUR (UTC) it runs pg_dump against the app's database and writes a
compressed custom-format dump named like
consultainer-20261010T030000Z.dump. It keeps the newest BACKUP_KEEP
dumps and deletes older ones. A dump is written under a temporary name first,
so a failed run never replaces a good backup. It's safe while the app is
running.
| Variable | Default | What it does |
|---|---|---|
BACKUP_ENABLED | true | false turns nightly backups off. |
BACKUP_HOUR | 3 | Hour of the day to run, 0–23, in UTC. |
BACKUP_KEEP | 14 | How many dumps to keep, here and in the bucket. |
BACKUP_DIR | the backups volume | A folder on this server to write to instead, e.g. /srv/consultainer-backups or ./backups. |
BACKUP_S3 | false | true also copies each dump to your S3 bucket. See Copy backups off the server. |
Set them in .env, then run docker compose up -d. Passwords are passed
through the environment and are never written to the log.
Check it worked: run a backup now and list what's there.
docker compose exec backup backup-now
docker compose exec backup ls -lh /backupsYou should see backup: wrote consultainer-....dump (...). The service's
log (docker compose logs backup) starts with
backup: nightly at 03:00 UTC to /backups, keeping 14 and records every run.
Where the files are
By default the dumps live in the backups Docker volume. To copy one out to
the current directory:
docker compose cp backup:/backups/consultainer-20261010T030000Z.dump .With BACKUP_DIR set, they're plain files in that folder, readable only by
root (they contain your data, including password hashes and encrypted
credentials).
Copy backups off the server
A backup on the same disk doesn't help if the server is lost. Either copy the dump files elsewhere yourself, or let the service upload them to the S3-compatible bucket you set up for file storage:
Set the S3 variables
Fill in
S3_BUCKET,S3_REGION,S3_ENDPOINT,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEYandS3_FORCE_PATH_STYLEin.envas described in File storage. The key needs to list, write and delete objects in the bucket.Turn on the upload
Add
BACKUP_S3=trueto.envand rundocker compose up -d.Run a backup
docker compose exec backup backup-nowYou should see
backup: copied consultainer-....dump to s3://<bucket>/backups/.
Dumps go under backups/ in the bucket (after S3_PREFIX, if you set one),
and the bucket also keeps only the newest BACKUP_KEEP. The upload uses
rclone, which the service installs the first time it
needs it. To keep backups for longer than that, use a separate bucket with
versioning or object lock, or copy them elsewhere.
Setting the S3_* variables also moves new uploads into the bucket. If you
only want off-site backups, that's fine too: existing files keep working,
and new ones get the larger 25 MB limit.
Turn it off
Set BACKUP_ENABLED=false in .env and run docker compose up -d. The
service stays up but idle (its log says backup: nightly backups are off),
and backup-now and restore still work by hand. Turn it off if you back up
Postgres another way, for example with snapshots of the whole server.
Restore
Restore into the same version of Consultainer you backed up from, or a newer
one. Use the same INTEGRATION_ENCRYPTION_KEY as the original instance.
Stop the app and scheduler
docker compose stop consultainer-app schedulerLeave
consultainer-dbandbackuprunning. On a new server, copy overdocker-compose.ymland the original.env, then rundocker compose up -d consultainer-db backup.Put the dump where the backup service can read it
Restoring a nightly backup on the same server? Skip this step. To list what's there, run
docker compose exec backup restore.For a dump from somewhere else (your bucket, another machine), copy it in:
docker compose cp consultainer-20261010T030000Z.dump backup:/backups/With
BACKUP_DIRset, you can instead put the file in that folder.Restore it
docker compose exec backup restore consultainer-20261010T030000Z.dump --yesThis deletes the current database, creates an empty one and loads the dump into it. You should see
restore: done.If the file isn't a valid dump, it stops before deleting anything.Start everything
docker compose up -dThe app applies any migrations newer than the backup on start.
Check it worked: you can sign in with an existing account, your recent clients and invoices are there, and Settings → Integrations shows your integrations as connected rather than asking for credentials again (if it asks, the encryption key doesn't match).
Restoring a dump made by hand, without the backup service
A dump from pg_dump -Fc (for example
docker compose exec -T consultainer-db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > consultainer.dump)
restores the same way: copy it in and run restore. For a plain .sql
dump, recreate the database and load it with psql:
docker compose exec -T consultainer-db sh -c 'dropdb -U "$POSTGRES_USER" --force --if-exists "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
docker compose exec -T consultainer-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < consultainer.sqlTroubleshooting
docker compose says “Set AUTH_SECRET in .env” (or INTEGRATION_ENCRYPTION_KEY, CRON_SECRET)
That variable is empty or missing in .env. Generate it as in
Install and run docker compose up -d from the directory
that contains both docker-compose.yml and .env.
“INTEGRATION_ENCRYPTION_KEY must decode to exactly 32 bytes.”
The key isn't 32 bytes of base64. Generate it with
openssl rand -base64 32. Only change it on an instance that has no
saved integrations yet; otherwise they can no longer be read.
The app keeps restarting and the log shows “password authentication failed”
POSTGRES_PASSWORD (or POSTGRES_USER) in .env was changed after the
database volume was created. Postgres still has the original values.
Put the original values back. On a brand-new install with nothing to
keep, you can instead delete the volume with docker compose down -v
and start again.
The app keeps restarting during “Running database migrations...”
Read the error above it with docker compose logs consultainer-app. The
most common cause is the database being unreachable or out of disk
space. If a migration failed after a downgrade or a manual schema
change, restore the backup taken before the upgrade.
The setup page says “Setup token doesn't match SETUP_TOKEN.”
Enter the exact SETUP_TOKEN value from .env. If you changed .env
after starting, run docker compose up -d so the app sees the new value.
SSO or an OAuth provider says the redirect URI doesn't match
The app built an http:// address or the wrong host. Behind a proxy,
make sure it sends Host and X-Forwarded-Proto (see
the examples). Then open the
settings card on the address people actually use and register exactly
the URL it shows.
Saving forms fails behind a proxy, or the log shows “Invalid Server Actions request”
The proxy is rewriting the Host header to the upstream address.
Forward the original Host (nginx: proxy_set_header Host $host;).
Uploads fail with 413 Request Entity Too Large
Raise the proxy's request size limit, for example
client_max_body_size 30m; in nginx.
Links in emails and invites point at the wrong address
The owner can fix the address in Settings → General → Instance URL.
If that card shows Set by AUTH_URL, change AUTH_URL in .env and
run docker compose up -d.
Recurring invoices, reminders or syncs never happen
Check the scheduler is running (docker compose ps) and
run a job by hand. An Unauthorized reply means
CRON_SECRET differs between the scheduler and the app.
The backup log shows “backup: pg_dump failed, no backup written”
The lines above it are Postgres's own error. password authentication failed: POSTGRES_PASSWORD in .env differs from the one the database
was created with (see the entry above). could not translate host name:
consultainer-db isn't running. No space left on device: free up disk
or lower BACKUP_KEEP. Nothing is deleted when a run fails, and the
service tries again the next night.
Backups are written but not copied to the bucket
The lines after backup: wrote ... are rclone's error. AccessDenied
or SignatureDoesNotMatch: the key can't write to the bucket, or it's
wrong. NoSuchBucket or a DNS error: check S3_BUCKET, S3_REGION and
S3_ENDPOINT (MinIO also needs S3_FORCE_PATH_STYLE=true). An
apk error means the server couldn't download rclone; it needs
outbound internet access the first time. The local dump is kept either
way.
The File storage card says “Can't reach the bucket”
The error name after it comes from the S3 client. NoSuchBucket: wrong
S3_BUCKET or region. InvalidAccessKeyId or SignatureDoesNotMatch:
wrong keys. PermanentRedirect or a DNS error: set S3_REGION to the
bucket's region, or set S3_ENDPOINT. For MinIO, set
S3_FORCE_PATH_STYLE=true.
Calendar links or a Slack webhook on my LAN aren't reached
Private-network addresses are blocked by default. Set
ALLOW_PRIVATE_FETCH=true and run docker compose up -d.
Someone is locked out
Try these in order:
- Forgot password? on the login page emails a reset link, if email is set up.
- Another owner, or an admin for anyone who isn't an owner, opens Settings → Members, picks Create password reset link from the ⋯ menu, and sends them the link. See Password resets.
- Lost authenticator: sign in with one of the recovery codes saved when two-factor authentication was turned on, then turn it off and on again on Profile.
If none of those work, for example a single owner who has lost both their password and their authenticator on a server without email, reset it from the server. Generate a hash for the new password:
docker compose exec consultainer-app node -e 'console.log(require("bcryptjs").hashSync(process.argv[1], 12))' 'a-new-long-password1'Then set it (this also signs out their existing sessions), and turn off two-factor authentication if the authenticator is lost too:
docker compose exec consultainer-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"'UPDATE "User" SET "passwordHash" = '<hash from above>', "sessionVersion" = "sessionVersion" + 1 WHERE email = 'you@example.com';
-- only if needed:
UPDATE "User" SET "twoFactorEnabled" = false, "twoFactorSecret" = NULL, "twoFactorRecoveryCodes" = '{}' WHERE email = 'you@example.com';If the organization has Require two-factor authentication on, they're asked to set up a new authenticator app right after logging in.