HRPulsar
Docs
On this page

Self-Hosted Deployment Guide

Deploy HRPulsar on your own infrastructure using Docker Compose.

Prerequisites

  • Docker 24.0+
  • Docker Compose 2.20+
  • 2+ vCPU, 4+ GB RAM, 20+ GB SSD (minimum)
  • A domain name (optional, for HTTPS)

Installation

1. Clone the repository

git clone https://github.com/HRPulsar/hrpulsar.git
cd hrpulsar

2. Configure environment

cp .env.example .env

Edit .env and set at minimum:

JWT_SECRET=paste-the-output-of-openssl-rand-hex-32
POSTGRES_PASSWORD=a-strong-database-password
FRONTEND_URL=https://hr.yourcompany.com   # public URL of your instance

The backend refuses to start with an empty JWT_SECRET or the old change-me-to-a-random-string placeholder. It also seeds ENCRYPTION_KEY, the key protecting per-workspace AI provider keys (BYOK) at rest — if you may want to rotate JWT_SECRET later, pin ENCRYPTION_KEY now (openssl rand -base64 32); rotating without it leaves those stored keys unreadable.

FRONTEND_URL is what links in outgoing emails (verification, password reset, invitations) point at — without it they fall back to http://localhost:3100 and only work from the server itself. It is also the default origin that browser-facing file links (logos, avatars, attachments) are signed against, since the bundled MinIO is only reachable inside the Docker network; leave S3_PUBLIC_ENDPOINT unset unless storage is served from a different origin than the app.

Configuring an email provider (SMTP_* or RESEND_API_KEY in .env) is recommended but not required to get started:

  • No email provider configured — new accounts are verified automatically at registration, so you can sign up and log in right away. Outgoing emails (invitations, password resets, notifications) are skipped.
  • Email provider configured — registration sends a standard verification email. If delivery fails, the backend prints the verification link to its log (docker compose -f docker-compose.self-hosted.yml logs backend, look for EMAIL VERIFICATION LINK) so you can complete the signup manually.

Email links are built from FRONTEND_URL (falling back to the first CORS_ORIGINS entry) — point it at your instance's public URL.

3. Start all services

docker compose -f docker-compose.self-hosted.yml up -d --build

The first start builds the backend and frontend images (several minutes) and starts 8 services:

  • Caddy — Reverse proxy (ports 80/443)
  • Backend — FastAPI application (port 8000 internal)
  • Frontend — Next.js application (port 3000 internal)
  • Celery worker — Background jobs (emails, AI processing)
  • Celery beat — Periodic task scheduler
  • PostgreSQL — Database (no extensions required)
  • Redis — Cache and task queue
  • MinIO — S3-compatible file storage (plus a one-shot minio-init container that creates the storage bucket on first boot)

MinIO image. MinIO no longer publishes its official images, so the bundled storage runs pgsty/minio and pgsty/mc, the community fork maintained by the Pigsty project. It is a drop-in replacement: same data format, same mc commands. Both images are pinned to a release tag and a digest, so a pull always gets the exact image this release was tested with.

Existing installations switch to the fork on the next upgrade (see Upgrading), or right away with:

docker compose -f docker-compose.self-hosted.yml pull minio minio-init
docker compose -f docker-compose.self-hosted.yml up -d

up -d recreates the minio container on the same data volume. Data and mc commands stay compatible — nothing to migrate.

External S3 storage. To keep files in an external S3-compatible storage instead of the bundled MinIO, create the bucket there, then add a docker-compose.local.yml (see Upgrading) that parks the bundled storage and points the app at yours — the variables are described under Environment Variables:

services:
  minio:
    profiles: ["bundled-storage"]
  minio-init:
    profiles: ["bundled-storage"]
  backend:
    environment: &external-s3
      - S3_ENDPOINT=https://s3.example.com
      - S3_ACCESS_KEY=your-access-key
      - S3_SECRET_KEY=your-secret-key
      - S3_BUCKET=hrpulsar
      - S3_PUBLIC_ENDPOINT=https://s3.example.com
    depends_on:
      minio: { condition: service_healthy, required: false }
      minio-init: { condition: service_completed_successfully, required: false }
  celery-worker:
    environment: *external-s3

and pass both files on every command: docker compose -f docker-compose.self-hosted.yml -f docker-compose.local.yml up -d --build.

4. Access the application

The root URL takes you to the sign-in page. Create your first account at /register — the form creates your company workspace and its admin account in one step. Without an email provider configured the account is verified automatically and you are signed in immediately (see step 2 above).

HTTPS with a Domain

Out of the box the bundled Caddy serves plain HTTP on port 80. To serve the app over HTTPS with a certificate Caddy obtains and renews by itself:

  1. Point your domain's DNS record (A, and AAAA if the server has IPv6) at the server.

  2. Make sure ports 80 and 443 are reachable from the internet. The compose file already publishes both; port 80 stays needed for the certificate challenge and for redirecting HTTP to HTTPS.

  3. Edit deploy/selfhosted/Caddyfile: replace :80 on the site line with your domain, e.g. hr.yourcompany.com {.

  4. Set the public URL in .env to the https:// address: FRONTEND_URL=https://hr.yourcompany.com (and the same origin in CORS_ORIGINS if you set it). Email links and file links are built from it.

  5. Apply both changes:

    docker compose -f docker-compose.self-hosted.yml up -d
    docker compose -f docker-compose.self-hosted.yml restart caddy
    

Caddy obtains a Let's Encrypt certificate for the domain as soon as it loads the new config, renews it automatically, and redirects HTTP to HTTPS. Certificates live in the caddy_data volume — keep it across upgrades, or Caddy has to request them again. Set S3_PUBLIC_ENDPOINT=https://your-domain only if storage is proxied from somewhere other than FRONTEND_URL.

Behind an external proxy or load balancer

If TLS is terminated in front of the server (a corporate reverse proxy, a cloud load balancer, Cloudflare):

  • keep :80 in the Caddyfile — Caddy serves plain HTTP to your proxy, and only the proxy needs a certificate;
  • set FRONTEND_URL to the public https:// address users open;
  • forward the original Host header unchanged — file download links are signed for that host;
  • if the proxy reaches the server from a public address, list its ranges in TRUSTED_PROXIES (see Environment Variables), otherwise per-IP rate limits see the proxy instead of your users.

Running Without Docker Compose

The compose file passes DEPLOYMENT_MODE to both the backend and the frontend containers. If you run the services yourself (systemd, bare next start), set DEPLOYMENT_MODE=onprem in the environment of both processes — the frontend uses it to serve the self-serve registration form and the sign-in redirect at the root URL; without it the app falls back to the hosted-product entry surface.

Environment Variables

VariableRequiredDefaultDescription
JWT_SECRETYes—Secret signing every access/refresh token — generate with openssl rand -hex 32. The backend refuses to start while it is empty or left at the old change-me-to-a-random-string placeholder
ENCRYPTION_KEYNoDerived from JWT_SECRETAES-GCM key encrypting per-workspace AI provider keys (BYOK) at rest; urlsafe-base64 decoding to at least 32 bytes (openssl rand -base64 32). Left unset it is derived from JWT_SECRET, so rotating JWT_SECRET without pinning this value first makes every stored BYOK key unreadable. Required explicitly when DEPLOYMENT_MODE=saas
POSTGRES_PASSWORDYeshrpulsarPostgreSQL password
DEPLOYMENT_MODENoonpremonprem or saas. Keep onprem for a self-hosted install — it enables the self-serve registration form and the sign-in redirect at the root URL. Must be set on both the backend and the frontend process when you run them outside the bundled compose file
SIGNUP_ENABLEDNotruefalse closes self-serve workspace creation (POST /api/auth/register) once your workspace exists — on a company-internal install this stops any employee from opening /register and starting a workspace of their own. Invitations keep working
CORS_ORIGINSNohttp://localhost:3100,http://localhost:3300Comma-separated origins allowed to call the API. Set it to your public URL(s) on any deployed install — the default only covers local development
DATABASE_URLNoAuto-configuredPostgreSQL connection string
REDIS_URLNoAuto-configuredRedis connection string
ANTHROPIC_API_KEYNo—Claude API key (for AI features)
OPENAI_API_KEYNo—OpenAI API key (for AI features)
GEMINI_API_KEYNo—Gemini API key (for AI features)
YANDEX_API_KEYNo—Yandex Foundation Models API key (for AI features)
YANDEX_FOLDER_IDNo—Yandex Cloud folder id, required with YANDEX_API_KEY
LLM_PROVIDERNoclaudeLLM provider (claude, openai, gemini, yandex)
ASSEMBLYAI_API_KEYNo—AssemblyAI key for interview transcription (recruiting module)
DEEPGRAM_API_KEYNo—Deepgram key for interview transcription
YANDEX_SPEECHKIT_API_KEYNo—Yandex SpeechKit key for interview transcription; falls back to the YANDEX_API_KEY service account and uses YANDEX_FOLDER_ID
TRANSCRIPTION_PROVIDER_DEFAULTNoEmpty (measured order)Platform-keyed transcription provider tried first (whisper, deepgram, assemblyai, yandex_speechkit); the others follow in the order AssemblyAI, Deepgram, SpeechKit, Whisper, which is also the order when empty. A workspace's own keys always go first. An unknown value stops the application at startup
AI_PROCESSORS_DEFAULTNoEmpty (all allowed)Comma-separated AI processors every workspace may use on the platform's keys: anthropic, openai, gemini, yandex, jev, deepgram, assemblyai. Empty (default) allows all. An unknown name stops the application at startup
SMTP_HOSTNo—SMTP server for email notifications
SMTP_PORTNo587SMTP port
SMTP_USERNo—SMTP username
SMTP_PASSWORDNo—SMTP password
FRONTEND_URLNoFirst CORS_ORIGINS entryPublic URL of your instance, used to build links in emails
TRUSTED_PROXIESNoPrivate rangesComma-separated CIDRs/IPs of the reverse proxies in front of the app. X-Forwarded-For is honoured only from these peers, and the per-IP rate limits (login, password reset, sign-up) key on the client address it carries. The default covers the bundled Caddy; set it when the app is reached through a proxy on a public address (Cloudflare, an external load balancer) and list that proxy's ranges — otherwise every request keys on the proxy's own IP and one user hitting a limit throttles everyone
S3_ENDPOINTNo—S3/MinIO endpoint for file storage
S3_ACCESS_KEYNo—S3 access key
S3_SECRET_KEYNo—S3 secret key
S3_BUCKETNohrpulsarS3 bucket name
S3_PUBLIC_ENDPOINTNoFRONTEND_URLPublic base URL for file links when S3_ENDPOINT is internal-only (bundled MinIO). Set it only when storage is served from another origin than the app. File URLs are signed path-style (<endpoint>/<S3_BUCKET>/<key>), so give the bare storage host — if your provider serves buckets as subdomains, do not paste https://<bucket>.<host> here or every download fails with NoSuchKey
MAX_UPLOAD_MBNo10Hard size cap on a single generic upload (POST /files/upload, avatars). Domain-specific ceilings (resume, interview attachment) stay stricter
BRAND_NAMENoHRPulsarInstallation name in outgoing emails and the API title
BRAND_LOGO_URLNoStock logoAbsolute URL of the email-header logo
BRAND_ACCENT_COLORNo#0066FFAccent color for email buttons and links
EMAIL_FROMNoHRPulsar <notifications@hrpulsar.com>From header of outgoing emails
NEXT_PUBLIC_BRAND_NAMENoHRPulsarInstallation name in the web UI (frontend)
NEXT_PUBLIC_LOGO_URLNoStock logoWeb UI logo for light backgrounds (frontend)
NEXT_PUBLIC_LOGO_DARK_URLNoStock logoWeb UI logo for dark backgrounds (frontend)
NEXT_PUBLIC_BRAND_ACCENT_COLORNo#0066FFWeb UI accent color, any CSS color (frontend)
NEXT_PUBLIC_FAVICON_URLNo/icon.svgWeb UI favicon (frontend)
NEXT_PUBLIC_SIDEBAR_LOGO_HEIGHTNo28pxSidebar logo height, px/rem value (frontend)
NEXT_PUBLIC_BRAND_THEMENodefaultTheme preset: default, teal, slate, violet (frontend)
NEXT_PUBLIC_BRAND_AUTH_BG_COLORNoStock darkLogin/register background color (frontend)
NEXT_PUBLIC_BRAND_AUTH_BG_URLNo—Login/register background image URL (frontend)
AVAILABLE_LOCALESNoenComma-separated interface locales this install offers, e.g. de,en
DEFAULT_LOCALENoenFallback interface locale; must be listed in AVAILABLE_LOCALES
NEXT_PUBLIC_AVAILABLE_LOCALESNoenFrontend counterpart of AVAILABLE_LOCALES — keep both in sync
NEXT_PUBLIC_DEFAULT_LOCALENoenFrontend counterpart of DEFAULT_LOCALE — keep both in sync
SENTRY_DSNNo—Backend error-tracking DSN. Empty disables Sentry entirely
SENTRY_ENVIRONMENTNodevelopmentEnvironment name reported with every backend event
SENTRY_TRACES_SAMPLE_RATENo0.1Share of backend transactions sampled for performance tracing, 0–1
NEXT_PUBLIC_SENTRY_DSNNo—Web UI error-tracking DSN. Read at runtime, so changing it needs a container restart, not a rebuild (frontend)
NEXT_PUBLIC_SENTRY_ENVIRONMENTNo—Environment name reported with every web UI event (frontend)

Branding

The platform is white-label ready: logo, installation name, accent color and favicon can all be replaced through environment variables, without touching the source code. A default installation is identical to the stock HRPulsar build.

Frontend (web UI) variables — set on the frontend container:

NEXT_PUBLIC_BRAND_NAME="Acme Talent"
NEXT_PUBLIC_LOGO_URL=https://cdn.acme.example/logo-light-bg.svg
NEXT_PUBLIC_LOGO_DARK_URL=https://cdn.acme.example/logo-dark-bg.svg
NEXT_PUBLIC_BRAND_ACCENT_COLOR="#AA0044"
NEXT_PUBLIC_FAVICON_URL=https://cdn.acme.example/favicon.png
  • NEXT_PUBLIC_BRAND_NAME replaces the name in browser titles, page metadata and every place the UI mentions the platform by name.
  • Logos: NEXT_PUBLIC_LOGO_URL is used on light surfaces (sidebar in light theme); NEXT_PUBLIC_LOGO_DARK_URL on dark surfaces (auth pages, sidebar in dark theme). If only NEXT_PUBLIC_LOGO_URL is set, it is used everywhere. Horizontal logos around 5:1 aspect ratio work best; the sidebar renders them at 28px height by default. If your logo's proportions need a different size, set NEXT_PUBLIC_SIDEBAR_LOGO_HEIGHT to an exact px/rem value (e.g. 24px).
  • NEXT_PUBLIC_BRAND_ACCENT_COLOR accepts any CSS color and recolors links, accent-colored buttons and badges, focus rings, and charts. Hover/darker shades are derived automatically. The neutral dark (near-black) primary buttons and body text are intentionally not affected. The same accent is applied in both light and dark themes — pick a color with sufficient contrast in both.
  • NEXT_PUBLIC_BRAND_THEME switches the whole UI to a curated theme preset — surfaces, accent, charts, sidebar and corner radius change together, in both light and dark mode. Available presets: default (stock navy + blue), teal (deep navy + teal accent), slate (neutral graphite + steel blue), violet (warm neutrals + violet accent). An explicit NEXT_PUBLIC_BRAND_ACCENT_COLOR still applies on top of the preset's accent for point tweaks.
  • The login/register pages can carry their own background: NEXT_PUBLIC_BRAND_AUTH_BG_COLOR replaces the stock dark backdrop with a solid color; NEXT_PUBLIC_BRAND_AUTH_BG_URL (absolute https:// or root-relative URL) shows a full-bleed image instead — it is scaled to cover the viewport on every screen size, and a dark scrim keeps the sign-in card readable on any image. Setting either hides the stock starfield decoration.
  • The variables are read at runtime on every request, so a prebuilt image (GHCR) picks them up from the container environment — no rebuild needed.

Backend (email) variables — set on the backend container:

BRAND_NAME="Acme Talent"
BRAND_LOGO_URL=https://cdn.acme.example/email-logo.png
BRAND_ACCENT_COLOR="#AA0044"
EMAIL_FROM="Acme Talent <notifications@acme.example>"

Outgoing emails then carry the custom name, header logo (rendered at 31px height) and button/link color. EMAIL_FROM controls the From header.

On a branded install, always set EMAIL_FROM, FRONTEND_URL and BRAND_LOGO_URL together with BRAND_NAME. EMAIL_FROM defaults to the stock HRPulsar sender — leaving its address on the stock domain means recipients see the stock brand in the From header (and in the SMTP Message-ID, which is derived from the sender's domain). FRONTEND_URL is what links inside emails point at, and without BRAND_LOGO_URL the email header renders the stock logo. The backend logs a startup warning when BRAND_NAME is customized but any of the three is left at its default.

Static assets that live in the frontend image (site.webmanifest, apple-touch-icon.png, PNG icons) can be replaced by mounting your own files over /app/public/* in the frontend container if you need a fully branded install surface (PWA icons, home-screen name).

Interface Languages

An install can offer more than one interface language. Set the pair on both containers (backend reads AVAILABLE_LOCALES / DEFAULT_LOCALE, frontend reads the NEXT_PUBLIC_* counterparts at runtime):

AVAILABLE_LOCALES=de,en
DEFAULT_LOCALE=de
NEXT_PUBLIC_AVAILABLE_LOCALES=de,en
NEXT_PUBLIC_DEFAULT_LOCALE=de

The onboarding wizard then asks for the workspace default language, and every member can pick a personal language in profile settings. The backend refuses to start when DEFAULT_LOCALE is not listed in AVAILABLE_LOCALES. Single-locale installs (the default) hide the language selects entirely. The wizard also records the AI content language — the language the AI generates competences and development plans in — which is independent of the interface language.

Upgrading

Back up first — every time, and without exception before a major version. Migrations run forward only: there is no downgrade path, so once a release has rewritten the schema the only way back to the previous version is restoring the backup you took before the upgrade.

Run ./scripts/backup_db.sh ./backups (see Backup & Restore), check that it printed a dump size, then:

cd hrpulsar
git pull
docker compose -f docker-compose.self-hosted.yml build
docker compose -f docker-compose.self-hosted.yml up -d

Third-party images (Caddy, PostgreSQL, Redis, MinIO) are pinned to fixed versions in the compose file; when a release moves a pin, up -d pulls the new image. Migrations run automatically on backend startup. Building happens while the old version keeps serving; expect well under a minute of downtime during the final up -d switchover.

Local changes and upgrades:

  • Keep your customizations out of docker-compose.self-hosted.yml — put them in a separate override file and pass both files on every command, e.g. docker compose -f docker-compose.self-hosted.yml -f docker-compose.local.yml up -d. An edited compose file will conflict on git pull.
  • The one file you are expected to edit in place is deploy/selfhosted/Caddyfile (your domain). If an upgrade touches it, git pull will ask you to merge — re-apply your domain line.

Backup & Restore

Backup

./scripts/backup_db.sh ./backups

Or set up a daily cron job:

crontab -e
# Add: 0 3 * * * cd /path/to/hrpulsar && ./scripts/backup_db.sh ./backups

Each run writes two files: hrpulsar_<timestamp>.sql.gz (the database) and minio_<timestamp>.tar.gz (the file-storage volume — resumes, interview recordings, generated reports). The database dump alone restores an instance where every attachment 404s, so keep the pair together.

If your .env has S3_ENDPOINT / S3_ACCESS_KEY / S3_SECRET_KEY / S3_BUCKET set, both files are also uploaded there under the backups/ prefix (override with BACKUP_S3_PREFIX) — a backup that lives on the same disk as the database is not a backup. Point those at a provider other than the bundled MinIO; copying the file storage into itself is not an off-site copy. With SLACK_BOT_TOKEN and BACKUP_SLACK_CHANNEL set, a failed backup posts to that channel instead of failing silently.

Restore

Restore both halves from the same timestamp. Stop the app containers first so nothing writes while the data is being replaced:

docker compose -f docker-compose.self-hosted.yml stop backend celery-worker celery-beat frontend

Database — ON_ERROR_STOP=on is what makes a failed restore stop instead of running to the end and leaving a half-old, half-new schema behind:

gunzip -c backups/hrpulsar_YYYYMMDD_HHMMSS.sql.gz | \
  docker compose -f docker-compose.self-hosted.yml exec -T postgres \
    psql --set ON_ERROR_STOP=on -U hrpulsar hrpulsar

File storage — the archive holds the MinIO data directory, so it unpacks at the container root:

gunzip -c backups/minio_YYYYMMDD_HHMMSS.tar.gz | \
  docker cp - "$(docker compose -f docker-compose.self-hosted.yml ps -q minio)":/

Then start the stack again:

docker compose -f docker-compose.self-hosted.yml up -d

Troubleshooting

Check service status

docker compose -f docker-compose.self-hosted.yml ps

View logs

docker compose -f docker-compose.self-hosted.yml logs backend
docker compose -f docker-compose.self-hosted.yml logs frontend

Health check

The backend port is not published on the host — query it through the proxy:

curl http://localhost/health

"status": "ok" means database, Redis, file storage, and the Celery worker are all reachable.

Hardware Recommendations

UsersCPURAMDisk
Up to 1002 vCPU4 GB20 GB SSD
100-5004 vCPU8 GB50 GB SSD
500+8 vCPU16 GB100 GB SSD