Running Sandly on your own server
Sandly ships as one Docker image (the API, which also serves the web app). The deployment files that come with each
release (docker-compose.yml, install.sh, and the Caddy and backup settings) run it with PostgreSQL, Caddy for
HTTPS, and daily database backups. The same files run our hosted service; only .env and secrets/ differ.
This guide takes you from a fresh server to your first sign-in in about half an hour, then describes every setting.
Install
Section titled “Install”You need:
- a Linux server or VM (x86-64 or ARM64) with 2 vCPUs and 4 GB of RAM, which is plenty for a few hundred people, and ports 80 and 443 reachable from the internet (Let’s Encrypt checks them before it issues the certificate);
- a domain name for it, such as
sandly.acme.rs; - a way for people to sign in: Google Workspace (step 2), or Microsoft Entra ID or Keycloak (see Sign-in with Microsoft or Keycloak);
- the Sandly image and deployment files of your release.
-
Prepare the server. Install Docker Engine with the Compose plugin. Point your domain at the server with a DNS
A(andAAAA) record, and wait untilping sandly.acme.rsanswers from the server’s address. -
Create the Google sign-in client. In Google Cloud Console, open APIs & Services → Credentials → Create credentials → OAuth client ID. Choose Web application, add
https://sandly.acme.rsunder Authorized JavaScript origins, and copy the client ID. -
Copy the deployment files to the server, for example to
/opt/sandly:docker-compose.yml,install.sh, and the Caddy and backup settings. -
Run the installer.
Terminal window cd /opt/sandly./install.shIt asks for:
Question Example The address people open sandly.acme.rsThe Sandly image ghcr.io/your-org/sandly:1.4, as given with your releaseYour company’s name, and a short name for it Acme d.o.o.,acmeThe email domain people sign in with acme.rsThe first administrator’s email ana@acme.rsCountry, currency, and time zone RS,RSD,Europe/BelgradeThe Google client ID from step 2 It writes your answers to
.env, and generates the database passwords and the encryption key as files insecrets/(Secrets). Run it again whenever you like: it keeps what’s there and adds what’s missing. -
Start Sandly.
Terminal window docker compose up -ddocker compose logs -f sandlyWait for “Sandly … starting (SelfHosted)”, then the modules initializing; Ctrl+C stops following the log. On the first start, Sandly creates the database, your organization (from
ORG_*) with its default roles, and invitesADMIN_EMAILas administrator, by email if email is set up already. Caddy gets the certificate in the background. -
Sign in at
https://sandly.acme.rswith the administrator’s account, and continue in Admin → Company settings. Employees can sign in once HR has added them (their work email is the link); anyone else, once an admin invites them under Admin → Users and access. -
Back up
secrets/somewhere safe, away from the server and from the database backups, such as a password manager or a vault. Without it, encrypted personal data and files can’t be read (Secrets). Thebackupservice dumps the database every night intobackups/: copy that folder off the server too (Backups).
Then set up email, where files go, and your license. The sections below describe every setting.
Secrets
Section titled “Secrets”Passwords and keys never go in .env or in a container’s environment, where docker inspect and process dumps show
them. Each is a file in secrets/, and Docker mounts it read-only where it’s needed, as /run/secrets/…. The app
reads those files as settings.
| File | What it is |
|---|---|
postgres_password, app_password |
The database’s owner, and the role the app connects as |
app_connection, owner_connection |
The connection strings made from them |
jwt_signing_key |
Signs access tokens; changing it signs everyone out |
encryption_key_k1 |
Encrypts JMBG, bank accounts, and files |
microsoft_client_secret, oidc_client_secret |
Sign-in |
google_service_account_key |
Suspended Google accounts, and email with Gmail |
smtp_password, email_graph_client_secret |
|
storage_s3_access_key, storage_s3_secret_key, storage_azure_connection_string |
Files |
install.sh generates the first six, once, and leaves the rest empty until you use them. To set or change one, write
its file and restart:
printf %s 'the-password' > secrets/smtp_passworddocker compose up -dThe folder is readable by you alone; the files inside are readable by others so the containers’ users can read them
through their own mounts, while nobody else on the server can reach them through the folder. Kubernetes and secret
stores such as Vault hand secrets over as files in the same way. If one stops being readable, ./install.sh sets the
permissions again.
Back up secrets/ somewhere safe, apart from the database backups. Without secrets/encryption_key_k1, encrypted
personal data (JMBG, bank accounts) and files can’t be read, even from a backup.
Deployment mode
Section titled “Deployment mode”DEPLOYMENT_MODE=SelfHosted (the default) runs one organization, the one in ORG_*. Startup stops with an explanation
if it’s missing on the first start, or if ORG_SLUG names a different organization than the one already there (the
slug can’t change). Domains and admin emails you add later are applied on the next start.
DEPLOYMENT_MODE=Hosted is for our multi-tenant service: any number of organizations, isolated by row-level security.
Leave the ORG_* settings empty or use them for the first organization.
Platform console
Section titled “Platform console”PLATFORM_ADMIN_EMAILS (comma-separated) names the people who run the installation. They sign in with their normal
account, as users of your own organization: ORG_SLUG self-hosted, or PLATFORM_ORGANIZATION (the slug) on a hosted
service. They get a Platform page listing every organization:
- Export data: a ZIP with every table as JSON, personal data (JMBG, bank accounts) decrypted, and the documents and invoices. Treat the file as you would the database itself. Each export is recorded.
- Suspend / Reactivate: a suspended organization can’t sign in or work; nothing is deleted.
- Delete (Hosted only): a suspended organization, confirmed by typing its slug, is erased for good 30 days later, with its stored files. It can be cancelled until then. Backups keep it until they expire (30 days).
- New organization (Hosted only): creates it with the default roles and invites its first admins.
Self-hosted installs can use the console to export their organization. Every step, and every erasure, is in the console’s Activity tab.
License
Section titled “License”A self-hosted installation runs on a license key from your vendor: who it’s for, your sign-in domain, how many current
employees it covers (hired and not left; candidates don’t count), and until when. Sandly checks it offline, against
the public key built into it; nothing is sent anywhere. Enter it in Company settings → License (admins), or set
SANDLY_LICENSE_KEY in .env. One entered in the app takes precedence.
- A new installation has 30 days to evaluate without a key.
- 30 days before a license expires, and while it’s expired or over its limit, admins see a notice with the date.
- When a grace period ends (30 days after the evaluation, the expiry, or going over the limit), Sandly becomes read-only for everyone but admins. Everyone can still sign in and see everything, but changes are refused until an admin enters a valid key. A new key takes effect immediately. Admins keep working throughout.
Update notices
Section titled “Update notices”Off by default: Sandly never calls out on its own. Set UPDATE_CHECK_URL to the release feed your vendor gives you,
and Sandly checks it once a day, sending nothing about your installation. Admins then see “Sandly 1.6.0 is available”
in the account menu.
Self-service sign-up (hosted)
Section titled “Self-service sign-up (hosted)”With DEPLOYMENT_MODE=Hosted and PLATFORM_SIGNUP=true, the sign-in page offers Create your organization. A
company signs up with a Google Workspace account of its own domain: Google has verified that the company owns that
domain, so the new organization claims it, with the person as its admin. It’s ready at once.
- Each sign-up emails
PLATFORM_ADMIN_EMAILSand appears in the platform console’s Activity. Suspend it there if it shouldn’t be. - One organization per domain: anyone else from that company is told to sign in, or ask their admin.
- Personal Google accounts can’t sign up. Microsoft 365 companies are set up from the platform console (New organization).
- Requests are limited per address (
PLATFORM_SIGNUPS_PER_HOUR, 10).
A self-hosted installation never offers sign-up.
Moving an organization
Section titled “Moving an organization”An organization moves between installations, hosted to self-hosted or back, as the ZIP that Export data downloads.
-
Run the same Sandly version on both. The import checks every module’s migrations and refuses another version.
-
On the source: Platform → the organization → Suspend (nobody changes anything after the export), then Export data. The ZIP has personal data decrypted: copy it as you would a database backup, and delete it when you’re done.
-
On the target, put the ZIP in an
importsfolder next todocker-compose.ymland run:Terminal window docker compose run --rm -v "$PWD/imports:/imports:ro" sandly import /imports/sandly-acme-2026-11-11.zip- Self-hosted: on a new installation, before its first
docker compose up, withORG_SLUGset to the organization’s slug. The import creates the organization; the first start then addsORG_DOMAINand invitesADMIN_EMAILif they’re new. - Hosted: any time. The organization’s slug and sign-in domains must be free.
It migrates the database, checks everything, and imports: every module’s data, the documents and invoices (file storage must be set up), and JMBGs and bank accounts encrypted again with this installation’s key. If anything fails, what it wrote is removed again and it says why.
- Self-hosted: on a new installation, before its first
-
Start Sandly (
docker compose up -d) and point people at the new address. Everyone signs in again with the same Google, Microsoft, or OIDC account. Emails the source hadn’t sent yet aren’t sent. The organization is active. -
On the source, when you’re sure: Delete… (erased 30 days later).
The installation’s own settings don’t move: sign-in providers, email, storage, the Google service account, and the platform console’s admins. Set them on the target. Events already in Google Calendar stay where they are.
Sign-in with Microsoft or Keycloak
Section titled “Sign-in with Microsoft or Keycloak”People sign in on Microsoft’s or your provider’s own page and come back to
https://SANDLY_DOMAIN/api/v1/auth/callback. Register that address with the provider. Sandly matches people by the
email domain (ORG_DOMAIN) and lets in those who are invited or have an employee record, as with Google. Several
providers can be on at once; the login page shows a button for each.
Microsoft Entra ID
- Microsoft Entra admin center → App registrations → New registration: accounts in this organizational directory
only. Redirect URI: platform Web,
https://SANDLY_DOMAIN/api/v1/auth/callback. - Certificates & secrets → New client secret. Put the Application (client) ID and the Directory (tenant) ID in
.envasMICROSOFT_CLIENT_IDandMICROSOFT_TENANT_ID, and the secret’s value insecrets/microsoft_client_secret. docker compose up -d. Only your tenant’s accounts can sign in. The email Sandly uses is the account’s email, or else its user principal name.
The secret expires (Entra’s maximum is two years): renew it before then, or sign-in with Microsoft stops.
Keycloak (or another OpenID Connect provider)
- In your realm, create a client: OpenID Connect, client authentication on (confidential), standard flow only. Valid
redirect URI:
https://SANDLY_DOMAIN/api/v1/auth/callback. Under Advanced, set PKCE to S256. - In
.env:OIDC_AUTHORITY=https://sso.acme.rs/realms/acme(the realm’s URL),OIDC_CLIENT_ID, andOIDC_DISPLAY_NAME=Keycloak(the button reads “Sign in with Keycloak”). The client secret (Credentials tab) goes insecrets/oidc_client_secret. docker compose up -d. Accounts need a verified email in the provider.
If the provider can’t be reached, the login page says so; people already signed in keep working until their session ends.
Suspended Google accounts
Section titled “Suspended Google accounts”A session lasts SESSION_MAX_AGE (12 hours) from signing in with Google; then people sign in again, and Google refuses
a suspended account. Deactivating someone in Sandly ends their access at once.
To also end a session within 15 minutes of suspending the account in Google Workspace, give Sandly read access to your directory:
- Google Cloud Console (any project of your organization): enable the Admin SDK API, then IAM & Admin → Service Accounts → create one (no roles needed) → Keys → Add key → JSON.
- Save the downloaded key as
secrets/google_service_account_key(cp key.json secrets/google_service_account_key), then delete the download. Restart:docker compose up -d. - Google Admin console → Security → Access and data control → API controls → Manage domain-wide delegation → Add new: the client ID and scope that Sandly shows under Company settings → Sign-in.
- In Company settings, enter a Workspace admin of your domain (Sandly reads the directory as them), Test, and save.
If Google can’t be reached, sessions continue. Keep the key file safe: it reads your user directory.
Sandly emails approvals, reminders, and invitations (an admin can send an invitation again from the user’s page until
they sign in). EMAIL_FROM is the sender (default sandly@SANDLY_DOMAIN). Choose how with EMAIL_PROVIDER:
Smtp(default):SMTP_HOST,SMTP_PORT,SMTP_USERNAME, andSMTP_SECURITYin.env, the password insecrets/smtp_password. Any provider, or Google Workspace’s SMTP relay (smtp-relay.gmail.com). WithoutSMTP_HOST, emails are only logged.Gmail: Google Workspace sends asEMAIL_FROM, a real mailbox (or a group with send-as), through the service account insecrets/google_service_account_key(set it up as in Suspended Google accounts, steps 1–2). Enable the Gmail API in its Cloud project, and in Google Admin → Security → API controls → Domain-wide delegation, addhttps://www.googleapis.com/auth/gmail.sendto its scopes (comma-separated, next to any others).MicrosoftGraph: Microsoft 365 sends asEMAIL_FROM, a mailbox in your tenant (a shared mailbox works).- Entra admin center → App registrations → New registration (no redirect URI). Put its Application (client) ID and
the Directory (tenant) ID in
EMAIL_GRAPH_CLIENT_IDandEMAIL_GRAPH_TENANT_ID. - Certificates & secrets → New client secret →
secrets/email_graph_client_secret. Note when it expires. - API permissions → Add → Microsoft Graph → Application permissions → Mail.Send → Grant admin consent.
- Recommended: limit the app to that one mailbox with an Exchange Online RBAC for Applications assignment, since Mail.Send otherwise allows sending as anyone in the tenant.
- Entra admin center → App registrations → New registration (no redirect URI). Put its Application (client) ID and
the Directory (tenant) ID in
Turn off click and open tracking at your email provider. Many providers (Mailjet, SendGrid, Mailgun, …) replace every link with a redirect through their own tracking address. An invitation’s sign-in link that leads somewhere else first looks like phishing, browsers may warn that the tracking address isn’t safe (its certificate doesn’t match), and the provider learns who opened what. Turn tracking off in the provider’s settings. Brevo can’t turn it off for transactional email, only anonymize it, so use another provider with Sandly. Amazon SES, Google Workspace’s relay, and Microsoft 365 don’t track links unless you set that up.
Restart: docker compose up -d. Startup stops with an explanation if a setting the provider needs is missing. An email
that fails is retried with growing pauses (up to four hours) and the reason is logged (docker compose logs sandly).
Files: documents and invoices
Section titled “Files: documents and invoices”HR keeps signed contracts and annexes with each person’s record, and contractors send their invoices. The files are
encrypted with your encryption key before they’re stored. Where, STORAGE_PROVIDER decides:
Local(default): thefilesvolume on the server. The daily backup archives it next to the database dump.- Amazon S3:
STORAGE_PROVIDER=S3,STORAGE_S3_BUCKET, andSTORAGE_S3_REGION. On AWS with an IAM role, leave the key files empty; otherwise writesecrets/storage_s3_access_keyandsecrets/storage_s3_secret_keyfor a user that may get, put, and delete objects in that bucket. - An S3-compatible store (MinIO, Hetzner, Cloudflare R2, …): the same, plus
STORAGE_S3_SERVICE_URL, the store’s address. - Azure Blob Storage:
STORAGE_PROVIDER=AzureBlobandSTORAGE_AZURE_CONTAINERin.env, and the connection string insecrets/storage_azure_connection_string. None: documents and invoices are off, and their pages say so.
Create the bucket or container first: Sandly doesn’t create production ones. Then restart: docker compose up -d.
Changing the provider doesn’t move files already stored: copy them over (the keys stay the same) before switching.
With S3 or Azure the backup doesn’t include the files: use your storage’s own versioning or replication. A file restored without the database, or the database without the encryption key, can’t be opened.
Upgrade
Section titled “Upgrade”# Set SANDLY_IMAGE in .env to the new version (e.g. ghcr.io/your-org/sandly:1.5.0), then:docker compose pull sandly # or build itdocker compose up -d sandlyMigrations run automatically on start, before the app accepts requests; check docker compose logs sandly. Take a
backup first (below). The account menu shows which version is running. What’s new: the release notes.
- Any later version of the same major version upgrades directly (1.2 → 1.5 is fine). A new major version (1.x → 2.0) goes one at a time: the latest 1.x first.
- Going back one minor version works without a restore (1.5 → 1.4: set
SANDLY_IMAGEback andup -d), since a release’s migrations only add to the database. It starts with a warning in the log. Further back, or to an earlier major version, Sandly refuses to start and says so: restore the backup taken before the upgrade instead. - Coming from a version whose
.envheld the passwords and keys: take the new release’s deployment files, then run./install.shonce. It moves them intosecrets/and takes them out of.env; nothing else changes. SANDLY_IMAGE=…/sandly:1.5follows 1.5’s fixes on everydocker compose pull. The current and previous minor versions get fixes (releases).
Backups
Section titled “Backups”The backup service writes backups/sandly-YYYYMMDD-HHMMSS.dump every day at BACKUP_AT (local time), with the files
volume as sandly-YYYYMMDD-HHMMSS-files.tar.gz when files are kept on disk, and deletes backups older than
BACKUP_KEEP_DAYS (30). Copy the folder off the server (another disk, S3, your backup system): a
backup on the same disk doesn’t survive the disk.
Take one now, e.g. before an upgrade:
docker compose exec backup /bin/sh /backup.sh nowRestore
Section titled “Restore”docker compose stop sandlydocker compose exec -T postgres psql -U sandly -d postgres -c "DROP DATABASE sandly WITH (FORCE)" -c "CREATE DATABASE sandly"docker compose exec -T backup pg_restore --dbname=sandly --exit-on-error /backups/sandly-20261109-020000.dump# Files kept on disk: the archive of the same backup (the volume is <project>_files, e.g. sandly-server_files).docker run --rm -v sandly-server_files:/files -v "$PWD/backups:/backups:ro" postgres:18-alpine \ sh -c 'find /files -mindepth 1 -delete && tar -xzf /backups/sandly-20261109-020000-files.tar.gz -C /files && chown -R 1654:1654 /files'docker compose start sandlyThe dump includes the permissions of the sandly_app role, which keeps existing across restores. Use the same
secrets/encryption_key_k1 as when the backup was taken. A backup from before someone was anonymized still has their data:
after restoring one, anonymize again anyone HR anonymized by hand since (the nightly job redoes the automatic ones). Test a restore now and then, into a scratch database:
docker compose exec -T postgres psql -U sandly -d postgres -c "CREATE DATABASE restore_test"docker compose exec -T backup pg_restore --dbname=restore_test --no-owner /backups/<latest>.dumpdocker compose exec -T postgres psql -U sandly -d restore_test -c "select count(*) from organization.users"docker compose exec -T postgres psql -U sandly -d postgres -c "DROP DATABASE restore_test"Rotating the encryption key
Section titled “Rotating the encryption key”Add a new key and make it active; keep the old one so existing data still decrypts:
head -c 32 /dev/urandom | base64 | tr -d '\n' > secrets/encryption_key_k2 && chmod 644 secrets/encryption_key_k2Mount it next to the first one, in docker-compose.override.yml (Compose reads it with docker-compose.yml):
services: sandly: secrets: - { source: encryption_key_k2, target: Encryption__Keys__k2 }secrets: encryption_key_k2: { file: ./secrets/encryption_key_k2 }Then set ENCRYPTION_ACTIVE_KEY=k2 in .env and docker compose up -d. New and updated personal data is encrypted
with k2. Back up the new key with the old one.
Monitoring
Section titled “Monitoring”- Error alerts: errors the server logs are emailed to
ALERT_EMAIL(default:ADMIN_EMAIL), grouped, at most one email every 10 minutes. They need working SMTP. Each lists the errors, where they happened, and a trace id to search the logs for. - Uptime: a server that’s down can’t send email. Point an external monitor (Uptime Kuma, UptimeRobot, Better Stack,
…) at
https://SANDLY_DOMAIN/health. It answersHealthy(200) when the app and database are up,Unhealthy(503) otherwise. - Logs:
docker compose logs sandly --since 1h. Structured, one event per line; the trace id in an alert or an error response finds the request.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Look at |
|---|---|
| The site doesn’t open or has no certificate | DNS points here, ports 80/443 are open; docker compose logs caddy |
sandly keeps restarting |
docker compose logs sandly: a missing setting is named in the error |
| “Google sign-in isn’t configured” | GOOGLE_CLIENT_ID in .env |
| Sign-in fails with Google’s “origin not allowed” | Add https://SANDLY_DOMAIN to the OAuth client’s JavaScript origins |
| “Sandly is read-only” | The license: Company settings → License says why (no key, expired, over the limit) and takes a new key |
| No emails | EMAIL_PROVIDER and its settings in .env (Email); with Smtp and no SMTP_HOST, emails are logged, not sent. docker compose logs sandly shows why a send failed |
| A link in an email goes to another address, or the browser says it isn’t safe | Your email provider’s click tracking: turn it off (Email) |
docker compose up says a secret’s file is missing |
Run ./install.sh: it creates the missing files (and generates what it should) |
| Startup stops: a setting is missing, or the database refuses the password | The file in secrets/ (Secrets); the connection strings must match the passwords the database was created with |
| Health | curl https://SANDLY_DOMAIN/health answers Healthy when the app and database are up |