Skip to content

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.

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.
  1. Prepare the server. Install Docker Engine with the Compose plugin. Point your domain at the server with a DNS A (and AAAA) record, and wait until ping sandly.acme.rs answers from the server’s address.

  2. 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.rs under Authorized JavaScript origins, and copy the client ID.

  3. Copy the deployment files to the server, for example to /opt/sandly: docker-compose.yml, install.sh, and the Caddy and backup settings.

  4. Run the installer.

    Terminal window
    cd /opt/sandly
    ./install.sh

    It asks for:

    Question Example
    The address people open sandly.acme.rs
    The Sandly image ghcr.io/your-org/sandly:1.4, as given with your release
    Your company’s name, and a short name for it Acme d.o.o., acme
    The email domain people sign in with acme.rs
    The first administrator’s email [email protected]
    Country, currency, and time zone RS, RSD, Europe/Belgrade
    The Google client ID from step 2

    It writes your answers to .env, and generates the database passwords and the encryption key as files in secrets/ (Secrets). Run it again whenever you like: it keeps what’s there and adds what’s missing.

  5. Start Sandly.

    Terminal window
    docker compose up -d
    docker compose logs -f sandly

    Wait 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 invites ADMIN_EMAIL as administrator, by email if email is set up already. Caddy gets the certificate in the background.

  6. Sign in at https://sandly.acme.rs with 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.

  7. 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). The backup service dumps the database every night into backups/: copy that folder off the server too (Backups).

Then set up email, where files go, and your license. The sections below describe every setting.

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

Terminal window
printf %s 'the-password' > secrets/smtp_password
docker compose up -d

The 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=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_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.

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.

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.

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_EMAILS and 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.

An organization moves between installations, hosted to self-hosted or back, as the ZIP that Export data downloads.

  1. Run the same Sandly version on both. The import checks every module’s migrations and refuses another version.

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

  3. On the target, put the ZIP in an imports folder next to docker-compose.yml and 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, with ORG_SLUG set to the organization’s slug. The import creates the organization; the first start then adds ORG_DOMAIN and invites ADMIN_EMAIL if 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.

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

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

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

  1. 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.
  2. Certificates & secrets → New client secret. Put the Application (client) ID and the Directory (tenant) ID in .env as MICROSOFT_CLIENT_ID and MICROSOFT_TENANT_ID, and the secret’s value in secrets/microsoft_client_secret.
  3. 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)

  1. 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.
  2. In .env: OIDC_AUTHORITY=https://sso.acme.rs/realms/acme (the realm’s URL), OIDC_CLIENT_ID, and OIDC_DISPLAY_NAME=Keycloak (the button reads “Sign in with Keycloak”). The client secret (Credentials tab) goes in secrets/oidc_client_secret.
  3. 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.

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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, and SMTP_SECURITY in .env, the password in secrets/smtp_password. Any provider, or Google Workspace’s SMTP relay (smtp-relay.gmail.com). Without SMTP_HOST, emails are only logged.
  • Gmail: Google Workspace sends as EMAIL_FROM, a real mailbox (or a group with send-as), through the service account in secrets/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, add https://www.googleapis.com/auth/gmail.send to its scopes (comma-separated, next to any others).
  • MicrosoftGraph: Microsoft 365 sends as EMAIL_FROM, a mailbox in your tenant (a shared mailbox works).
    1. Entra admin center → App registrations → New registration (no redirect URI). Put its Application (client) ID and the Directory (tenant) ID in EMAIL_GRAPH_CLIENT_ID and EMAIL_GRAPH_TENANT_ID.
    2. Certificates & secrets → New client secret → secrets/email_graph_client_secret. Note when it expires.
    3. API permissions → Add → Microsoft Graph → Application permissions → Mail.Send → Grant admin consent.
    4. 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.

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

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): the files volume on the server. The daily backup archives it next to the database dump.
  • Amazon S3: STORAGE_PROVIDER=S3, STORAGE_S3_BUCKET, and STORAGE_S3_REGION. On AWS with an IAM role, leave the key files empty; otherwise write secrets/storage_s3_access_key and secrets/storage_s3_secret_key for 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=AzureBlob and STORAGE_AZURE_CONTAINER in .env, and the connection string in secrets/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.

Terminal window
# 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 it
docker compose up -d sandly

Migrations 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_IMAGE back and up -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 .env held the passwords and keys: take the new release’s deployment files, then run ./install.sh once. It moves them into secrets/ and takes them out of .env; nothing else changes.
  • SANDLY_IMAGE=…/sandly:1.5 follows 1.5’s fixes on every docker compose pull. The current and previous minor versions get fixes (releases).

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:

Terminal window
docker compose exec backup /bin/sh /backup.sh now
Terminal window
docker compose stop sandly
docker 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 sandly

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

Terminal window
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>.dump
docker 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"

Add a new key and make it active; keep the old one so existing data still decrypts:

Terminal window
head -c 32 /dev/urandom | base64 | tr -d '\n' > secrets/encryption_key_k2 && chmod 644 secrets/encryption_key_k2

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

  • 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 answers Healthy (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.
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