Consultainer Docs

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:

ServiceWhat it is
consultainer-dbPostgres 16. Data lives in the db_data Docker volume.
consultainer-appThe 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.
schedulerA small loop that calls the app's scheduled jobs.
backupDumps 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

  1. 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.example
  2. Generate 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_SECRET

    INTEGRATION_ENCRYPTION_KEY must be base64 that decodes to exactly 32 bytes, which is what openssl rand -base64 32 produces. Don't use a hex value or a password for it.

    Save a copy of INTEGRATION_ENCRYPTION_KEY somewhere 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.

  3. Set a database password

    Change POSTGRES_PASSWORD=change-me to a long random value, for example the output of openssl rand -hex 24. Leave POSTGRES_USER and POSTGRES_DB as they are unless you have a reason to change them.

    Postgres only reads these values the first time it creates the db_data volume. Changing them later doesn't change the database; see Troubleshooting.

  4. 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_TOKEN
  5. Start it

    docker compose up -d
    docker compose logs -f consultainer-app

    Wait for Running database migrations... followed by Starting server, then press Ctrl+C to stop following the log (the app keeps running).

  6. Open the app and create your workspace

    Go to http://<this-host>:3113 (or your APP_PORT). A new database sends every page to /setup. Fill in:

    • Setup token: the SETUP_TOKEN value (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_URL is 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.

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.ts

Then 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 --build

Settings (.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).

VariableWhat it's forHow to get it
AUTH_SECRETSigns login sessions and OAuth state. Changing it signs everyone out.openssl rand -base64 32
INTEGRATION_ENCRYPTION_KEYEncrypts SSO secrets, integration credentials and tokens, and calendar links in the database. Back it up.openssl rand -base64 32
CRON_SECRETLets the scheduler call /api/cron/*.openssl rand -hex 32
POSTGRES_PASSWORDPassword of the database the compose file creates.Any long random string, e.g. openssl rand -hex 24
POSTGRES_USER, POSTGRES_DBDatabase 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.

VariableWhat it's for
SETUP_TOKENRequired 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_PORTHost 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_VERSIONImage tag to run. See Pinning a version.

Optional

VariableWhat it's for
AUTH_URLForces one public URL for everything, including sign-in redirects. Usually leave it unset; see URLs.
ALLOW_PRIVATE_FETCHtrue 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_SECRETShows 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_PASSWORDEmail through Resend or any SMTP server. Can also be set under Settings → Integrations. See Email.
QUICKBOOKS_CLIENT_ID, QUICKBOOKS_CLIENT_SECRET, QUICKBOOKS_ENVIRONMENTQuickBooks app credentials.
LINEAR_CLIENT_ID, LINEAR_CLIENT_SECRET, LINEAR_WEBHOOK_SECRETLinear OAuth app, and its webhook signing secret for live updates.
DOCUSIGN_CLIENT_ID, DOCUSIGN_CLIENT_SECRET, DOCUSIGN_ENVIRONMENTDocuSign app (integration key, secret key, demo or production) for pulling signed agreements.
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRETStripe, 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_PREFIXKeep 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:

HeaderWhy 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-ProtoWithout 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).

Caddyfile
time.example.com {
	reverse_proxy 127.0.0.1:3113
}

Caddy gets a certificate automatically and already sends the right headers. Traefik also sends them by default.

Then:

  1. 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.

  2. 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 /setup and only the Owner can change it, under Settings → General → Instance URL. These links never trust the request's Host header, 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:

PathWhy
/api/v1/*, /api/mcpREST 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.

EndpointRunsWhat it does
/api/cron/mercury-syncEvery hourEmails draft invoices scheduled to send by now, then checks Mercury and marks invoices paid when the client has paid.
/api/cron/linear-syncEvery hourSyncs every linked project with Linear (issues and comments) and retries failed pushes.
/api/cron/calendar-syncEvery hourPulls 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-syncEvery hourPulls newly completed agreements from every connected DocuSign, Documenso and Ironclad account.
/api/cron/scheduling-syncEvery hourRefreshes 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/filingEvery hourFiles invoices and documents that didn't reach the organization's filing folder yet, or changed since.
/api/cron/recurring-invoicesOnce a dayCreates 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:

  1. 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.

  2. 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:DeleteObject on arn:aws:s3:::consultainer/* and s3:ListBucket on arn: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.
  3. 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 bucket
  4. Apply it

    docker compose up -d
  5. Check 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.

  6. 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.mts

    It 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

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):

  1. 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).
  2. Create a client of type Web application with the redirect URI the card shows: <your URL>/api/auth/callback/google.
  3. 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-app

The 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:

TagWhat you get
latest (default)Every change merged to main, once its tests have passed. Upgrades whenever you pull.
latest-bleedingEvery 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.3Exactly that release. Never changes.
1.2The newest 1.2.x patch release.
1The 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:

  1. The database. The backup service does this for you every night. Without object storage, the database also holds every uploaded file, so the dump includes them.
  2. Your .env, above all INTEGRATION_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.
  3. 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.

VariableDefaultWhat it does
BACKUP_ENABLEDtruefalse turns nightly backups off.
BACKUP_HOUR3Hour of the day to run, 0–23, in UTC.
BACKUP_KEEP14How many dumps to keep, here and in the bucket.
BACKUP_DIRthe backups volumeA folder on this server to write to instead, e.g. /srv/consultainer-backups or ./backups.
BACKUP_S3falsetrue 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 /backups

You 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:

  1. Set the S3 variables

    Fill in S3_BUCKET, S3_REGION, S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY and S3_FORCE_PATH_STYLE in .env as described in File storage. The key needs to list, write and delete objects in the bucket.

  2. Turn on the upload

    Add BACKUP_S3=true to .env and run docker compose up -d.

  3. Run a backup

    docker compose exec backup backup-now

    You 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.

  1. Stop the app and scheduler

    docker compose stop consultainer-app scheduler

    Leave consultainer-db and backup running. On a new server, copy over docker-compose.yml and the original .env, then run docker compose up -d consultainer-db backup.

  2. 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_DIR set, you can instead put the file in that folder.

  3. Restore it

    docker compose exec backup restore consultainer-20261010T030000Z.dump --yes

    This 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.

  4. Start everything

    docker compose up -d

    The 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.sql

Troubleshooting

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:

  1. Forgot password? on the login page emails a reset link, if email is set up.
  2. 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.
  3. 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.