selfhostedworld.com logoselfhostedworld.com

Try describing what you need:

Rejourney logo

Rejourney

★ 283

A self-hosted alternative to Hotjar

sspl-1-0Source-Available — Not OSS15 Containers — 15 containers — heavy hosting footprintDocker · Compose · K8s +1Needs Postgres2+ active committers (12mo)

About

Rejourney is a session replay and observability platform aimed at mobile and web applications. It provides SDKs and related backend/dashboard components to capture user sessions with high fidelity,...

Community Ratings

No ratings yet

Features

  • pixel-perfect capture
  • real-time incident stream
  • crash detection
  • ANR detection
  • journey mapping
  • interaction heat maps
  • global stability monitoring
  • growth analytics
  • team alerts
  • role-based access

Details

Last Updated
Oct 19, 2026
Created
Jan 23, 2026
Install Methods
dockerdocker-composekubernetessource
Requirements
Docker ComposeKubernetes (local-k8s / K3s)Node/npmlocal secrets for development
Authentication
local
API
REST
Backup & Export
database-dumpfile-backup
Privacy & Independence
Cloud: Optional
User Model
Multi-User
Deployment

Deployment: Docker Compose ✓15 Containers — heavy hosting footprintRuns as 15 containers

Run Rejourney with Docker Compose

Compose file from the Rejourney project repository.

15 services · database: postgres · infra: traefik, postgres, redis, minio · 1 unclassified (mc)

no resource limits declared

needs persistent storage (5 volumes)

docker-compose.selfhosted.yml · @ 0cfe415 · scanned Jun 6, 2026

name: rejourney

x-app-env: &app-env
  DOCKER_ENV: "true"
  NODE_ENV: production
  LOG_LEVEL: ${LOG_LEVEL:-info}
  DATABASE_URL: ${DATABASE_URL}
  REDIS_URL: ${REDIS_URL}
  S3_ENDPOINT: ${S3_ENDPOINT}
  S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-}
  S3_REGION: ${S3_REGION:-us-east-1}
  S3_BUCKET: ${S3_BUCKET}
  S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
  S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
  JWT_SECRET: ${JWT_SECRET}
  JWT_SIGNING_KEY: ${JWT_SIGNING_KEY}
  INGEST_HMAC_SECRET: ${INGEST_HMAC_SECRET}
  STORAGE_ENCRYPTION_KEY: ${STORAGE_ENCRYPTION_KEY}
  PUBLIC_DASHBOARD_URL: ${PUBLIC_DASHBOARD_URL}
  PUBLIC_API_URL: ${PUBLIC_API_URL}
  PUBLIC_INGEST_URL: ${PUBLIC_INGEST_URL}
  DASHBOARD_ORIGIN: ${DASHBOARD_ORIGIN}
  TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-}
  TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY:-}
  GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
  GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
  STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
  STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
  SMTP_HOST: ${SMTP_HOST:-}
  SMTP_PORT: ${SMTP_PORT:-}
  SMTP_USER: ${SMTP_USER:-}
  SMTP_PASS: ${SMTP_PASS:-}
  SMTP_FROM: ${SMTP_FROM:-}
  SMTP_SECURE: ${SMTP_SECURE:-true}
  GEOIP_PREFER_MMDB: ${GEOIP_PREFER_MMDB:-true}
  GEOIP_MMDB_AUTO_DOWNLOAD: ${GEOIP_MMDB_AUTO_DOWNLOAD:-true}
  GEOIP_MMDB_URL: ${GEOIP_MMDB_URL:-https://cdn.jsdelivr.net/npm/geolite2-city/GeoLite2-City.mmdb.gz}
  GEOIP_MMDB_PATH: ${GEOIP_MMDB_PATH:-/tmp/rejourney/GeoLite2-City.mmdb}
  INGEST_MAX_OBJECT_BYTES: ${INGEST_MAX_OBJECT_BYTES:-26214400}
  INGEST_DEVICE_BYTES_PER_MINUTE: ${INGEST_DEVICE_BYTES_PER_MINUTE:-52428800}
  INGEST_DEVICE_BYTES_PER_DAY: ${INGEST_DEVICE_BYTES_PER_DAY:-2147483648}
  INGEST_PROJECT_BYTES_PER_MINUTE: ${INGEST_PROJECT_BYTES_PER_MINUTE:-536870912}
  INGEST_PROJECT_BYTES_PER_DAY: ${INGEST_PROJECT_BYTES_PER_DAY:-107374182400}
  INGEST_IP_BYTES_PER_MINUTE: ${INGEST_IP_BYTES_PER_MINUTE:-104857600}
  INGEST_IP_BYTES_PER_DAY: ${INGEST_IP_BYTES_PER_DAY:-5368709120}

x-api-image: &api-image ghcr.io/rejourneyco/rejourney/api:${IMAGE_TAG:-latest}
x-web-image: &web-image ghcr.io/rejourneyco/rejourney/web:${IMAGE_TAG:-latest}
x-migration-image: &migration-image ghcr.io/rejourneyco/rejourney/migration:${IMAGE_TAG:-latest}

services:
  traefik:
    image: traefik:v3.2
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    command:
      - --api.dashboard=false
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=rejourney-network
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --entrypoints.web.forwardedHeaders.insecure=true
      - --entrypoints.websecure.forwardedHeaders.insecure=true
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL}
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik-certs:/letsencrypt
    labels:
      - traefik.enable=true
      - traefik.http.middlewares.rejourney-security.headers.stsSeconds=31536000
      - traefik.http.middlewares.rejourney-security.headers.stsIncludeSubdomains=true
      - traefik.http.middlewares.rejourney-security.headers.stsPreload=true
      - traefik.http.middlewares.rejourney-security.headers.forceSTSHeader=true
      - traefik.http.middlewares.rejourney-security.headers.frameDeny=false
      - traefik.http.middlewares.rejourney-security.headers.customFrameOptionsValue=SAMEORIGIN
      - traefik.http.middlewares.rejourney-security.headers.contentTypeNosniff=true
      - traefik.http.middlewares.rejourney-security.headers.browserXssFilter=true
      - traefik.http.middlewares.rejourney-security.headers.referrerPolicy=strict-origin-when-cross-origin
      - "traefik.http.middlewares.rejourney-security.headers.contentSecurityPolicy=default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://m.stripe.network https://static.cloudflareinsights.com https://www.googletagmanager.com https://www.googleadservices.com https://googleads.g.doubleclick.net https://pagead2.googlesyndication.com https://www.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https: blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://${API_DOMAIN} wss://${API_DOMAIN} https://${INGEST_DOMAIN} https://api.stripe.com https://m.stripe.network https://cloudflareinsights.com https://api.mapbox.com https://events.mapbox.com https://*.tiles.mapbox.com https://www.googletagmanager.com https://pagead2.googlesyndication.com https://www.googleadservices.com https://googleads.g.doubleclick.net https://ad.doubleclick.net https://www.google.com https://google.com; media-src 'self' https: blob:; worker-src 'self' blob:; frame-src 'self' https://js.stripe.com https://hooks.stripe.com https://www.googletagmanager.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self';"
      - traefik.http.middlewares.rejourney-security.headers.permissionsPolicy=geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()
      - traefik.http.middlewares.rejourney-security.headers.customResponseHeaders.X-Powered-By=
      - traefik.http.middlewares.rejourney-security.headers.customResponseHeaders.Server=
      - traefik.http.middlewares.rejourney-security.headers.customResponseHeaders.X-Content-Type-Options=nosniff
      - traefik.http.middlewares.rejourney-security.headers.customResponseHeaders.X-Frame-Options=SAMEORIGIN
      - traefik.http.middlewares.rejourney-security.headers.customResponseHeaders.X-XSS-Protection=1; mode=block
      - traefik.http.middlewares.rejourney-compress.compress=true
      - "traefik.http.middlewares.rejourney-www-redirect.redirectregex.regex=^https?://www\\.${BASE_DOMAIN}(.*)"
      - "traefik.http.middlewares.rejourney-www-redirect.redirectregex.replacement=https://${DASHBOARD_DOMAIN}$${1}"
      - traefik.http.middlewares.rejourney-www-redirect.redirectregex.permanent=true
    networks:
      - rejourney-network

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-rejourney}
      POSTGRES_USER: ${POSTGRES_USER:-rejourney}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rejourney} -d ${POSTGRES_DB:-rejourney}"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks:
      - rejourney-network

  redis:
    image: redis:8.4-alpine
    restart: unless-stopped
    command: >
      redis-server
      --save 900 1
      --loglevel warning
      --requirepass ${REDIS_PASSWORD}
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks:
      - rejourney-network

  minio:
    image: minio/minio:RELEASE.2024-09-22T00-33-43Z
    restart: unless-stopped
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
      MINIO_API_CORS_ALLOW_ORIGIN: "*"
    volumes:
      - miniodata:/data
    profiles:
      - minio
    networks:
      - rejourney-network

  minio-setup:
    image: minio/mc:latest
    restart: "no"
    # Image default ENTRYPOINT is `mc`; override so we run a shell script (see local-k8s/minio.yaml).
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        set -e
        until mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"; do
          echo "Waiting for MinIO..."
          sleep 2
        done
        mc mb -p "local/${S3_BUCKET}" || true
        mc anonymous set download "local/${S3_BUCKET}" || true
    depends_on:
      - minio
    profiles:
      - minio
    networks:
      - rejourney-network

  bootstrap:
    image: *migration-image
    build:
      context: .
      dockerfile: backend/Dockerfile.migration
    restart: "no"
    environment:
      <<: *app-env
      STORAGE_SYNC_LABEL: selfhosted-bootstrap
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    command:
      - /bin/sh
      - -lc
      - |
        node --import tsx scripts/applyDatabaseSchema.ts && \
        node --import tsx scripts/seedIfDatabaseEmpty.ts && \
        node --import tsx scripts/bootstrapSystemData.ts && \
        node --import tsx scripts/syncStorageEndpoint.ts
    networks:
      - rejourney-network

  api:
    image: *api-image
    restart: unless-stopped
    environment:
      <<: *app-env
      PORT: "3000"
      COOKIE_SECURE: "true"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      bootstrap:
        condition: service_completed_successfully
    labels:
      - traefik.enable=true
      - traefik.http.routers.rejourney-api.rule=Host(`${API_DOMAIN}`)
      - traefik.http.routers.rejourney-api.priority=1
      - traefik.http.routers.rejourney-api.entrypoints=websecure
      - traefik.http.routers.rejourney-api.tls.certresolver=letsencrypt
      - traefik.http.routers.rejourney-api.middlewares=rejourney-security,rejourney-compress
      - traefik.http.routers.rejourney-api-ingest.rule=Host(`${INGEST_DOMAIN}`)
      - traefik.http.routers.rejourney-api-ingest.priority=1
      - traefik.http.routers.rejourney-api-ingest.entrypoints=websecure
      - traefik.http.routers.rejourney-api-ingest.tls.certresolver=letsencrypt
      - traefik.http.routers.rejourney-api-ingest.middlewares=rejourney-security,rejourney-compress
      - traefik.http.services.rejourney-api.loadbalancer.server.port=3000
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
      interval: 30s
      timeout: 5s
      retries: 5
    networks:
      - rejourney-network

  ingest-upload:
    image: *api-image
    restart: unless-stopped
    command: ["node", "dist/uploadServer.js"]
    environment:
      <<: *app-env
      PORT: "3001"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      bootstrap:
        condition: service_completed_successfully
    labels:
      - traefik.enable=true
      - traefik.http.routers.rejourney-ingest.rule=Host(`${INGEST_DOMAIN}`) && PathPrefix(`/upload`)
      - traefik.http.routers.rejourney-ingest.priority=100
      - traefik.http.routers.rejourney-ingest.entrypoints=websecure
      - traefik.http.routers.rejourney-ingest.tls.certresolver=letsencrypt
      - traefik.http.routers.rejourney-ingest.middlewares=rejourney-security,rejourney-compress
      - traefik.http.services.rejourney-ingest.loadbalancer.server.port=3001
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3001/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
      interval: 30s
      timeout: 5s
      retries: 5
    networks:
      - rejourney-network

  web:
    image: *web-image
    restart: unless-stopped
    environment:
      NODE_ENV: production
      API_URL: http://api:3000
      VITE_TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-}
    depends_on:
      api:
        condition: service_healthy
      bootstrap:
        condition: service_completed_successfully
    labels:
      - traefik.enable=true
      - traefik.http.routers.rejourney-web.rule=Host(`${DASHBOARD_DOMAIN}`)
      - traefik.http.routers.rejourney-web.entrypoints=websecure
      - traefik.http.routers.rejourney-web.tls.certresolver=letsencrypt
      - traefik.http.routers.rejourney-web.middlewares=rejourney-security,rejourney-compress
      - traefik.http.routers.rejourney-web-www.rule=Host(`${WWW_DOMAIN}`)
      - traefik.http.routers.rejourney-web-www.entrypoints=websecure
      - traefik.http.routers.rejourney-web-www.tls.certresolver=letsencrypt
      - traefik.http.routers.rejourney-web-www.middlewares=rejourney-www-redirect
      - traefik.http.services.rejourney-web.loadbalancer.server.port=8080
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:8080', (r) => process.exit(r.statusCode < 500 ? 0 : 1)).on('error', () => process.exit(1))"]
      interval: 30s
      timeout: 5s
      retries: 5
    networks:
      - rejourney-network

  ingest-worker:
    image: *api-image
    restart: unless-stopped
    command: ["node", "dist/worker/ingestArtifactWorker.js"]
    healthcheck:
      disable: true
    environment:
      <<: *app-env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      bootstrap:
        condition: service_completed_successfully
    networks:
      - rejourney-network

  replay-worker:
    image: *api-image
    restart: unless-stopped
    command: ["node", "dist/worker/replayArtifactWorker.js"]
    healthcheck:
      disable: true
    environment:
      <<: *app-env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      bootstrap:
        condition: service_completed_successfully
    networks:
      - rejourney-network

  session-lifecycle-worker:
    image: *api-image
    restart: unless-stopped
    command: ["node", "dist/worker/sessionLifecycleWorker.js"]
    healthcheck:
      disable: true
    environment:
      <<: *app-env
    depends_on:
      postgres:
        condition: service_healthy
      bootstrap:
        condition: service_completed_successfully
    networks:
      - rejourney-network

  # Matches k8s/workers.yaml retention-worker CronJob (*/15 * * * *): single-shot runs with backlog drain.
  retention-worker:
    image: *api-image
    restart: unless-stopped
    healthcheck:
      disable: true
    command:
      - /bin/sh
      - -c
      - |
        set -eu
        while true; do
          node dist/worker/retentionWorker.js --once --drain-backlog --trigger=scheduled
          sleep 900
        done
    environment:
      <<: *app-env
    depends_on:
      postgres:
        condition: service_healthy
      bootstrap:
        condition: service_completed_successfully
    networks:
      - rejourney-network

  alert-worker:
    image: *api-image
    restart: unless-stopped
    command: ["node", "dist/worker/alertWorker.js"]
    healthcheck:
      disable: true
    environment:
      <<: *app-env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      bootstrap:
        condition: service_completed_successfully
    networks:
      - rejourney-network

  revenue-sync-worker:
    image: *api-image
    restart: unless-stopped
    command: ["node", "dist/worker/revenueSyncWorker.js"]
    healthcheck:
      disable: true
    environment:
      <<: *app-env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      bootstrap:
        condition: service_completed_successfully
    networks:
      - rejourney-network

networks:
  rejourney-network:
    name: rejourney-network
    driver: bridge

volumes:
  pgdata:
  redisdata:
  miniodata:
  traefik-certs:

This file may be out of date. Check the Rejourney documentation for current setup steps.

Track your self-hosted stack

Bookmark software to try, rate tools you've used, and keep your collection in one place.

Metadata extracted from README on Jun 4, 2026