Portable Windows Launcher¶
Identity Atlas can run as a standalone portable ZIP on Windows — no Docker, no WSL, no administrator rights, no installation. Unzip and run a PowerShell script to start the full stack.
This deployment mode is designed for environments where Docker and WSL are blocked by security policy.
Which ZIP to download¶
Every GitHub release ships two portable ZIPs. They contain the same app; the second one adds a PostgreSQL server.
| Download | Database | Pick it when |
|---|---|---|
IdentityAtlas-portable.zip |
PGlite, inside the app | The default. Small and medium tenants, and any machine whose application control trusts signed publishers: node.exe, the only executable in the zip, is signed by the OpenJS Foundation. |
IdentityAtlas-portable-postgres.zip |
A real PostgreSQL 16 server | Large data sets that PGlite cannot hold (it is capped at 4 GB; see Real PostgreSQL mode). Start it with -Database Postgres. The PostgreSQL binaries are not code-signed, so WDAC needs hash or path rules for them, and they need the Microsoft Visual C++ runtime (VCRUNTIME140.dll), which the zip does not contain. |
Start-IdentityAtlas.ps1 -Database Postgres on the default ZIP fails with "no postgres binaries found": that ZIP has no postgres\ folder.
How It Works¶
IdentityAtlas-portable.zip bundles:
node.exe— the official Node.js 24 binary, signed by the OpenJS Foundation (trusted by enterprise WDAC / application-control policies)bootstrap.mjs— ESM entry point that initializes PGlite and starts the APIapp-bundle.mjs— esbuild bundle of the Express APImigrations/— SQL migration files@electric-sql/pglite/— PGlite WebAssembly packagedesktop-worker.cjs— crawler job dispatcherdist-frontend/— the built React UIStart-IdentityAtlas.ps1— launcher script
Why a signed node.exe?¶
Locked-down corporate laptops enforce WDAC (Windows Defender Application Control) — a kernel-level policy that blocks unsigned PE executables. Custom-signed or developer-built exe files are rejected even if you bypass SmartScreen.
node.exe from nodejs.org is signed by the OpenJS Foundation, a trusted publisher in most enterprise WDAC publisher allow-lists. Running your app via a trusted node.exe avoids the signing barrier entirely.
Architecture¶
IdentityAtlas-portable.zip (extracted)
├── node.exe — signed Node.js binary (OpenJS Foundation cert)
├── bootstrap.mjs — PGlite init + API bootstrap
├── app-bundle.mjs — Express API bundle
├── migrations/ — SQL migrations
├── @electric-sql/pglite/ — PostgreSQL WebAssembly
├── desktop-worker.cjs — crawler dispatcher (polls API for jobs)
├── dist-frontend/ — React UI static files
└── Start-IdentityAtlas.ps1
bootstrap.mjs initializes PGlite (WebAssembly PostgreSQL) in-process, sets DESKTOP_MODE=true, then imports the API bundle. connection.js detects this flag and routes all database calls through the in-process PGlite instance instead of a TCP connection pool. No child process is spawned, no executable is written to disk at runtime.
Database files are stored in pgdata\ under the data directory (see Data Location) and persist across restarts.
IdentityAtlas-portable-postgres.zip (built with --with-postgres) also carries a real PostgreSQL server and runs that instead — see Real PostgreSQL mode.
Running the Portable Launcher¶
Prerequisites¶
| Requirement | Notes |
|---|---|
| Windows 10/11 x64 | Only x64 builds are produced today |
PowerShell 7 (pwsh.exe) |
Required to start the launcher and run crawlers. Install from aka.ms/powershell or winget install Microsoft.PowerShell |
No Docker. No WSL. No administrator rights.
Starting the App¶
- Download
IdentityAtlas-portable.zip(orIdentityAtlas-portable-postgres.zipfor a large data set; see Which ZIP to download) from the GitHub Releases page - Extract the zip to a folder of your choice (e.g.
C:\Users\YourName\IdentityAtlas\) - Open PowerShell 7 and run:
- The script starts the server and opens
http://localhost:3001in your browser once it's ready (~5–10 seconds on first run)
Unblock the files if you downloaded the zip
Windows marks everything extracted from a downloaded zip as coming from the
internet. PowerShell then refuses to run those scripts under the common
RemoteSigned policy, and the failure is easy to misread: the app itself
starts fine (its launch line passes -ExecutionPolicy Bypass), but a crawler
run fails with AuthorizationManager check failed and a job whose only
detail is pwsh.exe exited with code 1. Clear the mark once, after
extracting:
Extracting with Expand-Archive propagates the mark to every file; unblocking
the zip before extracting works too.
On first run, load the bundled demo dataset to explore with synthetic data:
.\bundled-scripts\test\demo-dataset\Ingest-DemoDataset.ps1 `
-ApiKey (Get-Content "$env:APPDATA\IdentityAtlas\.builtin-worker-key")
Or go to Admin → Crawlers to connect your own data sources.
Data Location¶
All persistent data lives in one data directory, chosen in this order: IA_DATA_DIR when set; %APPDATA%\IdentityAtlas\ when an older install already keeps its pgdata\ there; otherwise %LOCALAPPDATA%\IdentityAtlas\. The launcher prints the one it uses.
| Path | Contents |
|---|---|
pgdata\ |
PGlite database files |
postgres\ |
Real PostgreSQL mode only: data\ (the cluster), password (owner-only), startup.log |
uploads\ |
CSV uploads and crawler configs |
jobs\ |
Crawler job trace logs |
.master-key |
Encryption key for stored secrets |
.builtin-worker-key |
Internal API key for the crawler worker |
startup-error.log |
Written when the app stops on an unexpected error — send this file along with a bug report |
launcher.lock, launcher.state.json |
Keep two launchers off one data directory; let the next start recognise a leftover process |
llm\ |
-WithLLM only, unless an llm\ folder sits next to the launcher: llama.cpp and the report model (see Report model) |
llm-slots\, llm-server.log, llm-server.pid |
-WithLLM only: the saved prompt cache, the model server's output, and its process id while it runs |
To back up all data, stop the app and copy this folder. To reset to a clean state, delete it (the app re-initializes on next start).
Crawlers¶
Crawlers run via PowerShell (pwsh.exe). The Entra ID and CSV crawlers are bundled inside the zip and dispatched automatically when you schedule or trigger a run from the Admin → Crawlers page.
If pwsh.exe is not on PATH, the UI will still load and display existing data, but attempting to run a crawler will fail with a clear error message.
Limitations vs Docker¶
| Feature | Docker | Portable ZIP |
|---|---|---|
| PostgreSQL version | 16 (full) | PGlite (WASM, based on PG 16) |
| Concurrent users | Multi-user | Single machine, localhost only |
pg_class.reltuples stats |
Updated by ANALYZE | Always 0 — exact COUNT used instead |
REFRESH MATERIALIZED VIEW CONCURRENTLY |
Supported | Not supported (no background worker) — plain REFRESH used |
| Background worker container | Separate process | Integrated, same Node.js process |
| Auth (Entra ID JWT) | Configurable | Disabled by default |
| Azure deployment | Via Bicep | N/A |
The PGlite rows do not apply in real PostgreSQL mode, which behaves like Docker for all of them except multi-user access.
Real PostgreSQL mode¶
PGlite has a hard ceiling that no setting moves. It is PostgreSQL compiled to 32-bit WebAssembly inside node.exe, so it can never address more than 4 GB. Its buffer pool is fixed at 128 MB and work_mem at 4 MB, and ALTER SYSTEM is accepted but ignored after a restart. It is also single-threaded. A tenant of 200k users, 800k entitlements and tens of millions of assignments (15–25 GB of tables and indexes) cannot fit.
IdentityAtlas-portable-postgres.zip (a package built with --with-postgres) therefore carries a real PostgreSQL 16 server in postgres\, unpacked from the official Windows binaries zip. Start-IdentityAtlas.ps1 then runs two processes, both as the current user, with no installation and no administrator rights:
- PostgreSQL, started with
pg_ctlfrompostgres\bin, listening on127.0.0.1:5433. node.exe bootstrap.mjs, givenPOSTGRES_HOST/POSTGRES_PORT/… instead of starting PGlite.DESKTOP_MODEis left off, so migrations installpg_trgmfor real and matview refreshes useCONCURRENTLY. The API is still pinned to127.0.0.1.
The launcher waits until the API reports its schema migrated, opens the browser, and on Ctrl+C stops node.exe first and then PostgreSQL with pg_ctl stop -m fast (a clean shutdown: no recovery needed on the next start).
Check application control before relying on this mode
node.exe passes WDAC because the OpenJS Foundation signs it. The PostgreSQL server binaries carry no Authenticode signature at all. This was checked on 2026-09-26 with Get-AuthenticodeSignature against EDB's Windows binaries zip for 16.15, 17.8 and 18.4. postgres.exe, initdb.exe, pg_ctl.exe, psql.exe, pg_isready.exe, libpq.dll and all other server files report NotSigned. The only EnterpriseDB-signed file in the archive is stackbuilder.exe, which is not shipped.
A policy that trusts publishers will therefore block these files. They can only run on such a machine if IT adds hash rules for the shipped postgres\ files (for example with New-CIPolicy -Level Hash -ScanPath <folder>\postgres) or a path rule. The launcher detects a refusal before it creates anything. On a fresh data directory it falls back to PGlite with a warning that says why.
The server also needs VCRUNTIME140.dll (the Microsoft Visual C++ 2015–2022 x64 redistributable), which the zip does not contain. Most machines have it. When it is missing, the same preflight reports it by name and falls back.
Using it¶
| Parameter | Default | Meaning |
|---|---|---|
-Database |
Auto |
Auto uses PostgreSQL when postgres\ is present, except on a data directory that already holds PGlite data. Postgres or PGlite forces one. |
-PostgresRoot |
postgres\ next to the script |
A folder containing bin\pg_ctl.exe, to use binaries kept elsewhere |
-PostgresPort |
5433 |
Loopback port for PostgreSQL. It differs from 5432 so it cannot collide with an installed server |
-Port |
3001 |
Loopback port for the app |
-StartupTimeoutSec |
300 |
When to warn that startup is slow. The launcher keeps waiting and never kills a process that is still starting, because on a large database that kill is what makes the next start slower |
-NoBrowser |
off | Do not open the browser |
-WithLLM |
off | Also run the local report model; see Report model |
-LlmRoot |
see The LLM folder | Where llama.cpp and the model are found or downloaded to |
Setting DATABASE_URL or POSTGRES_HOST before starting the launcher points the app at an existing server instead, and the launcher starts none.
Switching an existing install from PGlite to PostgreSQL does not move its data. Run once with -Database Postgres and re-import (run the crawlers again). The PGlite data stays in pgdata\ untouched, and -Database PGlite returns to it.
What the launcher does¶
- First run. It writes a random password to
postgres\password, restricted to the current user, and runsinitdbwithscram-sha-256authentication, UTF-8 encoding and the C locale. It then appends a tuning block topostgresql.conf, starts the server and creates theidentityatlasdatabase. initdb writes intodata.initializing, and the folder is renamed todataonly once it is complete, so an interrupted first run is discarded and redone rather than half-used. -
Tuning. The block is sized from the machine's memory and written once. It stays visible and editable in
postgresql.conf:Setting Rule 16 GB laptop shared_buffers¼ of RAM, 128 MB – 8 GB 4 GB effective_cache_size½ of RAM, 256 MB – 32 GB 8 GB work_memRAM ÷ 256, 4 – 128 MB 64 MB maintenance_work_memRAM ÷ 16, 64 MB – 2 GB 1 GB max_wal_size8 GB from 8 GB of RAM, else 2 GB 8 GB It also sets
listen_addresses = '127.0.0.1'and turns on the logging collector, which writes a weekly ring of logs inpostgres\data\log\. - Every start.-Vpreflight of the binaries,pg_ctl start, then a wait forpg_isready. While the server is recovering the launcher keeps waiting; if the server exits instead, it fails at once with the tail ofpostgres\startup.log. - One launcher per data directory. A second launcher on the same data directory leaves the first alone. It opens the browser if the app is ready and otherwise says it is still starting. - Leftovers. PostgreSQL runs in its own hidden console, so closing the launcher window cannot kill it mid-write. A hidden watchdog (Watch-IdentityAtlas.ps1) waits for the launcher to end, however it ends (Ctrl+C, closed window, killed process), and then stops what is left: the recordednode.exe, then PostgreSQL with a fast shutdown. If even that is missed, the next start recognises and stops a leftovernode.exe(by process id and start time) and restarts a leftover server cleanly.
Measured¶
Measured on 2026-09-26 on an 8 GB Windows 11 machine, as a non-administrator, from a freshly extracted zip:
| Step | Time |
|---|---|
| First start (initdb, 74 migrations, bootstrap) to "running" | 14–15 s |
| Restart with data | 4–5 s |
| Start after an unclean stop (crash recovery) | 9 s |
| Ctrl+C to nothing left running | 2–3 s, clean shutdown checkpoint |
| Closed window / killed launcher to nothing left running | 0.3–2.4 s, clean shutdown checkpoint |
The zip grows from ~56 MB to ~80 MB with postgres\ included: 1,112 files, 63 MB unpacked. The subset omits pgAdmin, StackBuilder, headers, docs and translations.
Report model (-WithLLM)¶
Custom reports can turn a question in plain language into a report definition, using a small model that runs on the machine itself. Docker runs that model in a separate container. The portable launcher runs it when it is started with -WithLLM:
It is the same model as Docker's (Qwen3-4B-Instruct-2507, 4-bit), run by llama.cpp's llama-server.exe. Neither is in the ZIP. The model alone is 2.5 GB, which is more than a release asset or Compress-Archive can hold. The launcher therefore finds or fetches both:
| File | Size | Where it comes from |
|---|---|---|
llama-b11306-bin-win-cpu-x64.zip |
19 MB | llama.cpp's official Windows CPU release on GitHub. Only the 23 files llama-server.exe loads are unpacked, into llama.cpp\. |
Qwen3-4B-Instruct-2507-Q4_K_M.gguf |
2.5 GB | Hugging Face. This is the exact file the Docker image ships. |
Both are pinned by SHA-256 in Launcher.Llm.ps1. A guard test keeps the model, its checksum and the server flags equal to the Docker image's. A download goes into <name>.partial, so an interrupted one resumes on the next start. The file gets its real name only once the checksum matches. Downloads go through the system proxy with the user's Windows credentials.
The LLM folder¶
The launcher uses the first of these:
-LlmRoot <folder>llm\next toStart-IdentityAtlas.ps1, when that folder existsllm\in the data directory
Without internet access, download the two files above on another machine and copy them into that folder. The launcher then unpacks the zip and checks both files, and downloads nothing. A model file that passed its check is not hashed again until it changes; that check takes about 10 seconds. A llama-server.exe that is already unpacked there, either in llama.cpp\ or loose in the folder, is used as it is.
What happens at runtime¶
- The launcher runs
llama-server.exe --versionbefore anything else, just as it checks the PostgreSQL binaries. If application control blocks the file, or a DLL is missing, it says so and does not download the model. node.exeruns a small supervisor (llmSupervisor.mjs, the Node port of the Docker image'ssupervisor.py) on a random loopback port. It startsllama-server.exe(127.0.0.1 only, with a random API key per start) when the first question needs it, and stops it after 15 minutes without use. That returns its ~3.6 GB of memory.REPORT_GENERATOR_IDLE_SECONDSchanges the idle time;0keeps the model loaded.- Custom reports are switched on. An explicit
FEATURE_CUSTOM_REPORTS=false, or a switch-off in Admin → Experimental, still wins. - The prompt cache is prepared in the background at startup (a few minutes on a laptop CPU, once) and kept in
llm-slots\in the data directory (~900 MB), so later starts restore it in under a second. llama-server.exeis a separate process that Windows does not stop together withnode.exe. Its process id is kept inllm-server.pid. The launcher's Ctrl+C handling and the watchdog both stop a leftover server, and so does the next start.
Anything that fails, fails soft. That covers a blocked download, a checksum mismatch, too little disk space (the model needs 2.8 GB free), and a blocked binary. Each prints a warning naming the cause, and what to download and where to put it when a download failed. The app then starts without the model and with custom reports left as they were (off by default). Switched on in Admin → Experimental, report definitions can still be built by hand.
llama.cpp's binaries are not signed
As with the PostgreSQL binaries, llama-server.exe and its DLLs carry no Authenticode signature (checked on 2026-10-16 for build b11306). On a machine whose application-control policy trusts publishers, IT has to add hash rules for the 23 files in llama.cpp\, or a path rule for that folder. Unlike PostgreSQL, the build needs no Visual C++ runtime. The launcher's preflight reports a refusal by name.
Measured¶
Measured on 2026-10-16 on a Windows 11 laptop, as a non-administrator:
| Step | Time |
|---|---|
First -WithLLM start with the model copied in (download and unpack llama.cpp, hash the model) |
9 s |
| Later starts | no extra time |
| Prompt-cache preparation, in the background after the first start (once) | 237 s |
| Question with the prompt cache prepared (model already loaded) | 14–18 s |
llama-server.exe memory while loaded |
3.6 GB |
Closed window / killed launcher to node.exe and llama-server.exe gone |
under 12 s |
Building the Portable ZIP¶
Prerequisites¶
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 18 or later | nodejs.org — the bundled node.exe is always Node.js 24 |
| npm | Comes with Node.js | |
| PowerShell 7 | 7.x | Required to run the build script (downloads node.exe and creates the zip) |
| Git | Any | |
| Windows | Required | The build script downloads win-x64/node.exe and uses Compress-Archive |
Steps¶
# 1. Clone the repository
git clone https://github.com/Fortigi/IdentityAtlas.git
cd IdentityAtlas
# 2. Install desktop dependencies (includes PGlite)
cd app/desktop
npm install
# 3. Run the build (from app/api/)
cd ../api
npm install
npm run build:node-launcher
The build script (app/desktop/scripts/build-node-launcher.mjs) does eight steps:
- Installs API dependencies (
npm install) - Builds the React UI (
app/ui → app/ui/dist/) - Cleans the staging area (
dist-node-launcher/stage/) - Bundles the Express API with esbuild →
app-bundle.mjs - Copies SQL migrations alongside the bundle
- Copies
@electric-sql/pglitefromapp/desktop/node_modules/ - Copies launcher files (
bootstrap.mjs,Start-IdentityAtlas.ps1,desktop-worker.cjs, UI dist) - Downloads
node.exefrom nodejs.org and createsIdentityAtlas-portable.zip
Skipping the UI build¶
If you've already built the UI and are iterating on the backend only:
Embedding PostgreSQL¶
npm run build:node-launcher:postgres
# or add the flag to any variant:
node ../desktop/scripts/build-node-launcher.mjs --skip-ui-build --with-postgres
This downloads EDB's PostgreSQL Windows binaries zip. The version and SHA-256 are pinned in app/desktop/scripts/postgres-bundle.mjs, and every build checks the hash. The zip is cached in dist-node-launcher/ and only the subset the server needs is copied into postgres\. Binaries are never downloaded at run time, so the package works offline.
Release builds¶
cut-release.yml, cut-beta.yml and cut-hotfix.yml build both ZIPs through one shared script, .github/scripts/build_portable_zips.sh, which writes them to app/api/dist-node-launcher/release/:
npm run build:node-launcher, then moves the zip torelease/IdentityAtlas-portable.zip. The move matters: every build deletes and rewritesdist-node-launcher/IdentityAtlas-portable.zip, so the second build would otherwise destroy the first's output.build-node-launcher.mjs --skip-ui-build --with-postgres(reusing the UI the first build produced), then moves the zip torelease/IdentityAtlas-portable-postgres.zip.- Checks that the default zip has no
postgres/bin/postgres.exeand the PostgreSQL zip does.
Both are attached to the GitHub release, and the release notes end with a short guide to which one to download. app/desktop/portableReleaseAssets.guard.test.js fails if any of the three workflows stops attaching either ZIP.
Build Output¶
app/api/dist-node-launcher/
IdentityAtlas-portable.zip ← portable ZIP (~50 MB; ~80 MB with --with-postgres)
stage/ ← unpacked contents (can run directly for dev)
release/ ← only from build_portable_zips.sh: both release ZIPs
Security Notes¶
Why PGlite instead of embedded-postgres?
The previous implementation used the embedded-postgres npm package, which extracts a postgres.exe binary to %APPDATA% at runtime and spawns it as a child process. Endpoint security tools (CrowdStrike, Defender for Endpoint, and others) flag this pattern — an exe extracting and executing another exe from a user-writable location.
PGlite runs PostgreSQL compiled to WebAssembly inside the Node.js process. Nothing is extracted to disk at runtime. The WASM module is part of the zip, loaded like any other Node.js module.
Network exposure
The app binds to 127.0.0.1:3001 only, and in real PostgreSQL mode the database binds to 127.0.0.1:5433 only, so neither is reachable from other machines. Authentication is disabled by default, which means any process on this machine can call the API. Enable authentication (Admin → Authentication) on a shared machine.
In real PostgreSQL mode the database requires a password (scram-sha-256, no trust entries). The password is random per install and stored in postgres\password, readable only by the current user.
Secret storage
The master encryption key is stored in %APPDATA%\IdentityAtlas\.master-key (plain text, user-only access). Crawler credentials are stored encrypted using AES-256-GCM in the database.