SafeSprint docs
How to install SafeSprint on your own server, configure it, and use it with your team.
Introduction#
SafeSprint is a self-hosted Kanban board. Teams get organizations, shared and private boards, cards with all the usual details, and live updates while several people work on the same board.
The name says what it is for. Safe: it runs on your server and keeps everything in your own Postgres database, with no hosted service behind it. Sprint: it is shaped around project sprints, with a backlog, work in progress limits, estimates and due dates.
It's a Next.js app with PostgreSQL, released under the MIT license. The source is on GitHub.
Quick start#
To try it on your own machine you need Docker with Compose v2.
git clone https://github.com/JayavandePol/SafeSprint.git
cd SafeSprint
AUTH_SECRET=$(openssl rand -base64 32) POSTGRES_PASSWORD=$(openssl rand -hex 24) docker compose up -d --buildOpen http://localhost:3000 and create an account. Without an email server, verification links are printed to the log: docker compose logs app.
Self-hosting#
A SafeSprint install is two containers: the app and PostgreSQL. Anything that runs Docker Compose can host it. Pick the guide for your setup below, then read HTTPS and proxies.
Requirements#
- A Linux server with 1 vCPU and 2 GB of RAM or more. Building the image needs about 1.5 GB.
- Docker Engine with the Compose plugin. On a fresh server:
curl -fsSL https://get.docker.com | sh. - A domain name pointing at the server, for example
boards.example.com. - Optionally an SMTP account for verification and password reset emails.
Docker#
Clone the code and create a .env file with real secrets:
git clone https://github.com/JayavandePol/SafeSprint.git /opt/safesprint
cd /opt/safesprint
cat > .env <<EOF
APP_URL=https://boards.example.com
AUTH_SECRET=$(openssl rand -base64 32)
POSTGRES_PASSWORD=$(openssl rand -hex 24)
CRON_SECRET=$(openssl rand -hex 24)
APP_BIND=127.0.0.1:3000
TRUSTED_PROXY_HOPS=1
EOF
chmod 600 .env
docker compose up -d --builddocker compose ps should show both containers as healthy, and curl http://127.0.0.1:3000/api/health should answer with {"status":"ok"}. Pending database migrations run every time the app starts.
Dokploy#
- Create a new Compose service, choose GitHub or Git as the source, and use
https://github.com/JayavandePol/SafeSprint.giton themainbranch withdocker-compose.ymlas the compose path. - Open the Environment tab and paste your variables there. Dokploy writes them to a
.envfile and passes it to Compose, so a.envin the repository is not needed. - Under Domains, add your domain for the
appservice on container port3000and turn on HTTPS. - Deploy.
APP_URL=https://boards.example.com
AUTH_SECRET=... # openssl rand -base64 32
POSTGRES_PASSWORD=... # openssl rand -hex 24
CRON_SECRET=... # openssl rand -hex 24
APP_BIND=127.0.0.1:3001
TRUSTED_PROXY_HOPS=1APP_BIND on a different port. Traefik talks to the container over Docker's network and doesn't use that port at all.Plesk#
- Install the Docker extension, or Docker itself, on the server.
- Over SSH, follow the Docker steps. Keep
APP_BIND=127.0.0.1:3000. - Add the domain in Plesk and issue a certificate with the Let's Encrypt extension.
- In the domain's Apache & nginx Settings, turn off proxy mode and add these lines to Additional nginx directives:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
client_max_body_size 12m;
}The exact menu names differ a little between Plesk versions.
CloudPanel#
- Install Docker on the server (
curl -fsSL https://get.docker.com | sh), then follow the Docker steps over SSH withAPP_BIND=127.0.0.1:3000. - In CloudPanel, add a site of type Reverse Proxy with your domain and
http://127.0.0.1:3000as the reverse proxy URL. - Issue a Let's Encrypt certificate from the site's SSL/TLS tab.
- In the site's Vhost editor, add
proxy_buffering off;andclient_max_body_size 12m;to thelocation /block.
HTTPS and proxies#
SafeSprint expects a reverse proxy in front of it that handles TLS. Two settings matter for any proxy:
- No buffering on
/api/boards/. Live updates use a long-lived event stream. If the proxy buffers it, other people's changes only show after a reload. - A 12 MB body limit, so 10 MB attachments fit.
With Caddy, which also fetches certificates for you:
boards.example.com {
reverse_proxy 127.0.0.1:3000 {
flush_interval -1
}
}Set TRUSTED_PROXY_HOPS to the number of proxies in front of the app: 1 for a single Caddy, nginx or Traefik, 2 if Cloudflare sits in front of that. Rate limits use the client address from X-Forwarded-For, and only trust that many hops.
Configuration#
All settings are environment variables. With Docker Compose they go in .env; the compose file passes them to the app. .env.example in the repository lists every one with a comment.
| Variable | What it does |
|---|---|
| APP_URLrequired | Public address, e.g. https://boards.example.com. Every link in an email is built from it. |
| AUTH_SECRETrequired | Signs sessions and encrypts two-factor secrets. Generate with openssl rand -base64 32 and don't change it later. |
| POSTGRES_PASSWORDrequired | Password for the bundled database. Only read when the volume is first created. |
| APP_BIND | Host address for the app port. Default 3000; use 127.0.0.1:3000 behind a proxy on the same machine. |
| REGISTRATION | Who may sign up. open (default) lets anyone in; invite only accepts invited emails and REGISTRATION_DOMAINS. The first account is always allowed. |
| REGISTRATION_DOMAINS | Comma-separated email domains that may always sign up, e.g. example.com. |
| TRUSTED_PROXY_HOPS | How many proxies in front of the app may set X-Forwarded-For. Default 0. |
| SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SECURE | Outgoing email as separate fields. See Email. |
| SMTP_URL | Outgoing email as one URL. Takes priority over the separate fields. |
| MAIL_FROM | Sender, e.g. SafeSprint <no-reply@example.com>. |
| S3_BUCKET, S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLE | Optional S3-compatible storage for attachments. Leave S3_BUCKET empty to keep them in Postgres. |
| CRON_SECRET | Bearer token for the daily digest endpoint. The endpoint is off while this is empty. |
| UMAMI_SCRIPT_URL, UMAMI_WEBSITE_ID, UMAMI_HOST_URL, UMAMI_DOMAINS | Umami analytics, cloud or self-hosted, on the public pages. |
| PLAUSIBLE_DOMAIN, PLAUSIBLE_SCRIPT_URL, PLAUSIBLE_API_URL | Plausible analytics, cloud or self-hosted, on the public pages. |
| GA_MEASUREMENT_ID | Google Analytics 4 on the public pages. |
| AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET, AUTH_GITHUB_ID, AUTH_GITHUB_SECRET | Optional sign-in with Google or GitHub. |
| PASSWORD_BREACH_CHECK | Set to 0 to skip the Have I Been Pwned check, for example on an offline network. |
| MIGRATION_DATABASE_URL | A more privileged database role used only for migrations. |
Email#
Email is used for verification, password resets, invitations, notifications and the digest. Without it, messages are written to the app log instead, and people can't verify their address or reset a password on their own.
Fill in the fields your provider gives you:
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_USER=noreply@example.com
SMTP_PASSWORD=...
SMTP_SECURE=true
MAIL_FROM="SafeSprint <noreply@example.com>"Port 465 uses TLS from the first byte (SMTP_SECURE=true). Port 587 starts plain and upgrades with STARTTLS (SMTP_SECURE=false). Leave SMTP_SECURE empty and it is worked out from the port. If you'd rather use one line, SMTP_URL=smtps://user:pass@smtp.example.com:465 works too, but special characters in the password then need URL encoding.
The domain in MAIL_FROM has to be verified with your provider (SPF and DKIM), or your mail will land in spam.
File storage#
Attachments can be up to 10 MB each and 250 MB per board. By default they are stored in Postgres, so there is nothing extra to run or back up. Downloads always go through the app, which checks that the person can see the board.
To keep files in object storage instead, set the S3_* variables. This works with AWS S3, Cloudflare R2, Backblaze B2, or a self-hosted server such as Garage or SeaweedFS.
S3_BUCKET=safesprint
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com # leave out for AWS
S3_REGION=auto
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...The key needs GetObject, PutObject and DeleteObject on the bucket, plus ListBucket if you create the bucket yourself. Files already stored in Postgres keep working after you switch.
Analytics#
You can add Umami, Plausible or Google Analytics to the public pages: the landing page and these docs. Analytics never load inside the app, so board names and card links don't end up in a third-party dashboard.
# Umami Cloud
UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js
UMAMI_WEBSITE_ID=00000000-0000-0000-0000-000000000000
# or Plausible
PLAUSIBLE_DOMAIN=boards.example.com
# or Google Analytics 4
GA_MEASUREMENT_ID=G-XXXXXXXXXXSet one of them. If more than one is set, Umami is used first, then Plausible. Malformed values switch analytics off instead of breaking the page, and the Content Security Policy only opens up for the hosts you configure.
Self-hosted Umami or Plausible#
Point the script URL at your own instance. Script URLs must use HTTPS. If your analytics collect on a different host than they serve the script from, set that too:
# Self-hosted Umami
UMAMI_SCRIPT_URL=https://umami.example.com/script.js
UMAMI_WEBSITE_ID=00000000-0000-0000-0000-000000000000
UMAMI_HOST_URL=https://umami.example.com # optional, where data is sent
UMAMI_DOMAINS=boards.example.com # optional, only track this hostname
# Self-hosted Plausible (Community Edition)
PLAUSIBLE_DOMAIN=boards.example.com
PLAUSIBLE_SCRIPT_URL=https://plausible.example.com/js/script.js
PLAUSIBLE_API_URL=https://plausible.example.com/api/event # optionalAd blockers often block analytics hosts. To avoid that, let your reverse proxy serve the script and the events endpoint from a path on this site, and use paths instead of URLs:
PLAUSIBLE_SCRIPT_URL=/stats/js/script.js
PLAUSIBLE_API_URL=/stats/api/eventSame-site paths need no Content Security Policy change at all. Your proxy has to forward them to the analytics server; with Caddy that's a handle_path /stats/* { reverse_proxy https://plausible.example.com } block.
Google and GitHub sign-in#
The buttons only show up once the credentials are set.
- Google: Google Cloud Console → APIs & Services → Credentials → OAuth client ID (Web). Callback URL
https://boards.example.com/api/auth/callback/google. - GitHub: Settings → Developer settings → OAuth Apps. Callback URL
https://boards.example.com/api/auth/callback/github.
AUTH_GOOGLE_ID=...
AUTH_GOOGLE_SECRET=...
AUTH_GITHUB_ID=...
AUTH_GITHUB_SECRET=...Daily digest#
People who opt in get one email a day with unread notifications and cards that are due soon. Something has to call the digest endpoint on a schedule, for example cron:
0 7 * * * curl -fsS -H "Authorization: Bearer YOUR_CRON_SECRET" https://boards.example.com/api/cron/digest > /dev/nullCalling it more often is harmless. Nobody gets more than one digest in about 20 hours.
Updating#
cd /opt/safesprint
git pull
docker compose up -d --buildMigrations run when the new container starts. Take a backup first. On Dokploy, press Deploy again or turn on auto-deploy for the branch.
Backups#
With the default setup, everything, attachments included, is in one database:
docker compose exec -T db pg_dump -U safesprint -Fc safesprint > safesprint-$(date +%F).dumpRun that nightly and copy the files off the server. To restore onto a fresh install with the same .env:
docker compose up -d db
docker compose exec -T db pg_restore -U safesprint -d safesprint --clean --if-exists < safesprint-YYYY-MM-DD.dump
docker compose up -dIf you use S3 storage, back up the bucket as well.
Using SafeSprint#
Organizations#
After signing up you create an organization. Invite people from its Members page. They get an email, and anyone who already has an account also sees the invitation in the app. An invite only works for the address it was sent to, once that address is verified, and it expires after 14 days.
Organizations have three roles. Owners and admins manage members, settings and the audit log. Members work on boards.
Boards and lists#
A board can be visible to the whole organization or private. On a private board only its members can see anything; on an organization board everyone can look, and people join to edit. Board members are admins, editors or viewers.
Start from an empty board or a template (sprint, product roadmap, bug tracker, content calendar, hiring). Lists can have a work-in-progress limit: the counter turns red when a list holds more cards than it should. Lists and cards can be archived and restored, and boards, lists and cards can be copied.
Cards#
Open a card to add labels, assignees, a due date, priority, an estimate, checklists, a Markdown description, comments and files. Type @ in a comment to mention someone. Image attachments can be used as the card cover. Cards can move to another board.
Views and filters#
Every board has four views: the board itself, a sortable table, a calendar by due date, and swimlanes grouped by assignee. Search and filter by text, label, member, due date or priority. Drag and drop pauses while a filter is active.
Keyboard shortcuts#
| Ctrl/⌘ K | Command palette: search boards and cards, jump anywhere |
| ? | Show all shortcuts |
| / | Search cards on the current board |
| n | New card in the first list |
| 1 – 4 | Switch between board, table, calendar and swimlanes |
| Ctrl/⌘ Z | Undo the last card move |
| Space | Pick up or drop the focused card or list |
| Arrow keys | Move a picked-up card or list |
| Enter | Open the focused card |
| Esc | Close a dialog or cancel editing |
Import and export#
Import a board from a Trello JSON export from your organization's board list. Export any board to JSON or CSV from the board menu. CSV exports are safe to open in a spreadsheet; cells that look like formulas are escaped.
Your account#
On the account page you can change your name, email and password, turn on two-factor sign-in with an authenticator app (and save the recovery codes), see every device that is signed in and sign any of them out, pick which notifications reach you by email, and delete your account.
Security#
- Every action checks, on the server, that you may see and change the board it touches. Hidden boards return 404.
- Passwords are hashed with bcrypt, checked against a list of common passwords and, unless turned off, against Have I Been Pwned using only a hash prefix.
- Two-factor secrets are encrypted at rest, and recovery codes are stored hashed.
- Sessions are tracked on the server, so signing out or changing your password takes effect right away on every device.
- Sign-in, sign-up, password resets, invites, uploads and imports are rate limited. Invites can only be sent from a verified email address.
- On a private server, set
REGISTRATION=inviteso only people you invite (or on yourREGISTRATION_DOMAINS) can create an account. - A strict Content Security Policy with a per-request nonce, HSTS in production, and no framing.
- Attachments are served through the app with
nosniff; only common image types display inline.
Found a problem? Please report it privately to the maintainer through GitHub's security page rather than in a public issue.
Troubleshooting#
- The deploy says “Set AUTH_SECRET”
- The variables never reached Compose. Put them in
.envnext todocker-compose.yml, or in your panel's environment settings, and deploy again. - “port is already allocated” on Dokploy
- Dokploy's own dashboard uses port 3000. Set
APP_BIND=127.0.0.1:3001. - Email links point to localhost
- Set
APP_URLto the public address and restart. - Changes from others only appear after a reload
- Your proxy is buffering the event stream. Turn buffering off for
/api/boards/. - Everyone gets rate limited at once
- You're behind a proxy with
TRUSTED_PROXY_HOPS=0, so every visitor looks like the proxy. Set it to the number of proxies. - Sign-in fails with UntrustedHost
- Keep
AUTH_TRUST_HOST=true. The Compose file already sets it. - Uploads fail
- Check
docker compose logs appfor[upload]lines. With S3 configured it is usually a wrong key or endpoint.
GET /api/health answers {"status":"ok"} when the app can reach its database.
Contributing#
To run SafeSprint from source you need Node.js 22 and PostgreSQL 14 or newer.
npm install
cp .env.example .env # set DATABASE_URL, AUTH_SECRET and APP_URL
npm run db:migrate
npm run db:seed # optional: demo@example.com / Demo-Password-2024
npm run devnpm run lint, npm run typecheck and npm test should pass before you open a pull request. Issues and pull requests are welcome on GitHub.