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

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

docker 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#

  1. Create a new Compose service, choose GitHub or Git as the source, and use https://github.com/JayavandePol/SafeSprint.git on the main branch with docker-compose.yml as the compose path.
  2. Open the Environment tab and paste your variables there. Dokploy writes them to a .env file and passes it to Compose, so a .env in the repository is not needed.
  3. Under Domains, add your domain for the app service on container port 3000 and turn on HTTPS.
  4. Deploy.
Environment for Dokploy
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=1
Dokploy's own dashboard listens on port 3000 of the host, so keep APP_BIND on a different port. Traefik talks to the container over Docker's network and doesn't use that port at all.

Plesk#

  1. Install the Docker extension, or Docker itself, on the server.
  2. Over SSH, follow the Docker steps. Keep APP_BIND=127.0.0.1:3000.
  3. Add the domain in Plesk and issue a certificate with the Let's Encrypt extension.
  4. In the domain's Apache & nginx Settings, turn off proxy mode and add these lines to Additional nginx directives:
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#

  1. Install Docker on the server (curl -fsSL https://get.docker.com | sh), then follow the Docker steps over SSH with APP_BIND=127.0.0.1:3000.
  2. In CloudPanel, add a site of type Reverse Proxy with your domain and http://127.0.0.1:3000 as the reverse proxy URL.
  3. Issue a Let's Encrypt certificate from the site's SSL/TLS tab.
  4. In the site's Vhost editor, add proxy_buffering off; and client_max_body_size 12m; to the location / 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:

/etc/caddy/Caddyfile
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.

VariableWhat it does
APP_URLrequiredPublic address, e.g. https://boards.example.com. Every link in an email is built from it.
AUTH_SECRETrequiredSigns sessions and encrypts two-factor secrets. Generate with openssl rand -base64 32 and don't change it later.
POSTGRES_PASSWORDrequiredPassword for the bundled database. Only read when the volume is first created.
APP_BINDHost address for the app port. Default 3000; use 127.0.0.1:3000 behind a proxy on the same machine.
REGISTRATIONWho may sign up. open (default) lets anyone in; invite only accepts invited emails and REGISTRATION_DOMAINS. The first account is always allowed.
REGISTRATION_DOMAINSComma-separated email domains that may always sign up, e.g. example.com.
TRUSTED_PROXY_HOPSHow many proxies in front of the app may set X-Forwarded-For. Default 0.
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_SECUREOutgoing email as separate fields. See Email.
SMTP_URLOutgoing email as one URL. Takes priority over the separate fields.
MAIL_FROMSender, e.g. SafeSprint <no-reply@example.com>.
S3_BUCKET, S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLEOptional S3-compatible storage for attachments. Leave S3_BUCKET empty to keep them in Postgres.
CRON_SECRETBearer token for the daily digest endpoint. The endpoint is off while this is empty.
UMAMI_SCRIPT_URL, UMAMI_WEBSITE_ID, UMAMI_HOST_URL, UMAMI_DOMAINSUmami analytics, cloud or self-hosted, on the public pages.
PLAUSIBLE_DOMAIN, PLAUSIBLE_SCRIPT_URL, PLAUSIBLE_API_URLPlausible analytics, cloud or self-hosted, on the public pages.
GA_MEASUREMENT_IDGoogle Analytics 4 on the public pages.
AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET, AUTH_GITHUB_ID, AUTH_GITHUB_SECRETOptional sign-in with Google or GitHub.
PASSWORD_BREACH_CHECKSet to 0 to skip the Have I Been Pwned check, for example on an offline network.
MIGRATION_DATABASE_URLA 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-XXXXXXXXXX

Set 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   # optional

Ad 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/event

Same-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/null

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

Migrations 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).dump

Run 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 -d

If 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/⌘ KCommand palette: search boards and cards, jump anywhere
?Show all shortcuts
/Search cards on the current board
nNew card in the first list
1 – 4Switch between board, table, calendar and swimlanes
Ctrl/⌘ ZUndo the last card move
SpacePick up or drop the focused card or list
Arrow keysMove a picked-up card or list
EnterOpen the focused card
EscClose 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=invite so only people you invite (or on your REGISTRATION_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 .env next to docker-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_URL to 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 app for [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 dev

npm run lint, npm run typecheck and npm test should pass before you open a pull request. Issues and pull requests are welcome on GitHub.