- Elixir 89.8%
- Shell 6.8%
- HTML 1.7%
- Dockerfile 0.9%
- JavaScript 0.6%
- Other 0.2%
| assets | ||
| config | ||
| deploy | ||
| lib | ||
| priv/repo | ||
| .dockerignore | ||
| .formatter.exs | ||
| .gitignore | ||
| caddyfile.snippet | ||
| docker-compose.services.yml | ||
| Dockerfile | ||
| mix.exs | ||
| mix.lock | ||
| README.md | ||
OpenSupport
Self-hosted support portal for Open Source Security. Built with Elixir, Phoenix, Phoenix LiveView, and PostgreSQL 15. No JavaScript frameworks, no Redis, no Elasticsearch, no SaaS dependencies. Replaces Zammad (500K+ LOC) with ~4,600 lines of Elixir. Live at support.opensourcesecurity.net
What It Does
- Ticket System - Public submission with file attachments, customer status tracking, agent queue with real-time PubSub updates, conversation threads, internal notes, priority/status/assignment controls
- Knowledge Base - Public searchable articles at
/help, categories with icons, Markdown editor with live preview, full-text search via Postgres tsvector - Email Notifications - Swoosh + gen_smtp via Proton Business SMTP (STARTTLS, TLS-verified), background delivery via Oban workers
- Agent Dashboard - Real-time ticket counts, filterable ticket queue, KB management, admin panel, self-service password change
- Dark Mode - Full dark UI across all public and agent pages
Scope and Deliberate Exclusions
OpenSupport is built for a single small team operating a single-tenant deployment. The feature set was scoped explicitly against Zammad (500K+ LOC) and reduced to what a small team actually uses daily. The following were excluded on purpose, not as oversights:
- No SLA timers or escalation policies. Best-effort response, business hours.
- No LDAP, SSO, or SAML. Agent accounts managed directly via IEx.
- No reporting dashboards or analytics. Ticket counts on the dashboard, nothing further.
- No multi-channel intake (chat, social, SMS). Email and the public web form only.
- No canned responses or macros.
- No multi-tenancy. Single corpus, single team, single domain.
These exclusions are why the codebase is ~4,600 lines instead of ~500,000. Adding any of them is straightforward if scope changes; none of them are load-bearing for a team operating at this size.
Stack
| Component | Technology |
|---|---|
| Language | Elixir 1.17 / OTP 27 |
| Framework | Phoenix 1.7 + LiveView 1.0 |
| Database | PostgreSQL 15 (dedicated container) |
| Search | Postgres full-text search (tsvector) - no Elasticsearch |
| Swoosh + gen_smtp -> Proton Business SMTP | |
| Jobs | Oban (Postgres-backed - no Redis) |
| HTTP | Bandit |
| CSS | Tailwind (standalone CLI - no Node) |
| Reverse Proxy | Caddy (auto-TLS, security headers) |
| Deploy | Docker Compose, multi-stage Alpine build |
| Backups | Nightly Postgres dumps + rsync to remote server |
| Source Control | Self-hosted Forgejo |
Architecture
Internet -> Caddy (SSL) -> opensupport:4000 (Phoenix/Bandit)
|-- LiveView (WebSocket, real-time updates)
|-- Oban (background email jobs)
\-- opensupport-db (Postgres 15)
All containers on a shared Docker proxy network. No external services. No SaaS. Everything runs on a single server.
OTP and Process Model
OpenSupport runs on the standard Phoenix supervision tree without adding custom GenServers or named processes. This is a deliberate choice: at this scope, the work splits cleanly between request-handling LiveView processes (each its own supervised process by default), Oban workers for background email delivery (Oban manages its own supervision tree against Postgres), and Phoenix.PubSub for real-time agent dashboard updates (also managed by the framework). There is no application state that needs to outlive a request or a job, so introducing custom OTP processes would add supervision burden without adding capability. If the system grew to need rate limiting, scheduled non-Oban work, or in-memory caches, those would be the right places for purpose-built GenServers under a named supervisor.
Secrets and Configuration
Secrets are injected at container start via /opt/services/.env, which is mounted into the OpenSupport container by Docker Compose. The file is owned by root, mode 0600, and is not committed to the repo. Required values include SECRET_KEY_BASE, DATABASE_URL, SMTP_USERNAME, SMTP_PASSWORD, and PHX_HOST. Rotation is manual: edit the file, docker compose up -d opensupport to restart with the new values. At this scope a secrets manager (Vault, Doppler, AWS Secrets Manager) is not justified; if the deployment grew to multiple hosts or added contributors with shell access, that calculus would change.
Database and Migrations
Six migrations cover the full schema: Phoenix-generated auth tables, Oban's job table, and the domain tables for tickets, conversation threads, internal notes, attachments, knowledge base articles, categories, and full-text search indexes. All migrations are reversible with explicit up and down functions where the default change/0 doesn't suffice (notably the tsvector index creation, which requires explicit drop in down). Migrations are run at release boot via OpenSupport.Release.migrate(); rollback is OpenSupport.Release.rollback(version) against the same release binary.
Tickets use an opaque public reference (e.g. OSS-850480) for the customer-facing /tickets/:reference/status route rather than exposing sequential integer IDs. This prevents enumeration of other customers' tickets and is generated at insert time.
Deployment
See deploy/DEPLOY.sh for the full deployment guide.
Quick version:
# 1. Add env vars to /opt/services/.env (mode 0600, root-owned)
# 2. Update docker-compose.yml and Caddyfile
# 3. Build and start
docker compose build opensupport && docker compose up -d
# 4. Run migrations and seed
docker exec opensupport bin/open_support eval "OpenSupport.Release.migrate()"
docker exec opensupport bin/open_support eval "OpenSupport.Release.seed()"
Agent Management
Agents are managed via IEx (docker exec -it opensupport bin/open_support remote):
# Add an agent
OpenSupport.Accounts.create_agent(%{
name: "Agent Name",
email: "agent@example.com",
password: "min-12-characters",
role: "agent"
})
# List agents
OpenSupport.Repo.all(OpenSupport.Accounts.Agent)
|> Enum.map(&{&1.id, &1.name, &1.email, &1.role})
# Remove an agent
OpenSupport.Accounts.get_agent_by_email("agent@example.com")
|> OpenSupport.Repo.delete()
Agents can change their own password at /agent/password.
Routes
Public:
/help- Knowledge Base/help/search- KB search/help/:category/:article- Article view/tickets/new- Submit a ticket/tickets/:reference/status- Check ticket status
Agent (authenticated):
/agent/login- Login/agent/dashboard- Dashboard/agent/tickets- Ticket queue/agent/tickets/:id- Ticket detail/agent/kb- KB editor/agent/admin- Admin/agent/password- Change password
Codebase
| Metric | Value |
|---|---|
| Elixir/template lines | ~4,600 |
| Files | 78 |
| Database migrations | 6 |
| LiveView modules | 14 |
| Oban workers | 2 |
| External dependencies | 0 |
Testing and Build Journey
Posture. No automated test suite. This was a deliberate scope decision: built in a day for a single small team I own, single-tenant, no SLA, no external customers. The cost of a Phoenix/LiveView/Oban test suite (factories, ConnCase, LiveViewTest, Oban testing helpers, CI) was not justified at this scope. Things that would change that calculus: multi-tenant deployment, a team of agents larger than two or three, customer-facing uptime commitments, or accepting outside contributors. At that point the suite earns its keep. Today it would not.
What I used instead during build and early production:
- IEx remote shell against the running release for backend isolation (ticket CRUD, agent auth, email delivery confirmed independent of the LiveView layer)
- Raw Erlang
:sslconnection tests against Proton's SMTP server to separate transport-layer problems from application config - Docker log inspection after every rebuild
- Manual end-to-end browser walkthroughs across public KB, ticket submission, status check, agent dashboard, KB editor
- Live test ticket submissions against the production instance to verify the full pipeline including email notification
Things that broke during build, root cause, and fix. Documented here because the failure-and-fix log is more useful signal than a coverage percentage for a system this size.
-
OpenSSL ABI mismatch between build and runtime images. App crashed on boot with
crypto.so: EVP_MD_CTX_get_size_ex: symbol not found. First attempt (bump Alpine to 3.21, addopenssl-dev) didn't resolve it. Real fix: use the same Elixir base image for both build and runtime stages in the Dockerfile so the OpenSSL ABI matches end to end. -
Swoosh trying to initialize hackney. Swoosh defaults to an HTTP API client we don't use - we send via SMTP through gen_smtp. Fix:
config :swoosh, :api_client, false. -
embed_templatespath resolution under release builds. Phoenix couldn't find the app layout at release runtime:no "app" html template defined for OpenSupportWeb.Layouts. Fix: inline the layout functions directly in the Layouts module instead of relying onembed_templateswith external HEEx files. -
Tailwind producing 4.7KB of CSS instead of 30-50KB. Only the base reset was being generated, no utility classes. Root cause: the Dockerfile copied
assets/and ranmix assets.deploybefore copyinglib/, so the Tailwind content scanner found an empty directory. Fix: reorder the Dockerfile solib/is in place before assets are compiled. -
@import "tailwindcss/base"syntax. Doesn't work with Phoenix's standalone Tailwind CLI. Fix: switch to@tailwind base; @tailwind components; @tailwind utilities;. -
tailwind.config.jsusingrequire()for plugins. Standalone CLI has no Node and nonode_modules. Fix: remove the plugins array, write the typography styling by hand (see item 11). -
SMTP/TLS failure on port 465 (the hard one). Proton SMTP was configured, email delivery failed with
:tls_failed. Multi-session debugging. gen_smtp on port 465 opens the SSL socket itself before Swoosh passestls_options, which under OTP 27's stricter defaults (verify: :verify_peer, cacerts: :undefined) fails the handshake. I proved the TLS connection itself was fine by opening it directly via raw Erlang:sslin IEx, which isolated the problem to gen_smtp's option-passing on the direct-SSL path. Fix: switch to port 587 (STARTTLS), where gen_smtp does honortls_optionsduring the protocol upgrade, combined with adeliver_email/1wrapper that merges TLS options at call time withcacertfilepointed at Alpine's CA bundle. -
tls: alwayswithout the atom colon. Inruntime.exs, written astls: alwaysinstead oftls: :always. Erlang compile error, crash dump on boot. Caught from the boot log. -
phx-changeon bare<select>elements. Ticket detail dropdowns (status, priority, assignment) hadphx-changedirectly on<select>tags without a wrapping form. LiveView requiresphx-changeto fire from a form element, so the UI changed but the event never reached the server and nothing persisted. Confirmed via IEx that the backend update path worked correctly - isolated the bug to event binding, not data layer. Fix: wrap each<select>in its own<form phx-change="...">. -
Stray character in
layouts.ex. Akended up at the start ofdefmoduleafter a paste. Compile-time catch, no runtime impact, included here for completeness. -
Tailwind Typography plugin unavailable under standalone CLI. Article view used
prose prose-invert prose-zincclasses that don't exist without the plugin, so all KB article content rendered unstyled. Fix: hand-written.kb-proseclass inapp.csscovering headings, lists, code blocks, blockquotes, tables, links, and images.
What I'd add if this grew. Oban worker tests for email delivery (the highest-risk path given item 7), LiveView tests for the agent dashboard PubSub flow, ConnCase tests for the public ticket submission and status check routes, and a mix test step in the Docker build. None of this is hard to retrofit; the scope just doesn't demand it yet.
License
MIT
Open Source Security - opensourcesecurity.net