Self-hosted support portal in Elixir/Phoenix/LiveView with Postgres-everything stack. Replaces Zammad (500K+ LOC) in ~4,600 lines. Single-tenant, no SaaS dependencies. https://support.opensourcesecurity.net/tickets/new
  • Elixir 89.8%
  • Shell 6.8%
  • HTML 1.7%
  • Dockerfile 0.9%
  • JavaScript 0.6%
  • Other 0.2%
Find a file
2026-05-27 16:38:47 -06:00
assets Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
config Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
deploy Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
lib Complete dark mode sweep, fix file uploads, fix ticket status saves, attachment downloads, KB bug fixes 2026-03-10 14:38:10 -07:00
priv/repo Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
.dockerignore Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
.formatter.exs Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
.gitignore Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
caddyfile.snippet Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
docker-compose.services.yml Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
Dockerfile Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
mix.exs Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
mix.lock Initial commit: OpenSupport v1.0 - full support portal replacing Zammad 2026-03-09 21:52:08 -07:00
README.md Fill in knowledge gaps on testing methodolgy. 2026-05-27 16:38:47 -06:00

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
Email 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 :ssl connection 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.

  1. 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, add openssl-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.

  2. 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.

  3. embed_templates path 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 on embed_templates with external HEEx files.

  4. 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 ran mix assets.deploy before copying lib/, so the Tailwind content scanner found an empty directory. Fix: reorder the Dockerfile so lib/ is in place before assets are compiled.

  5. @import "tailwindcss/base" syntax. Doesn't work with Phoenix's standalone Tailwind CLI. Fix: switch to @tailwind base; @tailwind components; @tailwind utilities;.

  6. tailwind.config.js using require() for plugins. Standalone CLI has no Node and no node_modules. Fix: remove the plugins array, write the typography styling by hand (see item 11).

  7. 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 passes tls_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 :ssl in 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 honor tls_options during the protocol upgrade, combined with a deliver_email/1 wrapper that merges TLS options at call time with cacertfile pointed at Alpine's CA bundle.

  8. tls: always without the atom colon. In runtime.exs, written as tls: always instead of tls: :always. Erlang compile error, crash dump on boot. Caught from the boot log.

  9. phx-change on bare <select> elements. Ticket detail dropdowns (status, priority, assignment) had phx-change directly on <select> tags without a wrapping form. LiveView requires phx-change to 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="...">.

  10. Stray character in layouts.ex. A k ended up at the start of defmodule after a paste. Compile-time catch, no runtime impact, included here for completeness.

  11. Tailwind Typography plugin unavailable under standalone CLI. Article view used prose prose-invert prose-zinc classes that don't exist without the plugin, so all KB article content rendered unstyled. Fix: hand-written .kb-prose class in app.css covering 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