Deploying hisar-server
Docker Compose runs Postgres and hisar-server from the container registry (linux/amd64 and linux/arm64). The server
listens on 127.0.0.1:8080 only; your nginx terminates TLS and proxies to it. Keep it that way: the server speaks plain
HTTP and, with HISAR_TRUST_PROXY=true, believes the X-Forwarded-For it is sent, so nothing but the proxy may reach
it. Run one hisar-server per database: rate limits, phone approvals, pending sign-ins and the event streams live in
that one process, so a second replica would neither see nor notify the first one's clients.
Images
A server release is a run of Actions → Release with the app server and a release type (patch, minor, major, or custom with
a version; CI and releases has the steps). It tags server-<version>, publishes
ghcr.io/tosunlab/hisar-server as :<version> and :sha-<commit>, for linux/amd64 and linux/arm64, built from
deploy/Dockerfile, and creates the GitHub prerelease server-<version>. Approving the run's Publish job then points
:<major>.<minor> and :latest at it and turns the prerelease into a release; only published releases make the admin
dashboard say "Version <version> is available". A custom build (server-1.0.0-alpha-test) can't be published. Running
"Release server image" by hand on a branch pushes :sha-<commit> only. Pull requests build the same image into a throwaway
registry and boot it, without pushing.
The workflows push with their own GITHUB_TOKEN, so the registry needs no secrets. While the package is private, the server
must log in once with a GitHub personal access token (classic) that has only the read:packages scope:
docker login ghcr.io -u <GitHub user> # paste the token as the password
First start
Copy compose.yaml, .env.example and hisar.env.example to the server (for example /opt/hisar), then:
(
umask 077 # only you may read the three files: they hold secrets
cp .env.example .env # set HISAR_DOMAIN, pin HISAR_VERSION
openssl rand -hex 32 | tr -d '\n' > db_password.txt
sed "s/CHANGE_ME/$(cat db_password.txt)/" hisar.env.example > hisar.env
)
chmod 600 .env db_password.txt hisar.env
docker compose up -d
curl http://127.0.0.1:8080/healthz # ok
On an empty, uninitialized database, hisar-server creates /data/hisar-secrets.json in the hisar-data volume.
It contains the OPAQUE server keys and HMAC key. Back up this file with the database. Startup refuses a missing file
when accounts or an enrolled secrets fingerprint exist. Restore the matching file instead of generating new keys.
After fingerprint enrollment, a different valid file also fails startup. The first upgrade of an older database cannot
detect a wrong file supplied before enrollment, so confirm the original secrets volume before upgrading.
nginx
nginx.conf is a site for /etc/nginx/sites-available/: replace hisar.example.com and the certificate paths
(e.g. from certbot certonly --nginx -d <domain>), and keep the port in line with HISAR_PORT. What the server needs from
the proxy:
client_max_body_size 32m: vault key rotation uploads a whole vault./api/v1/events(instant sync) unbuffered with a longproxy_read_timeout; the server sends a heartbeat every 25 s.X-Forwarded-Forfrom$proxy_add_x_forwarded_for: withHISAR_TRUST_PROXY=truethe server rate-limits by its last entry, the address nginx saw.- The
hisarlog format, which logs paths without query strings and only the route of/invite/…,/s/…and/api/v1/public/share-links/…, regardless of letter case, so invite codes, share link IDs and sign-in email addresses stay out of the access log. nginx's error log still names the whole request when it cannot reach the server, so keep it as private as the database.
sudo nginx -t && sudo systemctl reload nginx
curl https://<domain>/healthz # ok
Run bash scripts/test-nginx.sh from the repository root to check this configuration in a Docker container. It checks
nginx -t and the HTTPS and redirect access logs with lowercase, uppercase, and mixed-case capability paths.
Open the setup URL from the log
On an empty database, hisar-server serve logs one line with a one-time setup URL:
docker compose logs hisar | grep 'to create the first admin'
# Open https://<domain>/admin/setup#<token> to create the first admin.
Open it in a browser. The dashboard creates the first admin in the browser (name, email and master password), shows the
Secret Key and the Download Emergency Kit button, runs the server's checks (it continues past warnings, never past a
failed check), and asks who can invite people. It ends on Overview, signed in as the new admin. The token sits in the
URL fragment, so it never reaches nginx's access log or a Referer header. Each restart replaces it with a new one until
the first account exists; a link from an earlier start answers "This setup link is no longer valid. Use the link in the
latest server log."
To start from the command line instead, run docker compose exec hisar hisar-server invite. It asks for the email
address; the first invite defaults to the admin role. Open the printed link in the desktop app, the iPhone app, or the
CLI (hisar signup https://<domain>/invite/<token>). Once that account exists the setup URL stops working, and opening
/admin/setup while signed in as that admin finishes the remaining steps. Neither way needs email setup.
Email (invites and sharing)
Signed-in users invite people and share vaults by email, and the server sends those emails itself
(Design §5.1, §5.8). Without email, POST /invites answers "email is not configured on this server"; sharing
with someone who already has an account still works, and they see the invitation in the app.
- Get SMTP credentials from your mail provider for a sending address such as
noreply@<domain>, and set up SPF and DKIM for that domain so the emails are delivered. - Sign in to the admin dashboard at
https://<domain>/admin, open Settings → Email, fill in the host, port, username, password and sender, click Save, then Send test email… to check them. The change applies at once. The server encrypts the SMTP password with a domain-separated key derived fromhisar-secrets.json. A database-only dump does not contain that key. A live server, or someone with both the dump and secrets file, can decrypt it. Changing the host, port or username later clears it, so enter it again in the same save.
Or set them in .env on the server, which keeps the SMTP password out of the database. Any HISAR_SMTP_* variable
there sets all of Email: the values saved in the dashboard are ignored, a variable left empty gives its default (port
587), and the dashboard shows every Email field read-only with "Email is set by HISAR_SMTP_* variables on the server."
With a host, the server needs a port, a sender and, with a username, a password, and refuses to start without them:
HISAR_SMTP_HOST=smtp.example.com
HISAR_SMTP_PORT=587 # 465 for implicit TLS; any other port must offer STARTTLS
HISAR_SMTP_USERNAME=…
HISAR_SMTP_PASSWORD=…
HISAR_SMTP_FROM=Hisar <noreply@example.com>
Then chmod 600 .env && docker compose up -d. The log says email through smtp.example.com port 587.
The server never sends credentials without TLS. The emails are plain text without tracking; failures are logged as
email not sent: …, without the recipient's address.
Instance settings
Admins change the server's settings in the admin dashboard under Settings, without a restart: Email (above),
Sign-up (who can invite people: anyone with an account, or admins only), App versions (the minimum app versions
of Design §8.7) and Security (session lifetime, auth burst, and how long item history, the audit log and
expired invitations are kept; the audit log at least 30 days). They are stored in the instance_settings table. These
variables in .env override a field and lock it in the dashboard (any of the Email variables locks all of Email);
compose.yaml passes each of them to the server, and an empty value leaves the field to the dashboard:
| Setting | Variable |
|---|---|
HISAR_SMTP_HOST, HISAR_SMTP_PORT, HISAR_SMTP_USERNAME, HISAR_SMTP_PASSWORD, HISAR_SMTP_FROM | |
| App versions | HISAR_MINIMUM_VERSION_DESKTOP_MACOS, HISAR_MINIMUM_VERSION_IOS, HISAR_MINIMUM_VERSION_ANDROID, HISAR_MINIMUM_VERSION_CLI, HISAR_MINIMUM_VERSION_EXTENSION |
| Auth burst | HISAR_AUTH_BURST |
Run one server process per database: each process reads the settings at start and after its own saves.
An HTTP 507 answer from the server means a user's change hit a growth budget, such as the vaults one account can create or a vault's item history (Design §8.2.2). It is not a server fault: the user sees what to remove, and nothing needs fixing on the server.
Desktop app updates
The macOS app checks the server it is signed in to for updates (Design §9.1.1). The server reads the published
desktop-<version> releases of tosunlab/hisar on GitHub and relays the appcast and the download, so apps never talk to
GitHub. This needs no setup once the repository is public; while it is private, the server needs a read-only token:
-
On GitHub, open Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token.
-
Name it e.g.
hisar-server updates, set an expiry date, choose the resource ownertosunlab, Only select repositories →tosunlab/hisar, and under Repository permissions only Contents: Read-only. Create it and copy the token. -
In
.envon the server, uncomment and fill in:HISAR_DESKTOP_UPDATE_GITHUB_TOKEN=github_pat_… -
chmod 600 .env && docker compose up -d. The log saysdesktop updates from the GitHub Releases of tosunlab/hisar, with a token.
Check it with a session token from a signed-in device (desktop.json in the app's state folder):
curl -H "Authorization: Bearer <token>" https://<domain>/api/v1/desktop/updates/appcast.xml. The appcast is cached for
10 minutes; if GitHub is unreachable or rate limits the server, the last appcast is served. Without a usable token for a
private repository (missing or expired), update checks fail with "the update source is unreachable" and the log says
no repository tosunlab/hisar (a private repository needs a token). HISAR_DESKTOP_UPDATE_GITHUB_REPOSITORY reads another
repository (e.g. a fork with its own signed releases), and HISAR_DESKTOP_UPDATE_GITHUB_REPOSITORY=off turns updates off:
the endpoints then answer 404 "updates are not configured on this server".
Upgrades
# set HISAR_VERSION in .env to the new release
docker compose pull && docker compose up -d
hisar-server serve applies pending migrations on start, guarded by a Postgres advisory lock. To run them explicitly
first:
docker compose run --rm hisar migrate status
docker compose run --rm hisar migrate up
docker compose run --rm hisar migrate down # roll back the latest migration
Take a backup before applying a migration that changes existing tables.
Migration 00013_invitation_sources invalidates every sign-up invite that has not been used yet, including links from
hisar-server invite, the admin dashboard, and share or organization invitations sent to people without an account.
Older invites cannot be linked to the invitation they came from, so canceling that invitation could not stop them.
Accounts and invites that were already used are not changed. After the upgrade, resend any invite that is still
needed. Stop every server process running an older version before the migration runs.
Migration 00016_operational_secrets enrolls a fingerprint of both authentication keys, and migration
00018_smtp_password_encryption encrypts any existing dashboard SMTP password, on the next server startup. The
transaction finishes before settings are read or requests are served, including when SMTP environment overrides are
set. Existing plaintext in older backups remains sensitive. Once secrets are enrolled, migrate down refuses both
migrations. To run a pre-upgrade binary, restore its matching pre-upgrade database and secrets backup into an isolated
instance. Do not remove the fingerprint or decrypt the row by hand.
Backups
Back up nightly, after configuration changes, and before upgrades. Keep the database dump and matching secrets archive as one recovery set. Stop the server while taking the pair. The Postgres container stays running.
umask 077
mkdir backup
docker compose stop hisar
docker compose exec -T postgres pg_dump -U hisar -Fc hisar > backup/database.dump
docker cp "$(docker compose ps -aq hisar):/data/hisar-secrets.json" - > backup/secrets.tar
docker compose start hisar
The tar archive preserves the secrets file's owner and private permissions. Encrypt the recovery set before copying it
off-server, for example with age, and keep its decryption key elsewhere. Preserve the deployment's protected .env,
hisar.env, and db_password.txt configuration too. A backup containing both the database and server secrets
exposes the SMTP password if its own encryption fails.
Retain several dated sets, including a known-good pre-upgrade pair, under your organization's retention policy.
The dump contains encrypted vault payloads, plaintext account and sharing metadata, audit events and instance settings. It is sensitive, not ciphertext-only. The database schema records emails, devices, access grants and audit IPs outside the encrypted item payloads. Organization names and rosters are also plaintext. Operational credentials have different protection from vault keys, as described under Email above. Treat both backup components as sensitive. Possession of server secrets does not itself supply vault decryption keys.
A native client's offline cache is not a substitute for a server backup. It contains only records that device has downloaded and may omit whole unsynced vaults or newer writes. See the cache and export limits.
Test a paired restore
Use a fresh directory and an isolated Compose project, never the running project's volumes. Copy the deployment files
and protected configuration there. Pin the original server version first. Decrypt a recovery set into backup/.
Set HISAR_PORT=18080 in the copied .env, or choose another unused port and adjust the check below. Keep HISAR_PUBLIC_URL unchanged and route its original hostname to
the isolated host through a private TLS proxy for the sign-in check. Do not expose the restore instance publicly.
docker compose -p hisar-restore up -d postgres
docker compose -p hisar-restore exec -T postgres pg_restore --clean --if-exists -U hisar -d hisar < backup/database.dump
docker compose -p hisar-restore create hisar
docker cp -a - "$(docker compose -p hisar-restore ps -aq hisar):/data" < backup/secrets.tar
docker compose -p hisar-restore start hisar
curl http://127.0.0.1:18080/healthz
Expect ok. Sign in with an existing test account using its master password and Secret Key. Open a known encrypted
item and compare its value. Send a test email through a disposable SMTP sink, not a production recipient. Repeat after
upgrading the isolated instance. Test this recovery procedure quarterly and record the results.
If startup reports missing or mismatched secrets, stop and restore the matching archive. Do not delete account data,
remove the fingerprint, or replace keys to silence the error. Without the original authentication secrets, new keys
cannot recover existing accounts. The missing-file check also applies to hisar-server invite. A healthy /healthz
alone does not prove that accounts or encrypted SMTP settings were recovered.
Building from source
docker compose -f compose.yaml -f compose.build.yaml up -d --build