Apparition is a campaign-driven, containerized browser-in-the-middle platform that delivers ephemeral, isolated browsing sessions to recipients via a link. When a user visits /join/, Apparition launches a dedicated Docker container running Firefox in a lightweight Openbox desktop streamed over noVNC, providing a full-screen "real browser" experience without exposing the operator host or other sessions. A Node/Express control server orchestrates session creation and teardown, enforces campaign lifetime and completion rules, and records campaign/token/session state in SQLite.
Sessions can end automatically on timeout, manual teardown, or a defined completion condition—after which the container is destroyed and the user is redirected to a configured destination. Optionally, the user can continue browsing until a campaign-defined time limit is reached. When a completion condition is met, the session's browser profile can be downloaded from the admin console; otherwise, the profile remains available for download at any time from the sessions view for review and reporting.
This is a first-release build created with heavy vibe-coding. There may be unforeseen issues / flaws. Use only in an authorized context.
- You create a Campaign with a start URL, lifetime, and redirect URL.
- You generate Invite Tokens and optionally email them to recipients.
- A recipient clicks their
/join/<token>link. - Apparition spins up a Docker container running Firefox in a headless Openbox desktop via noVNC.
- The control server waits until the VNC display is ready and Firefox has opened its window before redirecting the user — no loading flash or black screen.
- The user sees a full-screen browser. When the session ends (timeout, completion trigger, or manual teardown), the container is destroyed and the user is redirected.
┌─────────────────────────┐
│ nginx │ (optional, recommended)
│ TLS termination + │
│ WebSocket proxy │
└────────────┬────────────┘
│ :443
┌────────────▼────────────┐
│ Apparition (Node) │ :3000
│ Express + SQLite │
│ Admin UI + API │
└────────────┬────────────┘
│ Docker socket
┌──────────────────┼──────────────────┐
│ │ │
┌──────────▼──────┐ ┌────────▼──────┐ ┌───────▼───────┐
│ novnc-session-1 │ │novnc-session-2│ │novnc-session-3│
│ Firefox + noVNC │ │Firefox + noVNC│ │Firefox + noVNC│
│ port 6900 │ │ port 6901 │ │ port 6902 │
└─────────────────┘ └───────────────┘ └───────────────┘
- Control server — Node.js/Express app. Manages campaigns, tokens, sessions, and the admin dashboard. Spawns and tears down sibling containers via the Docker socket.
- noVNC containers — Docker sibling containers, each running Openbox + Firefox + noVNC, bound to a unique host port (6900–6999 by default). Each container receives session context via environment variables.
- SQLite — Stores campaigns, invite tokens, sessions, email history, event logs, and submissions.
- nginx (recommended for production) — Terminates TLS, proxies all traffic including WebSocket upgrades to noVNC.
- Docker Engine 20.10+ and Docker Compose v2+
- A Linux host (Debian/Ubuntu or RHEL/Fedora recommended)
- Node.js 18+ (only needed for local development — Docker handles production)
- (Production) nginx + certbot for TLS
git clone <repo-url> apparition
cd apparitioncp .env.example .envOpen .env and set at minimum:
| Variable | What to set |
|---|---|
SESSION_SECRET |
A long random string — run openssl rand -hex 64 |
JWT_SECRET |
A different long random string — run openssl rand -hex 64 |
CONTROL_HOST |
The public URL of this server, e.g. https://example.com |
CONTAINER_HOST |
Internal URL for container callbacks — http://host.docker.internal:3000 |
NOVNC_HOST |
Hostname where noVNC ports are reachable — usually host.docker.internal |
The custom noVNC/Firefox container image must be built before any sessions can start:
docker compose build novnc-kioskThis builds ./novnc-container and tags it as novnc-kiosk locally. Only needs to be run once, or again after changes to novnc-container/.
docker compose up -dThe app runs on port 3000. Check logs with:
docker compose logs -f appdocker compose exec app node src/scripts/create-admin.jsFollow the prompts to set a username and password. This only needs to be run once.
Navigate to http://localhost:3000/admin/login (or your domain).
Copy the included nginx config:
sudo cp nginx/novnc-manager.conf /etc/nginx/sites-available/apparitionReplace YOUR_DOMAIN with your actual domain in the config file:
sudo sed -i 's/YOUR_DOMAIN/example.com/g' /etc/nginx/sites-available/apparitionsudo certbot --nginx -d example.comsudo ln -s /etc/nginx/sites-available/apparition /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginxCONTROL_HOST=https://example.com
CONTAINER_HOST=http://host.docker.internal:3000
COOKIE_SECURE=true
CONTAINER_HOSTvsCONTROL_HOST: Containers useCONTAINER_HOSTto POST callbacks (submit, complete, profile upload) directly to the Node server, bypassing nginx. This must be a URL reachable from inside Docker —http://host.docker.internal:3000works on Linux whenextra_hosts: host.docker.internal:host-gatewayis set (it is, indocker-compose.yml).
Restart the app after changing .env:
docker compose up -dSee .env.example for the full annotated template.
| Variable | Description |
|---|---|
SESSION_SECRET |
Secret for Express session signing |
JWT_SECRET |
Secret for container JWT signing (must differ from SESSION_SECRET) |
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Port the Node server listens on |
CONTROL_HOST |
— | Public base URL (used in invite links and emails) |
CONTAINER_HOST |
http://host.docker.internal:3000 |
Internal URL containers use for callbacks |
NOVNC_HOST |
host.docker.internal |
Host/IP where noVNC container ports are reachable |
COOKIE_SECURE |
false |
Set to true when serving over HTTPS |
JOIN_PATH |
join |
URL segment for invite links (e.g. /join/<token>) |
DB_PATH |
/data/app.db |
Path to the SQLite database file |
| Variable | Default | Description |
|---|---|---|
NOVNC_IMAGE |
novnc-kiosk |
Docker image used for session containers |
NOVNC_PORT_START |
6900 |
Start of host port pool for noVNC containers |
NOVNC_PORT_END |
6999 |
End of host port pool |
CONTAINER_NETWORK |
bridge |
Docker network containers are attached to |
CONTAINER_MEMORY_LIMIT |
1073741824 |
Per-container memory limit in bytes (default 1 GB) |
CONTAINER_CPU_LIMIT |
1000000000 |
Per-container CPU limit in nanocpus (default 1 CPU) |
CLEANUP_INTERVAL_MS |
60000 |
How often the cleanup job runs in milliseconds |
VNC_RESOLUTION |
1920x1080 |
Initial VNC framebuffer resolution. Set to match your typical viewer window size to avoid upscale blur. noVNC's resize=remote adjusts this dynamically once connected. |
| Variable | Default | Description |
|---|---|---|
SMTP_HOST |
— | SMTP server hostname |
SMTP_PORT |
587 |
SMTP port |
SMTP_SECURE |
false |
Use SMTPS (set to true for port 465) |
SMTP_USER |
— | SMTP username or API key |
SMTP_PASS |
— | SMTP password or secret key |
SMTP_FROM |
— | Sender address |
SMTP_FROM_NAME |
Apparition |
Display name for outgoing email |
SMTP settings can also be configured through the admin Settings UI. Environment variables take precedence over UI values.
| Variable | Description |
|---|---|
SSL_KEY |
Path to TLS private key — enables HTTPS on Node directly |
SSL_CERT |
Path to TLS certificate |
Only needed if you are not using nginx. When nginx handles TLS termination, leave these unset and set
COOKIE_SECURE=trueinstead.
A campaign is a named configuration that a set of invite tokens inherit. Each campaign defines:
| Field | Description |
|---|---|
| Name | Internal label for the campaign |
| Start URL | Firefox opens this URL when a session starts |
| Lifetime | Session duration in minutes — container is torn down after this |
| Redirect URL | Where users are sent after their session ends |
| Slug | Clean URL prefix for the viewer (e.g. /my-campaign/<shortId>) |
| Favicon URL | Tab icon shown in the viewer (auto-updated from user's browsing) |
| Completion URL | URL that, when visited by Firefox, triggers session completion |
| Completion Cookie | Cookie name that, when set, triggers session completion |
| After Completion | redirect (default) tears down the container; keep_alive keeps it running |
| Show Loading Page | Whether to show a "Starting..." page while the container boots. When disabled, the transition is seamless — the control server waits for the display to be ready before redirecting. |
Each invite token maps to one unique /join/<token> URL. Tokens can be:
- Generated individually from the campaign detail page
- Bulk-generated for email distribution
- Sent via the built-in email composer
When a token is used:
- A Docker container starts (
novnc-session-<uuid>) - Firefox opens the campaign's start URL
- The control server polls until Openbox is running, the root window is white, and Firefox has opened its window
- The viewer renders a full-screen iframe showing the Firefox session
The viewer polls the server every 3 seconds to sync the browser tab title and favicon with Firefox's current page.
When emails are sent through the built-in composer, Apparition logs every event as a discrete row in the email_events table — rather than overwriting boolean flags — so scanner and bot activity is visible alongside genuine human engagement.
| Event | When |
|---|---|
sent |
Email was successfully handed off to the SMTP server |
open |
The 1×1 tracking pixel embedded in the HTML email was loaded |
click |
The /join/<token> link was visited |
Each event records the timestamp, IP address, and user-agent. Multiple open events from the same send are expected and normal — email security scanners typically pre-fetch images before delivery, generating hits with Microsoft/Google datacenter IPs and generic scanner user-agents that precede any human open.
A 1×1 transparent GIF is automatically appended to every HTML email body at send time. The pixel URL is /t/<token> and requires no authentication. Set Cache-Control: no-store is sent to prevent CDN or proxy caching.
From the campaign detail page, click Export CSV to download a per-recipient summary with the following columns:
email, token, sent_at, send_error, open_count, first_open, last_open, open_ips, open_user_agents, click_count, first_click, last_click, click_ips, launched, completed
The open_ips and open_user_agents columns contain comma-separated lists of all recorded values, making it straightforward to distinguish scanner hits (repeated datacenter IPs before the email was delivered) from genuine opens.
The admin panel at /admin provides:
- Dashboard — live session count and recent activity
- Campaigns — create and manage campaigns; view tokens, session logs, and export tracking CSV
- Invite Links — manage individual tokens, view click and launch stats
- Email — compose and send campaign emails with embedded invite links and automatic open-tracking pixels
- Sessions — full session log with submission data and Firefox profile downloads
- Settings — configure SMTP and Cloudflare Turnstile
To require a CAPTCHA challenge on the join page, add to .env or configure via the Settings UI:
TURNSTILE_SITE_KEY=your-site-key
TURNSTILE_SECRET_KEY=your-secret-keyWhen configured, the join page renders a Turnstile widget before allowing a session to start.
Each session runs in an isolated Docker container built from novnc-container/. Key properties:
- Openbox window manager (replaces XFCE) — starts in ~1–2 seconds versus 5–7 for a full desktop environment, with no panels, file managers, or daemons
- Firefox runs in
--kioskmode against a pre-configured profile that disables session restore, welcome pages, telemetry, and default browser prompts - noVNC streams the display over WebSocket; the control bar, status popups, and loading overlay are hidden for a seamless presentation
- Dynamic resolution —
resize=remoteis set on all noVNC URLs, so the VNC framebuffer resizes to match the user's browser window automatically - Seamless startup — a readiness flag (
/tmp/kiosk-display-ready) is written only afterxsetroothas set the root window to white andxdotoolconfirms Firefox has opened its window. The loading page polls this flag viadocker execbefore redirecting, so the user always lands on an active session with no gray or black flash.
To rebuild the kiosk image after changes:
docker compose build novnc-kiosknpm install
cp .env.example .env # edit for local config
npm run dev # starts with nodemon hot-reloadThe server runs on http://localhost:3000. Docker must be running and accessible for container management.
- The app container mounts the Docker socket (
/var/run/docker.sock), which grants root-equivalent access to the host. In production, consider using Tecnativa/docker-socket-proxy to restrict which Docker API calls are permitted. - Session cookies are
httpOnly,sameSite: lax, andsecurewhenCOOKIE_SECURE=true. - All container-to-server callbacks are authenticated with short-lived, session-scoped JWTs.
- Rate limiting is applied to the join and session endpoints.
- The
security_opt: label:disableindocker-compose.ymlis required on Fedora/RHEL to allow Docker socket access under SELinux. It only affects label confinement for the app container. - The tracking pixel endpoint (
/t/:token) is intentionally unauthenticated — it must be reachable by email clients. It only records an event and returns a static GIF; it exposes no session data.


