ekhanemark/Docs
Docs: Self-hosting
Self-hosting

Self-hosting

Run EkhaneMark on your own server with Docker Compose.

EkhaneMark runs on one small server (2 vCPU and 4 GB RAM is plenty for an agency). It uses about 1 GB of memory: the dashboard, the API server, PostgreSQL and Caddy for HTTPS. Self-hosted, there are no plan limits.

Install

  1. Point a domain (for example ekhanemark.youragency.com) at the server with a DNS A record.
  2. Install Docker and clone the repository.
  3. In deploy/, copy .env.example to .env and fill it in (below).
  4. Run docker compose up -d --build. Caddy gets the HTTPS certificate; database migrations run before the app starts.
  5. Open the domain, create your account and workspace.

Update with git pull && docker compose up -d --build.

Server already running nginx

If other sites on the server already use ports 80 and 443, leave COMPOSE_PROFILES empty in .env so Caddy doesn't start. EkhaneMark then listens on 127.0.0.1 only: the dashboard on 3100 (APP_PORT) and the API server on 3102(API_PORT). Add a site to your existing proxy:

server {
    server_name ekhanemark.youragency.com;
    client_max_body_size 50m;   # screenshots, voice notes, images, files

    # The API server: what the widget and coding agents' uploads talk to.
    location ~ ^/api/(w/|uploads/|live$) {
        proxy_pass http://127.0.0.1:3102;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

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

Check it with nginx -t, reload, then get a certificate with certbot --nginx -d ekhanemark.youragency.com. Other sites keep running throughout. Without that block the dashboard passes those requests on to the API server itself, a little slower.

Settings

VariableWhat it does
DOMAIN, BETTER_AUTH_URLYour domain, and the same with https://.
BETTER_AUTH_SECRETA long random string. Signs sessions, links and file URLs. Changing it signs everyone out and breaks existing invite links.
ALLOWED_EMAIL_DOMAINSOnly these email domains can sign up (e.g. your agency's).
SMTP_URL, MAIL_FROMEmail: smtps://user:[email protected]:465. Without it, emails are written to the server log.
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETOptional “Continue with Google”. Redirect URI: your domain + /api/auth/callback/google.
STORAGE_DRIVERlocal (files in a Docker volume) or s3 (Cloudflare R2, Garage, any S3 API) with the S3_* variables.
BILLING_ENABLEDLeave false for internal use: no limits, no billing pages. true runs it as a service with plans.
SUPERADMIN_EMAILSWho can open the admin panel to turn on paid plans after a payment. The panel only exists in a container started with ADMIN_MODE=1; the compose file runs one as admin on port 3101, and the proxy exposes it on /admin to an allowlist of addresses.
CRON_SECRETProtects /api/cron/cleanup (feedback past the Free plan's history, old meeting audio) and /api/cron/transcribe (meeting clips still waiting).

Meetings

Meetings are off until MEETINGS_ENABLED=true; until then the dashboard shows them as coming soon. They need a LiveKit server, which the compose file runs when COMPOSE_PROFILES includes meetings. Set LIVEKIT_URL (wss:// your domain /livekit), LIVEKIT_API_KEY and a long random LIVEKIT_API_SECRET. Call audio and video go straight to the server on TCP 7881 and UDP 7882, so let those in. Caddy already proxies /livekit/; with nginx, add:

location /livekit/ {
    proxy_pass http://127.0.0.1:7880/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 1h;
}

Transcripts need TRANSCRIBE_API_KEY: an OpenAI key, or any OpenAI-compatible speech-to-text service through TRANSCRIBE_URL and TRANSCRIBE_MODEL. Without it, meetings work and their clips wait until a key is set. The cron service retries waiting clips every minute.

Backups

deploy/backup.sh dumps the database and archives uploaded files into deploy/backups, keeping 14 days. Run it from cron every night and copy that folder off the server.

Health check

/api/health returns 200 when the app can reach the database. Point your uptime monitor at it.