Skip to main content

Deploying a Siter Environment

This guide walks through deploying a Siter environment using Docker Compose. A standard deployment consists of four services:

  • Siter API — the backend application server
  • Siter UI — the frontend web application
  • PostgreSQL — the primary database (with PostGIS for spatial data)
  • Pufferfish API — the analysis engine for facility evaluation

Optionally, you can also add:

  • MongoDB — enables bug report, test case, and criteria reference features in Pufferfish, as well as MongoDB log shipping
  • Grafana Loki — centralized log aggregation for Pufferfish

Prerequisites​

  • Docker and Docker Compose installed on the host machine
  • Access to the Siter container registry (provided by the Siter team)
  • A valid Siter license (provided by the Siter team)
  • Hostnames with DNS records (or fixed IP addresses) for the UI and the API — see Hostnames and network
  • For HTTPS: a TLS-terminating reverse proxy with certificates in front of the containers. Siter does not terminate TLS itself; see Choosing HTTP or HTTPS

Hostnames and network​

The compose file below runs the containers and publishes plain HTTP on two host ports: the UI on port 80 and the API on port 5000. Everything between those ports and your users is your organization's network and is not provided by Siter. That includes DNS, TLS certificates, any reverse proxy or load balancer, and firewall rules. Decide on it before you fill in the configuration, because the same hostnames appear in several settings.

siter.example.com and siter-api.example.com throughout this guide are placeholders. Replace them with hostnames you own.

The standard layout uses two hostnames: the browser loads the UI from one and calls the API on the other. The API hostname must be reachable from users' browsers (not only from the Docker network), and the API's corsUrls must list the UI origin.

The UI also accepts a same-origin configuration, where one hostname serves the UI and a reverse proxy forwards /api/ and /hubs/ to the API container. The application supports it, but the Siter team has not deployed it as a containerized environment; treat it as an advanced option and test it in a staging environment first. See Same-origin deployments. (The single-user desktop install is same-origin by construction because the API serves the UI files itself — see UI and Hosting — and needs none of the proxy setup described there.)

Choosing HTTP or HTTPS​

The Siter containers serve plain HTTP only. The UI container's web server listens on port 80 with no TLS configuration, and the API listens on http://0.0.0.0:5000. Siter does not obtain, store, or renew certificates. To serve Siter over HTTPS, you put a TLS-terminating reverse proxy or load balancer in front of both containers. The proxy holds the certificates and forwards plain HTTP to the published ports.

HTTP (DNS only)HTTPS (reverse proxy)
What you provideDNS records (or IP addresses) that point at the Docker hostDNS records, certificates, and a reverse proxy that terminates TLS
UI addresshttp://siter.example.comhttps://siter.example.com
host_url (API address)http://siter-api.example.com:5000https://siter-api.example.com
Sign-in methodsActive Directory (32) and Windows (2) onlyAll
TrafficSign-in tokens and Active Directory passwords cross the network unencryptedEncrypted between the browser and the proxy
Suitable forIsolated or internal networks, evaluationAnything reachable from outside a trusted network

Microsoft and Google sign-in require HTTPS. Entra ID and Google accept plain-http redirect URIs and origins only for localhost, and the browser sign-in libraries need a secure context. On a plain-HTTP deployment that users reach by hostname or IP address, only the Active Directory and Windows login types work.

Both hostnames must use the same scheme. If the UI is served over https, host_url must be https too, because browsers block calls from an https page to an http API as mixed content. TLS therefore has to terminate in front of the API as well as the UI.

The base compose file is the HTTP layout. Adding HTTPS with a reverse proxy lists what changes for HTTPS.

HTTP (DNS-only) layout​

Users' browsers connect to the Docker host's published ports directly, with no reverse proxy or load balancer in between. You only need DNS records that point both hostnames at the Docker host:

Both names can resolve to the same address, because the two services listen on different ports. You can also skip DNS and use the host's IP address in place of the hostnames, for example http://10.20.30.40 for the UI and http://10.20.30.40:5000 for host_url, with http://10.20.30.40 in corsUrls. An internal DNS name or a hosts-file entry works the same way. The host's firewall must allow inbound connections on ports 80 and 5000 from users' workstations.

HTTPS layout​

A reverse proxy holds the certificates for both hostnames, accepts HTTPS on port 443, and forwards plain HTTP to the containers:

The DNS records point at the proxy, not the Docker host. The compose file only defines what runs inside the host. You provide the proxy and its certificates, and you renew the certificates.

Using the hostnames consistently​

The UI origin goes in the API's corsUrls and, when Microsoft sign-in is used, in the Entra ID app registration's redirect URI and the optional msftRedirectUri. The API origin goes in env.js as host_url. Each value must match what users' browsers use exactly, including the scheme and any non-default port.

Base Docker Compose Configuration​

Below is a base docker-compose.yaml you can use as a starting point. Copy this file and customize the environment variables for your deployment. It is the HTTP (DNS-only) layout: browsers reach the containers' published ports directly. The example.com hostnames are placeholders for your own. For HTTPS, make the changes in Adding HTTPS with a reverse proxy.

networks:
siter:

services:
# ── Siter API (backend) ──────────────────────────────────────────
siter-api:
image: <provided-by-siter-team>/siterapi:latest
ports:
- 5000:5000
networks:
- siter
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://0.0.0.0:5000
- ConnectionStrings__SITER_DB=Host=siter-db;Port=5432;Database=siterdb;Username=siter;Password=CHANGE_ME
- LoginTypes=20
- multiTenant=false
- corsUrls=["http://siter.example.com"]
- AnalysisOptions__AnalysisUrl=http://pufferfish:26667
- AnalysisOptions__DictionaryUrl=http://pufferfish:26667
- Licensing__PublicKey=PROVIDED_BY_SITER_TEAM
- jwt__AuthDomain=siter
- jwt__AuthKey=CHANGE_ME_TO_A_LONG_RANDOM_STRING
- jwt__AuthMinutes=480
- FileStorage__BasePath=/data/storage
volumes:
- siter-storage:/data/storage
depends_on:
siter-db:
condition: service_healthy
pufferfish:
condition: service_started

# ── Siter UI (frontend) ─────────────────────────────────────────
siter-ui:
image: <provided-by-siter-team>/siterui:latest
ports:
- 80:80
networks:
- siter
# Runtime UI configuration — the image is deployment-agnostic; env.js
# supplies the deployment-specific values (see "UI Runtime Configuration").
configs:
- source: siter-ui-env
target: /usr/share/nginx/html/env.js

# ── PostgreSQL + PostGIS (Siter database) ───────────────────────
siter-db:
image: postgis/postgis:18-3.6
networks:
- siter
ports:
- 5432:5432
volumes:
- siter-data:/var/lib/postgresql
environment:
- POSTGRES_DB=siterdb
- POSTGRES_USER=siter
- POSTGRES_PASSWORD=CHANGE_ME
healthcheck:
test: ["CMD-SHELL", "pg_isready -U siter -d siterdb"]
interval: 5s
timeout: 5s
retries: 5

# ── Pufferfish API (analysis engine) ────────────────────────────
pufferfish:
image: <provided-by-siter-team>/pufferfish:latest
ports:
- 26667:26667
networks:
- siter
environment:
- PINO_LOG_LEVEL=warn

configs:
siter-ui-env:
content: |
window.__env = {
host_url: "http://siter-api.example.com:5000",
name: "production",
isTestEnvironment: false,
};

volumes:
siter-data:
siter-storage:
warning

Replace all CHANGE_ME values with secure, unique passwords before deploying. The jwt__AuthKey should be a long, random string used to sign authentication tokens.

note

The inline content: form of configs: requires Docker Compose 2.23.1 or newer. On older versions, put the same JavaScript in a local env.js file and bind-mount it into the UI container instead — see UI Runtime Configuration.

Adding HTTPS with a reverse proxy​

Siter does not terminate TLS itself (see Choosing HTTP or HTTPS), so HTTPS needs a reverse proxy or load balancer in front of both containers. Any proxy that meets the requirements below works, including nginx, Caddy, Traefik, IIS with Application Request Routing, or a cloud load balancer. The proxy terminates TLS and forwards plain HTTP to the containers. Nothing inside the containers changes.

1. Change the compose file​

Switch both origins to https and drop the :5000 port from host_url, because browsers now reach the API through the proxy on port 443:

siter-api:
ports:
- 127.0.0.1:5000:5000 # reachable only through the proxy
environment:
- corsUrls=["https://siter.example.com"]

siter-ui:
ports:
- 127.0.0.1:8080:80 # frees port 80 for the proxy's HTTP-to-HTTPS redirect

configs:
siter-ui-env:
content: |
window.__env = {
host_url: "https://siter-api.example.com",
name: "production",
isTestEnvironment: false,
};

Binding the published ports to 127.0.0.1 stops users from bypassing TLS by going to the containers directly. This assumes the proxy runs on the Docker host. If the proxy runs on another machine, keep the ports published on the host's network address and use the host firewall to allow only the proxy to reach ports 8080 and 5000.

If Microsoft sign-in is enabled, change the Entra ID app registration's redirect URI (and msftRedirectUri, if set) to the https UI origin as well.

2. Meet the proxy requirements​

  • One site per hostname, each with a certificate that is valid for its name. A single certificate that covers both names, such as a SAN or wildcard certificate, also works.
  • Pass the original Host header to both containers. The API records the request host in project export manifests.
  • Pass WebSocket upgrades on the API hostname. /hubs/ carries SignalR real-time updates over WebSockets.
  • Allow large uploads, long requests, and unbuffered streaming on the API hostname. Set the request-body limit to at least the larger of Imagery__MaxFileSizeBytes (300 MB by default) and Import__MaxSnapshotBytes, allow a read timeout of several minutes, and turn off response buffering for exports. See Project Export for the details.
  • Redirect plain HTTP to HTTPS on port 80, so that old bookmarks and typed addresses still work.

Example: nginx​

map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

# Plain HTTP → HTTPS
server {
listen 80;
server_name siter.example.com siter-api.example.com;
return 301 https://$host$request_uri;
}

# UI
server {
listen 443 ssl;
server_name siter.example.com;
ssl_certificate /etc/ssl/siter/fullchain.pem;
ssl_certificate_key /etc/ssl/siter/privkey.pem;

location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

# API
server {
listen 443 ssl;
server_name siter-api.example.com;
ssl_certificate /etc/ssl/siter/fullchain.pem;
ssl_certificate_key /etc/ssl/siter/privkey.pem;

client_max_body_size 300m; # >= Imagery__MaxFileSizeBytes and Import__MaxSnapshotBytes
proxy_read_timeout 600s; # snapshot imports and archive exports

location / {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade; # SignalR (/hubs/)
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

Example: Caddy​

Caddy obtains and renews Let's Encrypt certificates automatically when both hostnames have public DNS records and ports 80 and 443 are reachable from the internet. It passes the Host header and WebSocket upgrades by default and sets no request-body limit:

siter.example.com {
reverse_proxy 127.0.0.1:8080
}

siter-api.example.com {
reverse_proxy 127.0.0.1:5000
}

Certificates​

Certificates can come from a public CA (for example, Let's Encrypt through certbot or Caddy) or from your organization's internal CA. Users' workstations must trust an internal CA. Otherwise, browsers show certificate warnings and refuse the API calls. The proxy renews certificates on its own schedule, and the Siter containers do not need to be restarted when it does.

Verify HTTPS​

  1. Open https://siter-api.example.com/api/health/status. It shows ok without a certificate warning.
  2. Check that the API log line Accepting CORS from: … lists https://siter.example.com.
  3. Open https://siter.example.com with the browser's developer tools open. The console shows no mixed-content errors, and the Network tab shows a WebSocket connection to /hubs/ with status 101.
  4. Open http://siter.example.com. It redirects to the https address.

UI Runtime Configuration (env.js)​

The Siter UI image is deployment-agnostic: one image serves every environment, and nothing specific to your deployment is baked in at build time. Each deployment supplies its settings at runtime by mounting a small JavaScript file at /usr/share/nginx/html/env.js inside the siter-ui container. The file runs in the browser before the application boots and assigns window.__env:

window.__env = {
host_url: "http://siter-api.example.com:5000",
name: "production",
isTestEnvironment: false,
};

Mounting the file​

The base compose file above mounts the file inline through a top-level configs: entry, which keeps the whole deployment in one file. On Docker Compose versions older than 2.23.1, save the JavaScript as env.js next to the compose file and bind-mount it read-only instead:

siter-ui:
volumes:
- ./env.js:/usr/share/nginx/html/env.js:ro

After changing the file, recreate the UI container (docker compose up -d --force-recreate siter-ui). The UI serves env.js with Cache-Control: no-store, so browsers pick up the change on their next reload — no cache purge is needed.

Recognized keys​

Only the keys below are recognized. Unknown keys and wrongly-typed values are ignored with a warning in the browser console (the warnings also appear on the configuration-error page, when it is shown).

KeyRequiredDescription
host_urlYesOrigin of the Siter API — scheme://host[:port] only, with no path, query, or fragment. The app appends /api/… and /hubs/… itself. This is the address users' browsers call, so it must resolve and be reachable from their workstations, not just from inside the Docker network. See host_url rules below.
nameRecommendedFree-text label for this deployment. Shown in the browser console and in the configuration-error page diagnostics. It is a label only — it does not change any behavior.
isTestEnvironmentYes, on productionControls the Test Environment! Do not enter sensitive information. banner. The compiled default is true (fail-safe), so every user sees the banner unless the deployment explicitly sets isTestEnvironment: false. Set true (or omit it) on PTR, staging, and test environments.
msftDefaultClientIdNoMicrosoft sign-in fallback, used only when the API's authentication configuration cannot be reached. Normally leave it out.
msftRedirectUriNoMicrosoft sign-in redirect fallback, same conditions. If set, it must be the bare origin of the UI (e.g., https://siter.example.com) — never a path.
wktApiNoSet true to exchange geometry with the API as WKT text instead of the default binary (WKB) form. Default false. Leave it at the default unless the Siter team tells you otherwise.
tip

The deployment name is a label and nothing more. Naming a deployment PTR no longer forces the test banner on; only isTestEnvironment controls it. When isTestEnvironment is false but the name looks non-production (contains "ptr", "test", "stag", or "qa"), the UI logs a console warning that the banner has been suppressed.

host_url rules​

The UI validates host_url before it boots and refuses to start when the value would make every API call fail. The value must:

  • be an absolute http:// or https:// URL;
  • carry no path — a pasted http://siter-api.example.com:5000/api is rejected with a hint that /api was appended, and a path-prefixed API behind a reverse proxy (e.g., /siter/…) is not supported — expose the API at a host root;
  • carry no query string, fragment, or embedded credentials (user:pass@);
  • use https when the UI itself is served over https. Browsers block plain-http calls from an https page as mixed content, so this combination is rejected outright. (Plain-http localhost/127.x targets are allowed from an https page with a warning, for developer setups.)

Trailing slashes, a stray trailing ?, #, or whitespace, and a redundant default port are all normalized away — the UI stores the canonical origin, never the raw string. A plain-http host_url to a non-loopback host is accepted with a console warning that bearer tokens will transit unencrypted.

The configuration-error page​

If env.js is missing, fails to load, or does not set a valid host_url, a production build refuses to boot into the app. Instead it shows a full-page diagnostic screen titled Runtime configuration is missing (browser tab: Siter — Configuration Error). The page contains:

  • a How to fix list — mount the file, base it on env.example.js, recreate the container, and hard-refresh (Ctrl+Shift+R) — with a copy-ready compose configs: snippet;
  • a Warnings from env.js section listing every key that was ignored or rejected, and why;
  • a Diagnostics block: whether env.js loaded, the host_url that was received and the rejection reason, name, isTestEnvironment, the page origin, and the time.

The env.js loaded line distinguishes the three ways a mount goes wrong: the image's placeholder was served (the file was never mounted), the browser could not fetch the file (an unreadable mount, or blocked), or a file was served but did not assign window.__env (an empty file, a syntax error, or JSON mounted where JavaScript was expected). The page prints legibly, so it can be attached to a support ticket as-is.

Same-origin deployments​

caution

The application supports this layout, but the Siter team has not run a containerized same-origin environment. The two-hostname layout in the compose file is the tested path. If you choose same-origin, verify sign-in, an analysis run, and a project export (which streams over /api/) in a staging environment before going live.

When the UI and API sit behind one hostname, env.js is ordinary JavaScript, so the supported pattern is:

window.__env = { host_url: window.location.origin, name: "production", isTestEnvironment: false };

This requires a reverse proxy in front of (or inside) the UI container that forwards both /api/ and /hubs/ to the Siter API — /hubs/ carries SignalR and needs WebSocket upgrade headers passed through. The nginx configuration shipped in the UI image deliberately answers 404 for /api/ so that a missing proxy fails loudly instead of returning the app shell with HTTP 200 for every API call. To run same-origin, replace that location ^~ /api/ block with a proxy_pass to your API container and add a matching location ^~ /hubs/ block. A same-origin deployment does not need corsUrls on the API.

Storage and Volumes​

Containers are disposable — anything a service writes inside its own filesystem is lost when the container is recreated (every image update recreates containers). All durable state in a Siter deployment therefore lives in mounts, and knowing what lives where is the foundation for backups, upgrades, and host migrations.

The base configuration uses two named volumes:

VolumeMounted atServiceContents
siter-data/var/lib/postgresqlsiter-dbThe database — projects, features, analysis results, users, licenses. This is the deployment's primary data.
siter-storage/data/storagesiter-apiUploaded files, primarily hosted imagery.

The UI's runtime configuration file is mounted from the compose file itself through a configs: entry (see UI Runtime Configuration); on older Compose versions it becomes a read-only bind mount instead. Two further optional bind mounts appear elsewhere in this guide, both read-only configuration files: the Active Directory CA certificate and a license file (Licensing__LicenseFilePath).

warning

Mount the database volume at /var/lib/postgresql — not /var/lib/postgresql/data. PostgreSQL 18+ images keep the data directory in a version-specific subdirectory (e.g., /var/lib/postgresql/18/docker) and refuse to start when anything is mounted at the legacy /data path — even an empty volume. The failure looks like this, repeating as the container restarts:

Error: in 18+, these Docker images are configured to store database data in a
format which is compatible with "pg_ctlcluster" ...
Counter to that, there appears to be PostgreSQL data in:
/var/lib/postgresql/data (unused mount/volume)

If you see this on a fresh deployment, correct the mount in your compose file and run docker compose up -d again — no data is affected, because the database never initialized.

Named volumes vs. bind mounts​

Both make host storage available inside a container; they differ in who manages the location.

  • A named volume (siter-data:/var/lib/postgresql) is created and managed by Docker in its own storage area. Docker handles ownership and permissions, the data survives docker compose down and container recreation, and the compose file stays portable because no host paths are hard-coded. Inspect them with docker volume ls and docker volume inspect <name>.
  • A bind mount (./certs/ca-root.crt:/usr/local/share/ca-certificates/ad-ca-root.crt:ro or /mnt/siter/storage:/data/storage) maps an explicit host path into the container. You control exactly where the files live and can read or edit them directly with host tooling, but you also own the consequences: the path must exist, its ownership/permissions must suit the container's user, and the compose file is now tied to that host's layout.

Use a named volume by default for data the services write — the database and the file storage. Use a bind mount for configuration files you maintain on the host (certificates, a license file, an env.js on older Compose versions — mount them :ro), or for written data when you have a specific placement requirement, such as putting imagery storage on a particular disk or network share, or wanting host-level backup tools to see the files directly. Either mount type works for siter-data and siter-storage; the mount type is invisible to Siter itself.

What this means for managing the environment​

  • Upgrades are safe by design: pulling new images and running docker compose up -d recreates containers but leaves volumes untouched. Data survives; the containers are rebuilt around it.
  • docker compose down -v deletes named volumes. The -v flag removes the database and all uploaded imagery permanently. Never use it on a production environment. (Bind-mounted paths are never deleted by compose.)
  • Backups: two complementary approaches, protecting against different failures — use both if you can.
    • Block-level snapshots (e.g., EBS snapshots of the disk holding the data) are fast, incremental, and safe to take while the database is running provided the entire data directory lives on the single snapshotted volume — a point-in-time snapshot is crash-consistent, and PostgreSQL recovers from it via WAL replay exactly as from a power loss. Restores are whole-disk and only work with the same PostgreSQL major version.
    • pg_dump is slower but portable: it restores across PostgreSQL versions (this is the upgrade path when the database image moves to a new major), supports selective restore of a single database or table, and reads every row — so silent data corruption fails loudly at backup time instead of at restore time.
    • Whichever you use, back up the siter-storage contents together with the database — a restored database that references imagery files which no longer exist on disk will show those hosted-imagery layers as missing.
  • Moving hosts: bind-mounted paths travel with an ordinary filesystem copy. Named volumes must be exported explicitly (for example, docker run --rm -v siter-data:/from -v $(pwd):/to alpine tar czf /to/siter-data.tar.gz -C /from .) and restored the same way on the new host. If you anticipate frequent migrations, that's a point in favor of bind mounts for the data directories.

Starting the Environment​

docker compose up -d

On first startup, Siter will automatically run database migrations to initialize the schema. You can verify the services are running with:

docker compose ps

The API will be available at http://localhost:5000, the UI at http://localhost:80, and the Pufferfish analysis engine at http://localhost:26667.

First Sign-In: Creating the Administrator Account​

A freshly started environment has no users, and every sign-in method except no-auth requires an approved account before the identity provider's login is accepted. The bootstrap uses a temporary seed account to approve the first real administrator, then removes itself. Do this before users are given the address, and keep the window short: while step 1's configuration is in place, anyone who can reach the UI can sign in as any name without a password.

1. Start the stack with a temporary seed account and no-auth sign-in​

In docker-compose.yaml, add two settings to the API service and start (or recreate) it:

siter-api:
environment:
- LoginTypes=28 # your production value plus 8 (NoAuth); 20 + 8 = 28 here
- SITER_SA=siter-sa::8::Siter SA

LoginTypes must include 8 (NoAuth) alongside your real sign-in methods — see Login Types. SITER_SA is LOGIN::LOGIN_TYPE::DISPLAY_NAME; with login type 8, the first part is the name you will type into the no-auth User Name box. On startup the API creates the account with every system role, including System Administrator, and logs Created SA account on startup. If the license has a free editor seat the account also gets Project Writer and occupies that seat until it is deactivated; if no seat is free, the account is created without it (the bootstrap does not need one).

docker compose up -d siter-api

2. Sign in as the seed account​

Open the UI. The sign-in dialog shows a User Name box and a Log In button in addition to the provider buttons. Enter the seed login (siter-sa in the example) and click Log In. The Admin item appears in the top navigation bar.

3. Register the real administrator's account​

Sign out, and in the sign-in dialog click Request Access to switch to sign-up mode. Sign up with the identity provider the administrator will use day to day: Sign up with Microsoft, Sign up with Active Directory, or Sign up with Windows (Google sign-up appears when that login type is enabled). Complete the provider's sign-in, fill in Email Address, User Name, and Organization, and click Sign Up. The request is queued for approval; the account cannot sign in yet.

4. Approve the registration and grant the administrator roles​

Sign in as the seed account again and open Admin > Registrations. The User Registrations page lists the pending request with its user name, email address, organization, and the customer it is licensed to. Tick the roles to grant — for the administrator, at least System Administrator plus the project roles they will work with (Project Reader, Project Writer, Project Administrator) — and click Accept. Granting Project Writer consumes an editor seat (see Licensing → Editor seats).

You can adjust roles later under Admin > Users: click the user's row, tick or clear the role checkboxes, and click Apply.

5. Verify the administrator can sign in​

Sign out and sign in with the provider account you registered. Confirm that Admin appears in the navigation bar and that Admin > Users opens. Do not continue until this works — once no-auth is removed, this account is the only way in.

6. Remove no-auth sign-in​

Edit the compose file: set LoginTypes back to the production value (drop the 8) and remove the SITER_SA line, then recreate the API:

docker compose up -d siter-api

The User Name / Log In controls disappear from the sign-in dialog. Removing SITER_SA matters even though the account already exists: the setting creates the account again on any later start if it is missing, so leaving it in place would resurrect a deleted seed account.

7. Retire the seed account​

Sign in as the administrator, open Admin > Users, and click the Siter SA row. Click Deactivate — this blocks sign-in, frees the editor seat it held, and keeps the audit trail — or Delete to remove it outright. Deactivated accounts are hidden from the list unless Include Inactive Accounts is ticked; with it ticked, each account shows Active or Inactive.

Skipping the no-auth window

If you know the exact login value the provider will present for the administrator, you can seed that account directly instead — for example SITER_SA=jane.doe@example.com::16::Jane Doe for a Microsoft account (the value is matched case-insensitively). The administrator then signs in with their provider on the first attempt and steps 2 through 7 are unnecessary. If the sign-in fails with an unapproved-login message, the stored value did not match what the provider reported; fall back to the procedure above.

Upgrading an Existing Environment​

  1. Back up the database and the file storage (see Storage and Volumes). The API applies database migrations automatically when it starts, and rolling back to an older image does not undo them.
  2. Update the image version tags in the compose file and pull the new images (docker compose pull), or docker load them on an air-gapped host.
  3. Run docker compose up -d. Containers are recreated; volumes are untouched.
  4. Ask users to hard-refresh every open Siter browser tab (Ctrl+Shift+R, or Cmd+Shift+R on macOS). A tab still running the previous version keeps working against the new API until it is refreshed.

Version-specific upgrade notes​

v0.33​

  • The Swagger API page is off by default. /swagger and /swagger/v1/swagger.json now return 404 unless EnableSwagger=true is set on the siter-api service. Point health checks or monitoring that used /swagger at /api/health/status instead. Admin > Application Settings shows Swagger API: disabled (set EnableSwagger to enable) while it is off.
  • echoRequests is off by default and logs route templates instead of request paths. Set echoRequests=true only while debugging.

v0.32​

  • Duplicate project memberships are removed. Earlier versions could hold two membership rows for one user on one project, which made that user's effective role arbitrary and left access in place after the member was removed. A one-time migration (AddProjectUsersUniqueIndex) keeps only the most recently written row for each user and project and deletes the others. The deleted rows cannot be restored, and rolling the image back does not bring them back. Before upgrading, save the list of affected memberships:

    SELECT u."Name", p."Name", pu."Role", pu."LastProjectAccess", pu."Id"
    FROM "ProjectUsers" pu
    JOIN "Users" u ON u."Id" = pu."UserId"
    JOIN "Projects" p ON p."Id" = pu."ProjectId"
    WHERE (pu."UserId", pu."ProjectId") IN
    (SELECT "UserId", "ProjectId" FROM "ProjectUsers" GROUP BY 1, 2 HAVING count(*) > 1)
    ORDER BY 1, 2;

    Run it with psql against the Siter database, for example docker compose exec siter-db psql -U siter -d siterdb, and keep the output. An empty result means the migration changes nothing. The migration writes a WARNING to the PostgreSQL server log for each row it removes, and one for each project left without an Administrator. After the upgrade, re-grant Project Administration Permission on any such project from Admin > Projects (it lists active projects; tick Include Inactive Projects to add archived ones, each with an Active or Archived status), and check that each listed user kept the access they should have.

  • Automatic label placement is switched on. The upgrade turns on Automatic label placement for every existing project, every layer label override, and every user's Feature Labels preferences, so labels on existing maps may move to avoid overlaps. Users can turn it off per project, per layer, or in their preferences. See Feature labels.

  • Paths become path layouts. Existing path facilities are converted to path layouts named after the facility. They appear in the Layouts sidebar and open in the path editor. See Path facilities.

v0.30​

  • Facility Description moves to its own field. A one-time migration moves the text facilities held in Notes (shown in the app as Facility Description) into a dedicated Description field, and rewrites project and layer feature-label templates that used {notes} to {description}. Per-feature Label Text overrides and users' default label templates on Preferences are not rewritten; a label that comes out blank or wrong is fixed by replacing {notes} with {description} in its template (see Feature labels). Take a database backup first: rolling the image back does not move the text back. (The migration file, ReclaimFeatureNotesAsDescription, documents the manual reverse SQL for a rollback.)
  • Hard-refresh every open tab after the upgrade. A tab still running the previous version that saves a facility sends no description, which clears that facility's migrated Facility Description.
  • Project files carry a format version. Archives (.siter-archive) are now format 2, which an older Siter refuses with This archive was created by a newer version of Siter. Snapshots (.siter-snapshot) now carry a schema version too, but only v0.30 and later can check it: a v0.30 snapshot imported into an older Siter imports without its Facility Descriptions. Keep environments that exchange project files on the same version. Files from older versions import into v0.30 normally, with the description moved into its new field. If a browser still running a cached copy of the old app refuses a new archive export, hard-refresh it.

Air-Gapped Installation​

When the deployment host cannot reach the container registry, transfer the images as files with docker save / docker load. The environment runs identically afterward — Docker Compose only pulls images that are missing locally, so once the images are loaded, docker compose up -d never touches the network.

Preparing the transfer (on a machine with registry access)​

  1. Pull the exact images the deployment needs — including the PostgreSQL image (the air-gapped host cannot reach Docker Hub either) and MongoDB/Loki if used:

    docker pull <provided-by-siter-team>/siterapi:<version>
    docker pull <provided-by-siter-team>/siterui:<version>
    docker pull <provided-by-siter-team>/pufferfish:<version>
    docker pull postgis/postgis:18-3.6
  2. Export the images into a single archive, then generate a checksum so the transfer can be verified:

    docker save <provided-by-siter-team>/siterapi:<version> \
    <provided-by-siter-team>/siterui:<version> \
    <provided-by-siter-team>/pufferfish:<version> \
    postgis/postgis:18-3.6 | gzip > siter-<version>-images.tar.gz
    sha256sum siter-<version>-images.tar.gz > siter-<version>-images.tar.gz.sha256
  3. Transfer both files to the air-gapped host by whatever channel is available (FTP, physical media).

note

Pin explicit version tags — never :latest. Pinned tags keep the compose file aligned with exactly what was shipped, and prevent Compose from ever attempting a network pull. If the air-gapped host's CPU architecture differs from the machine preparing the transfer, add --platform (e.g., --platform linux/amd64) to each docker pull.

Loading on the air-gapped host​

  1. Verify the archive arrived intact, then load it:

    sha256sum -c siter-<version>-images.tar.gz.sha256
    gunzip -c siter-<version>-images.tar.gz | docker load
  2. Confirm the images are present with docker image ls. They keep their original registry-qualified names and tags, so the standard compose file works unchanged — just make sure its image: references match the loaded tags exactly.

  3. Start the environment normally with docker compose up -d. Do not run docker compose pull (or up with --pull always) — there is no registry to pull from.

Upgrades follow the same loop: receive and verify a new archive, docker load it, update the version tags in the compose file, and docker compose up -d. Volumes are untouched (see Storage and Volumes), so data carries forward. Reclaim space from superseded images periodically with docker image prune -a.

Air-gap configuration notes​

  • Fonts and icons: from v0.29 every font the UI uses (Barlow, Inter for the submittal print forms, and the Material Symbols icon font) is bundled into the UI image. The browser makes no requests to Google Fonts, so text and icons render correctly with no internet access and no allow-list entries. Earlier images loaded these from fonts.googleapis.com and showed fallback fonts and missing icons offline.
  • UI configuration: host_url in env.js must be a hostname or address that browsers on the isolated network can resolve and reach — it is the browser, not the UI container, that calls the API.
  • Map background: the default OpenStreetMap basemap loads tiles from the internet and will be blank offline. Use hosted imagery (GeoTIFFs stored on the siter-storage volume) or an internal tile/WMS server via custom imagery instead — see Custom and hosted imagery.
  • Authentication: cloud sign-in providers are unreachable, so use on-premises Active Directory (LoginTypes=32) — the Login Types table lists this as the air-gapped configuration.
  • Licensing: supply the license as a file or environment value rather than relying on any external service — see Licensing.

Environment Variables Reference​

All configuration in Siter follows the ASP.NET Core configuration model. Environment variables override values from config files and use double underscores (__) as separators for nested settings (e.g., AnalysisOptions__AnalysisUrl).

Core Application​

VariableRequiredDefaultDescription
ASPNETCORE_ENVIRONMENTNoProductionThe application environment name.
ASPNETCORE_URLSNo—The URL(s) for Kestrel to listen on (e.g., http://0.0.0.0:5000).

Database Connection​

The database connection can be configured as a full connection string or as individual components. If both are provided, the full connection string takes precedence.

Option A: Full connection string

VariableRequiredDefaultDescription
ConnectionStrings__SITER_DBYes*—Full PostgreSQL connection string (e.g., Host=db;Port=5432;Database=siterdb;Username=siter;Password=pass).

Option B: Individual components

VariableRequiredDefaultDescription
SITER_DB_SERVERYes*—Database server hostname or IP address.
SITER_DB_USERYes*—Database username.
SITER_DB_PWYes*—Database password.
SITER_DB_DATAYes*—Database name.

* One of Option A or Option B is required.

Database Behavior​

VariableRequiredDefaultDescription
SITER_NO_MIGRATIONSNofalseSet to true to disable automatic database migrations on startup. Useful for environments where migrations are applied separately.

Authentication​

VariableRequiredDefaultDescription
LoginTypesNo0 (None)Bitmask controlling which authentication methods are enabled. See Login Types below.
jwt__AuthDomainYes—The issuer and audience string used when generating and validating JWT tokens.
jwt__AuthKeyYes—The secret key used to sign JWT tokens. Must be a long, random string.
jwt__AuthMinutesNo480How long (in minutes) JWT tokens remain valid before expiring.

Login Types​

LoginTypes is a bitmask (flags enum). Combine values by adding them together.

ValueTypeDescription
1LoginReserved.
2WindowsWindows/Kerberos authentication (NTLM/Negotiate).
4GoogleGoogle OAuth sign-in.
8NoAuthAuto-login without credentials. Development/testing only.
16MicrosoftMicrosoft Entra ID (Azure AD) sign-in.
32Active DirectoryOn-premises Active Directory (LDAP) sign-in.

Common configurations:

ValueMethodsUse Case
12Google + NoAuthDevelopment with quick login
20Google + MicrosoftProduction (typical)
4Google onlyProduction (Google only)
16Microsoft onlyProduction (Microsoft only)
48Microsoft + Active DirectoryProduction (hybrid cloud + on-prem)
32Active Directory onlyAir-gapped / on-premises only

Microsoft Entra ID (Azure AD) SSO​

These are required when Microsoft login is enabled (LoginTypes includes 16). See the SSO Setup Guide for detailed instructions on configuring the Azure App Registration.

VariableRequiredDefaultDescription
AzureAd__ClientIdConditional—The Application (client) ID from your Azure App Registration.
AzureAd__RedirectUriConditional—The redirect URI configured in your App Registration (must match exactly).
AzureAd__InstanceNohttps://login.microsoftonline.com/The Azure AD authority URL. Change for government clouds (e.g., https://login.microsoftonline.us/).
AzureAd__TenantIdNocommonThe Azure AD tenant ID. Use common for multi-tenant, or a specific tenant GUID to restrict access.

Active Directory (LDAP)​

These settings provide default values for Active Directory authentication when no SSO configuration exists for a user's email domain. In most deployments, AD settings are managed per-organization through the Admin > SSO Integrations UI instead.

VariableRequiredDefaultDescription
ActiveDirectory__ServerNo—Default AD domain controller hostname or IP. Only used as fallback when no SSO config matches the user's email domain.
ActiveDirectory__PortNo636LDAP port. Use 636 for LDAPS (recommended) or 389 for plain LDAP.
ActiveDirectory__UseSslNotrueWhether to use TLS for the LDAP connection.
ActiveDirectory__DefaultUpnSuffixNo—Optional UPN suffix override. When set, replaces the email domain in the UPN sent to AD (e.g., if users sign in with user@company.com but the AD UPN is user@corp.local).
ActiveDirectory__SkipCertificateValidationNofalseDevelopment only. Set to true to disable TLS certificate validation for LDAP connections. In production, the API container must trust the AD domain controller's CA certificate — see TLS Certificate Setup below.

Active Directory TLS Certificate Setup​

AD domain controllers use certificates issued by the organization's internal Certificate Authority (typically AD Certificate Services). The Siter API container must trust this CA for LDAPS connections to succeed.

To configure certificate trust, volume-mount the CA root certificate into the API container and run update-ca-certificates before the application starts:

siter-api:
volumes:
- ./certs/ca-root.crt:/usr/local/share/ca-certificates/ad-ca-root.crt:ro
entrypoint: ["/bin/sh", "-c", "update-ca-certificates && dotnet Siter.Api.dll"]
warning

Do not set ActiveDirectory__SkipCertificateValidation=true in production. This disables certificate validation for all LDAP connections from the API and exposes the connection to man-in-the-middle attacks.

Licensing​

Applying and renewing the license itself is covered in the Licensing guide.

VariableRequiredDefaultDescription
Licensing__PublicKeyYes—The public key used to validate your Siter license (provided by the Siter team along with the license itself).
multiTenantNofalseSet to true for multi-tenant deployments where each customer/organization has its own license. Set to false for single-tenant deployments with one system-wide license.
SITER_SYSTEM_LICENSENo—Single-tenant only: the system license text, applied to the Default customer at startup. Alternatives: Licensing__SystemLicenseString (config value) or Licensing__LicenseFilePath (path to a mounted license file). See Licensing.

Analysis Engine (Pufferfish)​

The analysis engine (Pufferfish) provides automated facility evaluation capabilities. When running in the same Docker Compose stack, point these at the internal service name (e.g., http://pufferfish:26667).

VariableRequiredDefaultDescription
AnalysisOptions__AnalysisUrlYes—URL of the Pufferfish analysis engine (e.g., http://pufferfish:26667).
AnalysisOptions__DictionaryUrlYes—URL of the criteria dictionary service. Typically the same as the analysis URL.
AnalysisOptions__UseWktNofalseSet to true to transmit geometry data as WKT instead of WKB format.

File Storage (Hosted Imagery)​

Self-hosted imagery (hosted GeoTIFF basemaps) is stored on the API container's disk. The API needs a persistent volume at the storage path — without one, every uploaded image is lost when the container is recreated (existing hosted-imagery layers will show as missing on disk).

Mount a named volume and point FileStorage__BasePath at it, as shown in the base compose file:

siter-api:
environment:
- FileStorage__BasePath=/data/storage
volumes:
- siter-storage:/data/storage
VariableRequiredDefaultDescription
FileStorage__BasePathNostorageDirectory where the API stores uploaded files, including hosted imagery. The default is relative to the application directory inside the container — set an absolute path and back it with a persistent volume in production.
Imagery__MaxFileSizeBytesNo314572800 (300 MB)Maximum size of a single hosted-imagery upload.
Imagery__MaxCustomerStorageBytesNo10737418240 (10 GB)Cumulative hosted-imagery storage cap per customer. Leave unset to keep the default; set empty to disable the cap.
Imagery__GdalEnabledNotrueWhether the API looks for GDAL to auto-convert uploaded GeoTIFFs to Cloud-Optimized GeoTIFF (COG). When GDAL is not available, the server rejects non-COG uploads at upload time with a clear message.
Imagery__GdalPathNogdal_translatePath to the gdal_translate executable. The default resolves on the container's PATH.
Imagery__MaxConcurrentConversionsNo1How many COG conversions may run at once.
Imagery__ConversionTimeoutSecondsNo600Hard timeout for a single COG conversion.

Project Import​

Project snapshot imports (single .siter-snapshot files and each project of a .siter-archive) build the whole project in memory on the server, so the API limits how large one snapshot may be and how many imports run at once. The archive importer in the UI waits and retries automatically when the server is busy.

VariableRequiredDefaultDescription
Import__MaxSnapshotBytesNo134217728 (128 MB)Maximum size of a single project snapshot accepted for import. Larger snapshots are rejected and the UI reports that the file exceeds the server's import size limit.
Import__MaxConcurrentNo2How many snapshot imports may run at the same time, server-wide.
Import__MaxQueuedNo8How many further imports may wait for a free slot before the server answers "busy" immediately.
Import__QueueWaitSecondsNo30How long a queued import waits for a slot before the server answers "busy"; the archive importer retries after this.

Project Export​

Project archive exports (.siter-archive files, from Export projects… on the Project Management page) stream from the server while the download runs. Each running export holds a database connection for its whole duration, so the API caps how many may run at once and how long any one may take. A user can only ever run one export at a time; the settings below bound the server as a whole.

VariableRequiredDefaultDescription
Export__MaxConcurrentNo8Server-wide ceiling on archive exports streaming at the same time. When every slot is taken, further requests are refused immediately and the UI shows "The server is busy with other exports. Try again in a few minutes." Keep this well below the database connection pool size.
Export__MaxRequestSecondsNo14400 (4 hours)Wall-clock cap on a single export request, measured from the moment it starts. Values below 60 are raised to 60. An export that runs past the cap is aborted, so the browser sees a failed download rather than a truncated but complete-looking file. Only very slow clients ever reach the default.

If a reverse proxy sits in front of the API, its request-body limit must be at least Import__MaxSnapshotBytes (nginx: client_max_body_size), and its read timeout should allow several minutes (nginx: proxy_read_timeout 600s or more) for both the archive export and snapshot import requests — a large import sends nothing back while the project is being built. An export can stream for up to Export__MaxRequestSeconds; the API writes a heartbeat every 15 seconds while it streams, so a read timeout of a few minutes is enough, but any proxy or load balancer that enforces a total request duration must allow the full export time. The API also sends X-Accel-Buffering: no on export responses so nginx streams them unbuffered — other proxies may need response buffering disabled explicitly, or the progress display in the UI stalls until the buffer fills.

CORS​

VariableRequiredDefaultDescription
corsUrlsYeslocalhost origins only (from the image's appsettings.json, for local development)JSON array of allowed CORS origins. Must contain the exact origin users load the UI from — scheme, host, and port (e.g., ["https://siter.example.com"], or ["http://10.20.30.40"] on a LAN). Every layout in this guide serves the UI and API from different origins, so if this is missing or wrong the browser blocks every call from the UI to the API and users see an app that cannot sign in. The API logs Accepting CORS from: … at startup when the setting is read, or CORS not configured. when it is absent. The only deployment that does not need it is a same-origin one, such as the desktop install.

Seed Administrator Account​

On first startup, you can automatically create a system administrator account. See First Sign-In: Creating the Administrator Account for the procedure that uses it.

VariableRequiredDefaultDescription
SITER_SANo—Creates a seed SA account. Format: LOGIN::LOGIN_TYPE::DISPLAY_NAME. The LOGIN_TYPE is the numeric value from the Login Types table (e.g., user@example.com::16::Jane Doe for a Microsoft account).

UI and Hosting​

These are typically only needed in standalone or self-hosted deployments where the API serves the UI directly.

VariableRequiredDefaultDescription
useDefaultFilesNofalseSet to true to have the API serve static UI files from wwwroot.
wwwRootNo—Custom path to the static content folder (when useDefaultFiles is true).
baseHrefNo/Base href for the Angular application.
useResponseCompressionNotrueEnables Brotli/Gzip response compression.

Telemetry and Debugging​

VariableRequiredDefaultDescription
EnableTelemetryNotrueEnables internal process statistics collection.
echoRequestsNofalseLogs each incoming HTTP request's method and route template (for example GET api/health/status) to the console — never the raw path, which can carry share-invite codes. Useful for debugging.
EnableSwaggerNofalseSet to true to serve the Swagger API page at /swagger (for example on a staging server). Any other value leaves it off and logs a warning. Admin > Application Settings shows whether it is on.
SiterEnableDbContextSensitiveLoggingNofalseEnables Entity Framework sensitive data logging. Do not enable in production as it may log passwords and other sensitive values.

PostgreSQL Container​

These variables configure the PostgreSQL container itself (not the Siter application).

VariableRequiredDefaultDescription
POSTGRES_DBYes—Name of the database to create.
POSTGRES_USERYes—Database superuser name.
POSTGRES_PASSWORDYes—Database superuser password.

Pufferfish Container​

These variables configure the Pufferfish analysis engine service.

VariableRequiredDefaultDescription
PINO_LOG_LEVELNowarnLogging verbosity. One of debug, info, warn, error.
DB_IPNo—Hostname of a MongoDB instance. Enables bug report, test case, and criteria reference routes, as well as MongoDB log shipping.
DB_PORTNo27017Port of the MongoDB instance. Only used when DB_IP is set.
LOKI_IPNo—Hostname of a Grafana Loki instance for centralized log shipping.
LOKI_PORTNo—Port of the Loki instance. Required when LOKI_IP is set.
VERSIONNodevelopmentApplication version string (informational).

Configuration Precedence​

Siter loads configuration in the following order, where later sources override earlier ones:

  1. appsettings.json (base defaults, built into the image)
  2. appsettings.{ASPNETCORE_ENVIRONMENT}.json (environment-specific overrides)
  3. Environment variables (the primary way to configure deployments)
  4. Command-line arguments

For most deployments, environment variables in the Docker Compose file are the recommended configuration method.

Verifying Your Deployment​

After starting the services, verify the deployment:

  1. API health: Navigate to http://localhost:5000/api/health/status to confirm the API is running — it returns ok. The Swagger API page (/swagger) is off unless EnableSwagger is true (see Telemetry and Debugging).

  2. Database connectivity: Check the API container logs for the DB: log line confirming the connection string.

  3. CORS: In the same logs, confirm Accepting CORS from: … lists the exact origin users load the UI from. CORS not configured. means corsUrls was not read, and the UI will not be able to call the API.

  4. Analysis engine: Check the Siter API logs for the line listing supported criteria types — this confirms the API can reach Pufferfish. If the engine is unreachable, you'll see a warning — the application will still start but analysis features will be unavailable until the engine is accessible. You can also verify Pufferfish directly at http://localhost:26667.

  5. UI configuration: Navigate to the UI. The application should load — not the Runtime configuration is missing page, which means env.js is not mounted or host_url is invalid (see UI Runtime Configuration). Then confirm the runtime configuration file is served uncached:

    curl -I http://siter.example.com/env.js

    Use your UI origin, with https behind a reverse proxy. The response should be 200 with Cache-Control: no-store. A 404 means the file is not mounted at /usr/share/nginx/html/env.js. On a production deployment, also confirm the Test Environment! Do not enter sensitive information. banner is absent — if it shows, env.js does not set isTestEnvironment: false.

  6. Authentication: Confirm the expected login options appear, and complete First Sign-In to create the administrator account.

  7. Hosted imagery (if used): Check GET /api/system/info for the server's imagery capability status (whether GDAL conversion is available), and confirm the storage volume is mounted by uploading a test GeoTIFF and recreating the API container — the imagery should survive the restart.