ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
---
|
||||
title: Backups and restore
|
||||
section: selfhost
|
||||
order: 4
|
||||
summary: Nightly database dumps and photo copies from the compose file's own services and volumes, the in-app export as a second copy, and how to restore and test a restore.
|
||||
role: Self-hosting admin
|
||||
keywords: backup, restore, pg_dump, pg_restore, database, photos, signatures, volume, cron, export, import, disaster recovery, test restore
|
||||
---
|
||||
|
||||
## What has to be kept
|
||||
|
||||
A Community instance keeps its data in two Docker volumes. Back up both, together.
|
||||
|
||||
| Volume | Mounted at | Holds |
|
||||
|---|---|---|
|
||||
| `threadcount_db` | `/var/lib/postgresql/data` in `db` | Every record: catalogue, stock, staff, issues, orders, requests, users |
|
||||
| `threadcount_photos` | `/data/photos` in `app` | Signatures and damage photographs |
|
||||
|
||||
The names come from `docker-compose.yml`: the project is named `threadcount`, and its volumes are `db` and `photos`.
|
||||
|
||||
Photos are not in the database. Each one is a file at `<facility id>/<photo id>.jpg` (or `.png`) under `/data/photos`, and the database holds only that path. A database dump without the photos restores every record but none of the signatures or photographs.
|
||||
|
||||
## Nightly dumps
|
||||
|
||||
The checkout includes `docker/backup.sh`. Run it from the checkout and give it a destination directory:
|
||||
|
||||
```sh
|
||||
cd /srv/threadcount
|
||||
docker/backup.sh /srv/backups/threadcount
|
||||
```
|
||||
|
||||
Each run writes a dated directory, for example `2026-09-15-033001`, containing:
|
||||
|
||||
- `threadcount.dump`: the database as a custom-format `pg_dump`, taken inside `db` as the `threadcount` user
|
||||
- `photos.tgz`: the photos directory copied out of `app`
|
||||
|
||||
It keeps the newest 14 directories and deletes older ones. Run it nightly from cron:
|
||||
|
||||
```
|
||||
30 3 * * * cd /srv/threadcount && docker/backup.sh /srv/backups/threadcount >> /var/log/threadcount-backup.log 2>&1
|
||||
```
|
||||
|
||||
> **Careful** A backup on the same disk as the volumes is lost with them. Copy the backup directory to another machine.
|
||||
|
||||
## The export as a second copy
|
||||
|
||||
An admin can download the whole facility as one JSON file from `Settings › Data & audit log` with Export backup. It needs no access to the server's command line, and it can be imported on another server.
|
||||
|
||||
| Part | In the export |
|
||||
|---|---|
|
||||
| All facility records | Yes |
|
||||
| Photos | The newest ones only: at most 2000 photos and 40 MB of image data. The screen says how many older photos were left out. |
|
||||
| Users (admins and issuers) | No |
|
||||
| Staff app logins | No. A restore keeps the existing logins and re-attaches them by staff number. |
|
||||
|
||||
Import backup on the same screen asks you to confirm, then replaces all of that facility's data with the file's. Users are kept. See [export and backup](/docs/account/export-and-backup).
|
||||
|
||||
Because it leaves out older photos and users, the export is a second copy, not a replacement for the dump.
|
||||
|
||||
The Backup line on that screen shows the date of the last export. For admins, `Settings` in the menu shows `!` when that export is more than 7 days old or has never been taken. A run of `docker/backup.sh` does not change that date.
|
||||
|
||||
## Restore from a dump
|
||||
|
||||
This replaces everything on the server with the backup. Choose the backup directory first; these steps use `2026-09-15-033001`.
|
||||
|
||||
1. **Extract the photos.**
|
||||
|
||||
```sh
|
||||
cd /srv/backups/threadcount/2026-09-15-033001
|
||||
tar -xzf photos.tgz
|
||||
```
|
||||
|
||||
2. **Stop the stack and remove both volumes.**
|
||||
|
||||
```sh
|
||||
cd /srv/threadcount
|
||||
docker compose down
|
||||
docker volume rm threadcount_db threadcount_photos
|
||||
```
|
||||
|
||||
3. **Start only the database, then load the dump.**
|
||||
|
||||
```sh
|
||||
docker compose up -d db
|
||||
docker compose exec -T db pg_restore -U threadcount -d threadcount --clean --if-exists < /srv/backups/threadcount/2026-09-15-033001/threadcount.dump
|
||||
```
|
||||
|
||||
4. **Start everything.** `migrate` runs first and finds nothing to apply that the dump does not already have.
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **Put the photos back and give them to the app's user.**
|
||||
|
||||
```sh
|
||||
docker compose cp /srv/backups/threadcount/2026-09-15-033001/photos app:/data/
|
||||
docker compose exec -u root app chown -R threadcount:threadcount /data/photos
|
||||
```
|
||||
|
||||
Restore onto the release the dump was taken from, or a later one. Migrations only go forwards.
|
||||
|
||||
> **Careful** `docker volume rm` deletes the live data. Take a fresh backup first if the current data might still be needed.
|
||||
|
||||
## Test a restore
|
||||
|
||||
A backup that has never been restored is not proven. Restore into a second, separate stack on the same server, under another project name and port, so the live instance is untouched:
|
||||
|
||||
```sh
|
||||
cd /srv/threadcount
|
||||
APP_PORT=3100 docker compose -p tcrestore up -d db
|
||||
docker compose -p tcrestore exec -T db pg_restore -U threadcount -d threadcount --clean --if-exists < /srv/backups/threadcount/2026-09-15-033001/threadcount.dump
|
||||
APP_PORT=3100 docker compose -p tcrestore up -d --build
|
||||
curl -fsS http://127.0.0.1:3100/api/health
|
||||
```
|
||||
|
||||
Then check what came back:
|
||||
|
||||
- **Sign in.** Forward the port with `ssh -L 3100:127.0.0.1:3100` and open `http://localhost:3100` on your own machine.
|
||||
- **Compare the counts.** The line at the top of `Settings › Data & audit log` gives active staff, garments, issues and orders. Check it against the live instance.
|
||||
|
||||
When you are done, remove the test stack and its volumes:
|
||||
|
||||
```sh
|
||||
docker compose -p tcrestore down -v
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Configuration reference
|
||||
section: selfhost
|
||||
order: 6
|
||||
summary: Every environment variable, whether it is required, its default and what it does, with the hosted-only ones marked.
|
||||
role: Self-hosting admin
|
||||
keywords: configuration, environment, variables, env, .env, settings, reference, session secret, smtp, turnstile, signups disabled, app port, edition, hosted only
|
||||
---
|
||||
|
||||
## How settings are read
|
||||
|
||||
Settings go in `.env` in the checkout. `docker-compose.yml` passes the whole file to the `app` container, then sets five values itself, and those win over `.env`:
|
||||
|
||||
| Variable | Set by compose to |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | The bundled `db` service, using `POSTGRES_PASSWORD` |
|
||||
| `EDITION` | `community` |
|
||||
| `PHOTO_DIR` | `/data/photos` |
|
||||
| `PORT` | `3000` |
|
||||
| `HOSTNAME` | `0.0.0.0` |
|
||||
|
||||
Variables starting `NEXT_PUBLIC_` are compiled into the app when the image is built. The build does not read `.env`: compose passes only `NEXT_PUBLIC_SITE_URL` and `NEXT_PUBLIC_TURNSTILE_SITEKEY` into it. Any other `NEXT_PUBLIC_` variable in `.env` has no effect on a Docker install. A change to either of those two needs `docker compose up -d --build`; any other change needs `docker compose up -d`.
|
||||
|
||||
The repository's `.env.example` lists the variables the Community edition reads. The tables below also list the ones only threadcount.tech uses, marked hosted only, so you know they can stay blank.
|
||||
|
||||
## Required
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `SESSION_SECRET` | `change-me` | Signs every session cookie. The server refuses to start with the placeholder or with none. Make one with `openssl rand -base64 48`. |
|
||||
| `POSTGRES_PASSWORD` | none | The bundled database's password. Compose refuses to start without it. |
|
||||
| `NEXT_PUBLIC_SITE_URL` | `http://localhost:3000` | The address people open. Every link in an email is built from it. Build-time. |
|
||||
| `EDITION` | none | `community`. Turns off plans, the staff ceiling, the demo and reporting to ThreadCount, and makes Turnstile optional. |
|
||||
| `DATABASE_URL` | set by compose | The Postgres connection. Required outside compose. |
|
||||
|
||||
## Mail
|
||||
|
||||
See [email](/docs/selfhost/email).
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | none | Mail server. Mail is on only when this, `SMTP_USER` and `SMTP_PASS` are all set. |
|
||||
| `SMTP_PORT` | `587` | `465` connects with TLS from the start. |
|
||||
| `SMTP_USER` | none | Mail server login |
|
||||
| `SMTP_PASS` | none | Mail server password |
|
||||
| `SMTP_FROM` | `SMTP_USER` | The From line |
|
||||
| `CONTACT_TO` | none | Hosted only. Where the website's contact form is delivered. |
|
||||
|
||||
## Switches and the server
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `SIGNUPS_DISABLED` | open | `1` hides facility sign-up and refuses the sign-up endpoint. |
|
||||
| `APP_PORT` | `3000` | The port on the server the app is published on. Compose reads it; the container always listens on 3000. |
|
||||
| `PHOTO_DIR` | set by compose | Where signatures and photos are written. Back it up with the database. |
|
||||
| `TURNSTILE_SECRET` | none | Cloudflare Turnstile on sign-in, sign-up and password reset. Enforced only when set. |
|
||||
| `NEXT_PUBLIC_TURNSTILE_SITEKEY` | none | Turnstile's site key. Build-time. Set both or neither. |
|
||||
| `TURNSTILE_OPTIONAL` | none | `1` lets production run without Turnstile. For local tests; a Community instance does not need it. |
|
||||
| `DB_POOL_MAX` | the driver's default | Local development only. `1` makes every database call wait for the one before. Never set it on a server. |
|
||||
|
||||
## Documents, errors, statistics and chat
|
||||
|
||||
These are all `NEXT_PUBLIC_` variables, so as the first section explains, they have no effect on a Docker install today.
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_TERMS_URL` | none | Your terms page, linked from the staff sign-in and account screens. With none, no link shows. |
|
||||
| `NEXT_PUBLIC_PRIVACY_URL` | none | Your privacy notice, linked the same way |
|
||||
| `NEXT_PUBLIC_GLITCHTIP_DSN` | none | Your own GlitchTip or other Sentry-protocol address for error reports. With none, nothing is reported. |
|
||||
| `NEXT_PUBLIC_RELEASE` | none | A label stamped on error reports; also shown as `version` at `/api/app-info` when there is no `COMMUNITY_VERSION` file |
|
||||
| `NEXT_PUBLIC_UMAMI_SRC` | none | Your own Umami tracker script. With none, no statistics are sent. |
|
||||
| `NEXT_PUBLIC_UMAMI_SITE_ID` | none | Umami site id |
|
||||
| `NEXT_PUBLIC_UMAMI_APP_ID` | none | Umami id for the app |
|
||||
| `NEXT_PUBLIC_CHATWOOT_URL` | none | Your own Chatwoot. With this and the token set, a chat widget shows in the coordinator app. |
|
||||
| `NEXT_PUBLIC_CHATWOOT_TOKEN` | none | Chatwoot website token |
|
||||
|
||||
## Hosted only
|
||||
|
||||
Nothing in the Community edition reads these. Leave them blank.
|
||||
|
||||
| Variable | Used on threadcount.tech for |
|
||||
|---|---|
|
||||
| `JACKSON_URL`, `JACKSON_API_KEY` | Single sign-on for facilities |
|
||||
| `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD` | Sign-in to ThreadCount's own staff tools |
|
||||
| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_ANNUAL`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Card payments |
|
||||
| `INVOICE_ENTITY`, `INVOICE_ABN` | Who invoices hosted plans |
|
||||
| `PLANS_LIVE` | Forcing plans on. Ignored when `EDITION=community`. |
|
||||
| `DEMO_DISABLED`, `DEMO_RESET_TOKEN` | The public demo facility |
|
||||
| `ANDROID_APP_FINGERPRINTS_COUNTER`, `ANDROID_APP_FINGERPRINTS_STAFF` | Android App Links for ThreadCount's own Play apps |
|
||||
| `LISTMONK_URL`, `LISTMONK_LIST_UUID` | The product-update mailing list |
|
||||
| `CHATWOOT_URL`, `CHATWOOT_API_TOKEN`, `CHATWOOT_ACCOUNT_ID`, `CHATWOOT_INBOX_ID` | Filing contact-form messages in the helpdesk |
|
||||
|
||||
threadcount.tech's own settings file has four more variables, for its internal administration. The Community edition does not contain that code.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Email
|
||||
section: selfhost
|
||||
order: 5
|
||||
summary: The SMTP settings, every email the product sends, what each screen does when no mail is configured, and how to test it.
|
||||
role: Self-hosting admin
|
||||
keywords: email, mail, smtp, password reset, approval link, notifications, ready to collect, supplier order, orders, order and email, no-reply, from address, test email
|
||||
---
|
||||
|
||||
## The settings
|
||||
|
||||
Email is optional. It is set with five variables in `.env`:
|
||||
|
||||
| Setting | Required for mail | What it does |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | Yes | Your mail server's hostname |
|
||||
| `SMTP_PORT` | No | Defaults to `587`. On `465` the connection uses TLS from the start; on any other port it starts plain and upgrades if the server offers it. |
|
||||
| `SMTP_USER` | Yes | The login for the mail server |
|
||||
| `SMTP_PASS` | Yes | The password for that login |
|
||||
| `SMTP_FROM` | No | The From line, e.g. `"ThreadCount <no-reply@example.health>"`. If blank, `SMTP_USER` is used. |
|
||||
|
||||
Mail counts as configured only when `SMTP_HOST`, `SMTP_USER` and `SMTP_PASS` are all set. A mail server that needs no login cannot be used.
|
||||
|
||||
These are read while the app runs, not when it is built, so a change needs a restart but no rebuild:
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Links in emails are built from `NEXT_PUBLIC_SITE_URL`, which is compiled in. If the links are wrong, fix that setting and rebuild with `docker compose up -d --build`.
|
||||
|
||||
## What is sent
|
||||
|
||||
| Email | Sent to | When |
|
||||
|---|---|---|
|
||||
| Facility set up | The person who signed up | A facility is created |
|
||||
| Password reset | An admin or issuer | They ask on the sign-in page. The link works once and expires in 1 hour. |
|
||||
| Uniform request needing approval | The manager, with a link to approve | A request is raised in the staff app, or re-addressed to another manager from the queue at `/app/requests` |
|
||||
| Decision | The person the request is for | A manager approves or declines, or someone uses Withdraw it in the requests queue |
|
||||
| Ready to collect, or coming on the round | The person the request is for | The request is held at the counter or sent on the ward round from the requests queue |
|
||||
| Waitlist offer | The person on the waitlist | A garment is offered to them, with the time it is held until |
|
||||
| Supplier order | The supplier's email under `Settings › Catalogue & suppliers` | An admin uses Order and email on `Orders`, or Email supplier on the order's page. Replies go to that admin's address. |
|
||||
|
||||
Staff notices go only to people whose staff app login has an email address. See [the staff app](/docs/apps/staff-app) and [requests from staff](/docs/counter/requests-from-staff).
|
||||
|
||||
When an admin adds a user, the product does not email them their password.
|
||||
|
||||
## With no mail configured
|
||||
|
||||
Nothing is sent, and the log records `[mail] no SMTP configured — not sending:` followed by the subject. The work itself is still recorded.
|
||||
|
||||
- **Sign-up.** The facility is created. The screen says no mail is configured, so the address has not been checked.
|
||||
- **Requests and decisions.** The request is raised and the decision is recorded. When someone raises a request in the staff app, the screen says their manager hasn't been emailed. After a decision on the approval-link page, it says the wearer hasn't been emailed. A decision made in the staff app does not say whether the wearer was emailed. The requests queue in the coordinator app does not show whether anyone was emailed.
|
||||
- **Supplier orders.** The order is still raised, but it is not emailed. The screen says "Email is not set up on this server — print the order instead."
|
||||
- **Password reset.** No link is sent, but the sign-in page still shows "Reset link sent". The only way back in is another admin setting a new password under `Settings › People & sign-in`.
|
||||
|
||||
> **Careful** Without mail, a facility whose only admin forgets their password cannot get back in through the product. Keep a second admin.
|
||||
|
||||
## When sending fails
|
||||
|
||||
If mail is configured but the server refuses a message, the action that caused the email still succeeds and `[mail] send failed:` is logged with the reason. Emailing a supplier order is the exception: the screen says "The email could not be sent — try again in a minute, or print the order." and the order is not marked as emailed. Print and CSV stay on the order.
|
||||
|
||||
## Test it
|
||||
|
||||
1. **Restart after setting the variables.** Run `docker compose up -d`.
|
||||
2. **Ask for a password reset for your own address.** On the sign-in page, enter your email and use the forgot-password link.
|
||||
3. **Check the inbox and the log.**
|
||||
|
||||
```sh
|
||||
docker compose logs app | grep '\[mail\]'
|
||||
```
|
||||
|
||||
If a reset email arrives and there is no `[mail]` line, mail works. At most 4 reset emails go to one address in an hour. Check that the link in the email starts with your own address, not `http://localhost:3000`.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: First run
|
||||
section: selfhost
|
||||
order: 2
|
||||
summary: The first sign-up creates the facility and its admin; then groups, data, a second admin, closing sign-ups, and what the Community edition leaves out.
|
||||
screen: Settings
|
||||
role: Self-hosting admin
|
||||
keywords: first run, sign up, create account, facility, admin, staff groups, routes, import, csv, checklist, today, second admin, signups disabled, community, hosted, differences
|
||||
---
|
||||
|
||||
## Create the facility
|
||||
|
||||
There is no default username or password. Whoever creates an account first creates a facility and becomes its admin.
|
||||
|
||||
1. **Open your address.** `/` sends you to the sign-in page at `/auth`.
|
||||
2. **Choose to create the facility's account.** Sign-up is three steps.
|
||||
3. **Step 1, You.** Your first and last name, your work email and a password of at least 8 characters. The email is where a password reset goes, so it has to be right. If you have set `NEXT_PUBLIC_TERMS_URL` or `NEXT_PUBLIC_PRIVACY_URL`, you also tick that you agree to them.
|
||||
4. **Step 2, Your facility.** The facility name, plus two optional answers. The setting (hospital, aged care or community) chooses a starting list of staff groups. The state sets the time zone that counts and month-end use.
|
||||
5. **Step 3, Confirm.** You are signed in as the facility's admin. Open ThreadCount on the next screen takes you to `Today`, where the Welcome to ThreadCount checklist lists six first steps, each with a button while it is not done.
|
||||
|
||||
| Record | Change | Undo |
|
||||
|---|---|---|
|
||||
| Facility | Created with your facility name | Delete the account under `Settings › People & sign-in` |
|
||||
| User | You, role Admin, title Uniform Coordinator | Edit under `Settings › People & sign-in` |
|
||||
|
||||
The address you typed is not verified. If mail is set up, a welcome note goes to it. If it is not, the screen says so and asks you to make sure the address is right.
|
||||
|
||||
## Staff groups first
|
||||
|
||||
Open `Settings › Issuing rules` and find Staff groups and how they get uniform. It is a board with one column per route: FTE table, Starting kit and Manager approval. If you chose a setting at sign-up, a starting list is there; drag a group to another column, or use its menu to rename or remove it. Otherwise the board says "No staff groups yet — everyone is on manager approval." Add a group with New group name and Add group.
|
||||
|
||||
Each group takes one route to its kit. The routes are explained in [groups and routes](/docs/people/groups-and-routes).
|
||||
|
||||
## Load your data
|
||||
|
||||
`Settings › Data & audit log` has Import from CSV. Choose the kind of file (Catalogue, Staff register, Departments & cost centres, Supplier barcodes, Reorder levels or Opening balances), use Download template for its columns, then Import CSV. Re-importing updates matching rows. The columns are in [CSV templates](/docs/reference/csv-templates).
|
||||
|
||||
For admins, Import the register on `People` opens this screen with Staff register already chosen. The first two checklist steps on `Today` open this screen too.
|
||||
|
||||
## Add a second admin
|
||||
|
||||
Do this before you sign out.
|
||||
|
||||
1. **Open `Settings › People & sign-in`.** Users comes after Two-factor, and only admins see it.
|
||||
2. **Choose Add user.** Enter the first and last name, an optional title, the role (Admin or Issuer), the work email and a password of at least 8 characters.
|
||||
3. **Hand the password over yourself.** The screen says so: passwords set here are not emailed.
|
||||
|
||||
An admin can later set a new password for another user with Edit on the same list, in New password (leave blank to keep). That is the way back in when mail is not configured: the forgot-password form sends nothing without mail, so a facility whose one admin forgets their password has nobody who can reset it. See [users](/docs/account/users).
|
||||
|
||||
## Close sign-ups
|
||||
|
||||
Once your facility exists, stop anyone else creating one on your server:
|
||||
|
||||
```sh
|
||||
# in .env
|
||||
SIGNUPS_DISABLED=1
|
||||
```
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
With `SIGNUPS_DISABLED=1` the sign-in page no longer offers to create a facility, and the sign-up endpoint answers `403` with "New facility sign-ups are closed." Nothing in the product can reopen sign-ups while the variable is set.
|
||||
|
||||
> **Careful** While sign-ups are open, any visitor to your address can create their own facility on your database.
|
||||
|
||||
## What differs from hosted
|
||||
|
||||
The Community edition is built from the same code, with the parts that belong to threadcount.tech removed before release. Your instance has the coordinator app at `/app`, the phone counter at `/m` and the staff app at `/my`, in full.
|
||||
|
||||
| Area | Hosted | Community |
|
||||
|---|---|---|
|
||||
| Plans, staff ceiling, trials | Yes | None. Every facility has everything, with no limit on staff records. |
|
||||
| Single sign-on | Yes | Not included. Password plus an authenticator code. |
|
||||
| Health-service organisations | Yes | Not included |
|
||||
| Card payments | Yes | Not included |
|
||||
| Public website, guides, pricing, legal pages, demo | Yes | Not included. `/` goes to sign-in. |
|
||||
| Cloudflare Turnstile | Required | Optional. Without it the per-address limits stand alone. |
|
||||
| Error reports and usage statistics | Sent to ThreadCount | Sent nowhere |
|
||||
| Android App Links | Yes | Not included |
|
||||
| Email | ThreadCount's mail server | Yours, or none |
|
||||
|
||||
The published Android apps can still use your server: on the app's first screen, change the server, pick Self-hosted and enter your hostname. See [the counter app](/docs/apps/counter-app).
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: Install the Community edition
|
||||
section: selfhost
|
||||
order: 1
|
||||
summary: What the server needs, the four settings that must be set, starting it with Docker Compose, the HTTPS proxy in front, and how to tell it is up.
|
||||
role: Self-hosting admin
|
||||
keywords: install, self-host, docker, compose, community edition, server, port, proxy, caddy, https, health check, requirements
|
||||
---
|
||||
|
||||
## What you need
|
||||
|
||||
The Community edition runs as three containers from one `docker-compose.yml`. Before you start you need:
|
||||
|
||||
- A Linux server with Docker and Docker Compose. The README suggests 2 CPU and 2 GB of memory to start.
|
||||
- A hostname pointing at that server, for example `uniforms.example.health`.
|
||||
- An HTTPS reverse proxy in front of it (Caddy, nginx or Traefik).
|
||||
|
||||
HTTPS is not optional. The app runs with `NODE_ENV=production`, and in production the session cookie is marked `Secure`. A browser on another machine will not send that cookie over plain HTTP, so nobody can sign in.
|
||||
|
||||
The three services are:
|
||||
|
||||
| Service | Image | What it does |
|
||||
|---|---|---|
|
||||
| `db` | `postgres:16-alpine` | The database, in the named volume `db` |
|
||||
| `migrate` | built locally | Runs `npx prisma migrate deploy` once, then exits |
|
||||
| `app` | built locally | The product, on port 3000, photos in the named volume `photos` |
|
||||
|
||||
## Clone and set the four settings
|
||||
|
||||
```sh
|
||||
git clone https://github.com/pricehq/threadcount-community.git
|
||||
cd threadcount-community
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open `.env` and set these four. The rest can stay blank.
|
||||
|
||||
| Setting | What to put |
|
||||
|---|---|
|
||||
| `SESSION_SECRET` | A long random string: `openssl rand -base64 48`. The server refuses to start while it still says `change-me`. |
|
||||
| `POSTGRES_PASSWORD` | Any long password. Compose refuses to start without it. |
|
||||
| `NEXT_PUBLIC_SITE_URL` | The address people will type: `https://uniforms.example.health`. |
|
||||
| `EDITION` | `community` |
|
||||
|
||||
`NEXT_PUBLIC_SITE_URL` is compiled into the app when the image is built, and it is the base of every link in an email (password resets, approval links). If it is blank the build uses `http://localhost:3000`. If you change it later you must rebuild.
|
||||
|
||||
> **Careful** The image build does not read `.env`: only `NEXT_PUBLIC_SITE_URL` and `NEXT_PUBLIC_TURNSTILE_SITEKEY` are passed in as build arguments. Other `NEXT_PUBLIC_` values in `.env` do not reach a Docker install. See [the configuration reference](/docs/selfhost/configuration-reference).
|
||||
|
||||
## Build and start
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The first start builds the image, which takes a few minutes. Compose then starts `db` and waits for `pg_isready`, runs `migrate` to create the schema, and starts `app` only once `migrate` has finished without an error.
|
||||
|
||||
Watch progress with:
|
||||
|
||||
```sh
|
||||
docker compose ps
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
If the app stops at once with `Refusing to start:`, the message names the setting that is missing or unsafe. In the Community edition it checks two: `DATABASE_URL` and `SESSION_SECRET`. `DATABASE_URL` is filled in by `docker-compose.yml`, so you do not set it.
|
||||
|
||||
## The port and the proxy
|
||||
|
||||
Inside the container the app always listens on 3000. On the server it is published on `APP_PORT`, which defaults to `3000`. Change `APP_PORT` in `.env` if 3000 is taken.
|
||||
|
||||
Point your proxy at that port. A Caddyfile for Caddy is:
|
||||
|
||||
```
|
||||
uniforms.example.health {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
Docker publishes the port on every interface of the server, not only `127.0.0.1`. Firewall it, or set `APP_PORT=127.0.0.1:3000` so only a proxy on the same machine can reach it.
|
||||
|
||||
The sign-in, sign-up and reset limits count per address. The app reads the address from the last entry of the `X-Forwarded-For` header, then from `X-Real-IP`. Caddy sets `X-Forwarded-For` for you. If neither header arrives, every visitor counts as the same address, and one person's failed attempts count against everyone.
|
||||
|
||||
## Check it is up
|
||||
|
||||
The app answers `/api/health` without signing in. It asks the database a question and replies:
|
||||
|
||||
| Answer | Meaning |
|
||||
|---|---|
|
||||
| `200` with `{"ok":true}` | The app is serving and the database answers |
|
||||
| `503` with `{"ok":false}` | The app is running but cannot reach the database |
|
||||
|
||||
```sh
|
||||
curl -fsS https://uniforms.example.health/api/health
|
||||
```
|
||||
|
||||
The image runs the same check itself every 30 seconds, so `docker compose ps` shows `app` as `healthy` once it passes.
|
||||
|
||||
`/api/app-info` is also public. It returns `"product": "threadcount"`, `"edition": "community"` and the release in `version`, which is read from the `COMMUNITY_VERSION` file. The Android apps call it before they will point at your server.
|
||||
|
||||
## Next
|
||||
|
||||
Open `https://uniforms.example.health`. The address `/` sends you to `/auth`. Go on to [first run](/docs/selfhost/first-run) to create the facility, then set up [email](/docs/selfhost/email) and [backups](/docs/selfhost/backups) before anyone relies on it.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Update to a new release
|
||||
section: selfhost
|
||||
order: 3
|
||||
summary: Back up, fetch the release, rebuild, and let the migration run before the new app starts. Why a plain git pull does not work.
|
||||
role: Self-hosting admin
|
||||
keywords: update, upgrade, release, new version, git pull, fetch, reset, rebuild, migrations, schema, changelog, version
|
||||
---
|
||||
|
||||
## How releases are published
|
||||
|
||||
Each Community release is one commit with no history behind it. It is exported from ThreadCount's own code and the parts that belong only to threadcount.tech are removed. Before it is pushed, the result must type-check, build with `EDITION=community` and pass a smoke test. The new commit replaces the previous one on the `main` branch.
|
||||
|
||||
The commit message names the date it was built. The same date and the source commit are written to the `COMMUNITY_VERSION` file in the checkout, in this form:
|
||||
|
||||
```
|
||||
community 2026-09-15 b36d739
|
||||
```
|
||||
|
||||
A running server reports that line as `version` at `/api/app-info`.
|
||||
|
||||
## Back up first
|
||||
|
||||
Before every update, take a database dump and a copy of the photos, as in [backups](/docs/selfhost/backups):
|
||||
|
||||
```sh
|
||||
docker/backup.sh /srv/backups/threadcount
|
||||
```
|
||||
|
||||
Migrations only go forwards. `prisma migrate deploy` applies new migrations and has no step that undoes one, so the way back to an older release is to restore the dump you took before updating.
|
||||
|
||||
## Fetch the release
|
||||
|
||||
Because each release replaces the last commit rather than adding to it, `git pull` refuses to merge the two. Fetch, then move your checkout to the new commit:
|
||||
|
||||
```sh
|
||||
cd /srv/threadcount
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
cat COMMUNITY_VERSION
|
||||
```
|
||||
|
||||
`.env` is ignored by git, so `git reset --hard` leaves it alone.
|
||||
|
||||
> **Careful** `git reset --hard` throws away any change you made to a file in the repository, `docker-compose.yml` included. Keep local settings in `.env`, or copy your changes somewhere before you reset.
|
||||
|
||||
## Rebuild and start
|
||||
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
What happens, in order:
|
||||
|
||||
1. **The images are rebuilt.** The build takes `NEXT_PUBLIC_SITE_URL` and `NEXT_PUBLIC_TURNSTILE_SITEKEY` from `.env` again.
|
||||
2. **`db` is checked.** Compose waits until Postgres answers `pg_isready`.
|
||||
3. **`migrate` runs.** It runs `npx prisma migrate deploy` against the bundled database and applies any migration the database does not have yet.
|
||||
4. **`app` starts.** Compose starts the new app only if `migrate` exited successfully.
|
||||
|
||||
If a migration fails, `app` is not started. Read what went wrong with:
|
||||
|
||||
```sh
|
||||
docker compose logs migrate
|
||||
```
|
||||
|
||||
Then check the app is healthy:
|
||||
|
||||
```sh
|
||||
docker compose ps
|
||||
curl -fsS https://uniforms.example.health/api/health
|
||||
```
|
||||
|
||||
## What changed
|
||||
|
||||
The Community edition has no changelog of its own. The [changelog](/changelog) on threadcount.tech lists what changed for the people who use ThreadCount, newest first. It is written for the hosted service, so entries about plans, card payments, single sign-on or the website do not apply to your server.
|
||||
|
||||
## Phones after an update
|
||||
|
||||
The Android apps ask your server at `/api/app-info` for the oldest app version it still works with, given in `minApp`. A phone running an older app is told to update rather than failing. Phones using `/m` or `/my` in a browser get the new version the next time the page loads.
|
||||
Reference in New Issue
Block a user