Running ByteMatch

Self-hosting

ByteMatch runs as two processes, a Next.js web server and a background worker, sharing one PostgreSQL database. There is no Redis; the job queue (pg-boss) lives in Postgres.

Requirements

  • Node.js 24
  • Yarn 4 (the repository pins 4.18.1; enable it with corepack enable)
  • PostgreSQL 15 or newer
  • A domain and a reverse proxy with TLS
  • A GitHub App (see Creating the GitHub App) or at least a GITHUB_TOKEN

Or Docker / Podman with Compose, which provides Node and Postgres for you.

Configuration

Settings are environment variables. Copy .env.example to .env; the file is read by next start, the worker and the migrations.

VariableDefaultDescription
DATABASE_URLpostgres://bytematch:bytematch@localhost:5432/bytematchPostgreSQL connection string.
PUBLIC_URLhttp://localhost:5000Public address of the instance, used in links, badges and PR comments.
SITE_NAMEByteMatchShown in the header and page titles.
GITHUB_TOKENOptional fine-grained PAT (public read is enough) for downloading artifacts from repositories without the App.
GITHUB_APP_IDGitHub App id.
GITHUB_APP_PRIVATE_KEYThe App's private key PEM. Literal \n sequences are allowed.
GITHUB_APP_SLUGApp slug, for the "Install the App" link.
GITHUB_WEBHOOK_SECRETWebhook secret set on the App.
GITHUB_CLIENT_IDThe App's OAuth client id, for sign-in.
GITHUB_CLIENT_SECRETThe App's OAuth client secret.
SECRET_KEY32 random bytes as 64 hex characters or base64 (openssl rand -hex 32). Encrypts signed-in users' GitHub tokens. Sign-in is disabled without it.
ADMIN_GITHUB_LOGINSComma-separated GitHub logins who can manage every project.
OPEN_REGISTRATIONtruefalse makes adding projects admin-only.
POLL_INTERVAL_MINUTES15How often the worker checks projects for new CI runs.
REPORT_RETENTION_DAYS00 keeps everything. Otherwise full reports older than this are dropped, except each version's latest. Measures and history are kept forever.
MAX_REPORT_BYTES268435456 (256 MiB)Largest report accepted.
DECOMP_DEV_URLhttps://decomp.devSource for "Import from decomp.dev".
WORKER_CONCURRENCY4Jobs each worker process runs at once.

Sign-in needs GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET and SECRET_KEY. Webhooks, PR comments and installation tokens need GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY and GITHUB_WEBHOOK_SECRET.

Deploying with pm2

git clone https://github.com/Veradictus/ByteMatch.git
cd ByteMatch
cp .env.example .env        # then edit it
yarn install --immutable
yarn build                  # Next build, plus dist/main.js (worker) and dist/migrate.js (migrator)
yarn db:migrate
yarn pm2:start
pm2 save
pm2 startup                 # prints a command that makes pm2 start at boot; run it

yarn pm2:start starts ecosystem.config.cjs, which defines two apps:

pm2 appRuns
bytematch-webnext start -H 0.0.0.0 -p 5000
bytematch-workerdist/main.js

Other scripts: yarn pm2:reload, yarn pm2:stop, yarn pm2:logs.

pm2 remembers each process's environment from its first start. yarn pm2:reload passes --update-env, so it picks up changes to .env; a bare pm2 reload or pm2 restart does not.

The web server listens on 0.0.0.0:5000. Firewall that port and expose only the reverse proxy.

Deploying with containers

cp .env.example .env        # then edit it
docker compose --profile app up -d --build
ServiceRole
dbPostgreSQL 17, on 127.0.0.1:5432, user, password and database bytematch
migrateRuns the migrations, then exits
webThe website, on port 5000
workerThe background worker

web and worker start after migrate succeeds. The compose file sets DATABASE_URL to the db service, overriding .env. Change the default database password before exposing anything beyond the host.

All three app services use one image, built from Containerfile. To run it without Compose:

docker build -t bytematch -f Containerfile .          # or: podman build -t bytematch .
docker run --env-file .env bytematch node dist/migrate.js
docker run -d -p 5000:5000 --env-file .env bytematch  # web
docker run -d --env-file .env bytematch node dist/main.js   # worker

Reverse proxy

Put a reverse proxy with TLS in front of port 5000, and set PUBLIC_URL to the public address (e.g. https://bytematch.dev): links, badges, sign-in and PR comments are built from it.

The server listens on 0.0.0.0:5000, so it is also reachable directly on port 5000. Block that port in the firewall (e.g. ufw deny 5000) so all traffic goes through the proxy, or bind the app to loopback by changing -H 0.0.0.0 to -H 127.0.0.1 in ecosystem.config.cjs.

nginx

server {
    listen 80;
    listen [::]:80;
    server_name bytematch.dev www.bytematch.dev;
    return 301 https://bytematch.dev$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name bytematch.dev www.bytematch.dev;

    ssl_certificate     /etc/letsencrypt/live/bytematch.dev/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/bytematch.dev/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    # One canonical host, so sign-in cookies and links agree.
    if ($host = www.bytematch.dev) { return 301 https://bytematch.dev$request_uri; }

    # Report uploads (POST /api/v1/projects/.../reports) can be large; match MAX_REPORT_BYTES.
    client_max_body_size 256m;

    location / {
        # 127.0.0.1, not localhost: localhost may resolve to ::1, where the server is not listening.
        proxy_pass http://127.0.0.1:5000;
        proxy_http_version 1.1;
        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;
        proxy_read_timeout 120s;
    }
}

Caddy

<host> {
	request_body {
		max_size 256MB
	}
	reverse_proxy 127.0.0.1:5000
}

Caddy obtains the certificate itself. With several web hosts, list them all: reverse_proxy 10.0.0.2:5000 10.0.0.3:5000.

Port 5000 on macOS

macOS's AirPlay Receiver listens on port 5000, so yarn dev and yarn start fail there with EADDRINUSE. Turn off AirPlay Receiver (System Settings → General → AirDrop & Handoff), or run the dev server on another port: yarn next dev -p 5050 (and set PUBLIC_URL to match).

Administering an instance

Accounts listed in ADMIN_GITHUB_LOGINS see Admin in the header (/admin; everyone else gets a 404 there). It has:

TabWhat it does
OverviewWhether the worker is alive (it writes a heartbeat every minute), queued and failed jobs, how much of GITHUB_TOKEN's hourly GitHub allowance is left, and which GitHub App settings are present.
ProjectsEvery project with its status (healthy, syncing, erroring, hidden, disabled), submitter and last error. Re-sync, disable, remove a linked image or Discord invite, or delete a project outright.
JobsEach queue's counts, and failed jobs with their (scrubbed) error: retry or discard them.
UsersEveryone who has signed in, and the blocklist. Blocking works by GitHub username even before someone signs in; it signs them out, stops them signing in or submitting, and can take their projects offline.
Audit logEvery admin action: who, what, when, and the reason given.
SettingsOpen or close project submissions (overriding OPEN_REGISTRATION), and pause all syncing during maintenance; queued work resumes when you switch it back on.

Deleting a project removes it and everything stored for it. Disabling hides it but keeps its history.

Upgrades

pm2:

git pull && yarn install --immutable && yarn build && yarn db:migrate && yarn pm2:reload

Containers:

git pull && docker compose --profile app up -d --build

The migrate service runs before web and worker start again.

Backups

Everything lives in PostgreSQL: projects, history, full reports, tokens and the job queue.

pg_dump -Fc "$DATABASE_URL" > bytematch-$(date +%F).dump

With the compose database:

docker compose exec -T db pg_dump -Fc -U bytematch bytematch > bytematch-$(date +%F).dump

Restore with pg_restore -d <database> --clean bytematch-YYYY-MM-DD.dump. Also keep a copy of .env: without the same SECRET_KEY, stored user tokens cannot be decrypted and users must sign in again.

To reduce database size, set REPORT_RETENTION_DAYS. Measures and history are never dropped.

Scaling

The web and worker processes are stateless.

  • Web: with pm2, raise instances for bytematch-web in ecosystem.config.cjs (it runs in cluster mode, so the processes share port 5000). On several machines, list each one in the proxy.
  • Worker: run more worker processes, or raise WORKER_CONCURRENCY. Jobs are claimed through Postgres, so each runs once, and duplicate webhook jobs for the same run collapse into one.

The database is the only shared component.

Next
Creating the GitHub App