Docs › Self-hosting › Operations

Self-hosting operations (updates, backup, env, monitoring)

This manual is for founders who self-host The-Y CRM. It covers day-to-day operations: the Docker stack, updates, backup & restore, the key environment variables, reverse proxy/TLS, monitoring and the licence. For first install & server sizing, see Installation and server requirements.

Audience: self-hosting (Paid/Founder) · Docker Compose · basic Linux knowledge helpful

The stack at a glance

The-Y runs as a Docker Compose stack. The services:

ServicePurpose
appthe web app (Next.js) — binds internally on port 3000
workerbackground jobs (email fetch, reminders, sweeps, automation scheduler)
postgresdatabase (all tenant data)
redisjob queue (BullMQ)
miniofile/media storage (attachments, recordings) — S3-compatible
caddyreverse proxy with automatic HTTPS
watchtowerinstallations from before 1.1.53 only: optional auto-updater (nightly image pull) — from 1.1.53 update.sh in the installation folder updates (Updates)
set up by the installer if you want: asterisk, coturnphone system and TURN (WebRTC) in the host network, as the add-on docker-compose.pbx.yml — since 1.1.58 the installer asks (phone system). Earlier there was also piper (neural voice announcements, only up to 1.1.50)
optional (from 1.1.42): translate, translate-modelstranslation in the inbox on your server (LibreTranslate, internal network without internet) and the one-off service that downloads the language models — see below

Updates

New versions come as images from Docker Hub. A banner in the app reports new versions (update check against crm.the-y.at).

cd ~/the-y-crm
docker compose pull app worker
docker compose up -d app worker
Database migrations run automatically on app start (the container runs migrate before start) — no separate step needed. Update to 1.1.53: on first start the app builds a search index for messages; with very many messages (over 1 million) it skips this, and the command to add it later is in the log (docker compose logs app). Changing the account address isn’t available when self-hosting – you use your own domain.

Turn off automatic updates: bash update.sh --zeitplan-aus (then only by hand with bash update.sh).

Switching from Watchtower to update.sh (installations from before 1.1.53)

Nothing is changed automatically – Watchtower keeps running until you do this (about 5 minutes, in the installation folder, e.g. ~/the-y-crm; also described in SELF-HOSTING.md, German):

  1. Download update.sh (and, if you like, schluessel.sh) from the-y.at into the installation folder:
    cd ~/the-y-crm
    curl -fsSL https://the-y.at/update.sh -o update.sh
    curl -fsSL https://the-y.at/schluessel.sh -o schluessel.sh   # optional
  2. Dry run: bash update.sh --probe – at the end it must say “ERGEBNIS: bereit” (result: ready). If “Sicherung vorher” (backup first) says kein Sicherungs-Dienst (no backup service) although you set up daily backups (installations from before 1.1.52), add the line COMPOSE_FILE=docker-compose.yml:docker-compose.backup.yml to .env and run the dry run again – otherwise the script updates without a backup. The current backup scripts are available the same way (curl -fsSLO https://the-y.at/backup.sh, likewise restore.sh, restore-files.sh and restore-test.sh; the file docker-compose.backup.yml with curl -fsSL https://the-y.at/compose-backup.yml -o docker-compose.backup.yml); what else to do is in SELF-HOSTING.md, section “Wiederherstellen” (restore).
  3. Stop and remove Watchtower: docker compose stop watchtower && docker compose rm -f watchtower, then delete the whole watchtower: block in docker-compose.yml (up to the next service or volumes:).
  4. Delete the lines WATCHTOWER_HTTP_API_TOKEN=… and WATCHTOWER_PERIODIC=… from .env (no longer needed). For the “Update now” button, app needs the line ./update-signal:/they-update under volumes (installations from 1.1.52 have it); otherwise add it and run mkdir -p update-signal:
      app:
        # … existing entries stay …
        volumes:
          - ./update-signal:/they-update
    Then docker compose up -d app worker.
  5. Set up the schedule: bash update.sh --zeitplan (as root a systemd timer, otherwise a cron entry; plus a watcher for the “Update now” button).
  6. Check: systemctl list-timers the-y-crm-update.timer or crontab -l, and run bash update.sh --probe once.

The Watchtower labels on app and worker may stay – without Watchtower they do nothing. Installed with docker-compose.self-host.yml: the same steps; so that update.sh uses the right file, add to .env: COMPOSE_FILE=docker-compose.self-host.yml. That setup has no backup service – the script then updates without a backup; back up yourself first (Backup & restore). Back to Watchtower: bash update.sh --zeitplan-aus, put the watchtower block back into your compose file, restore the two .env lines, docker compose up -d.

Watchtower reports “client version … is too old” (installations from before 1.1.53 only)

New Docker versions reject the old interface Watchtower otherwise uses; automatic updates then stop arriving. With install.sh the right line is already there, and since 1.1.48 also in docker-compose.self-host.yml. If you installed another way, add the line to the watchtower service (if it already has environment:, only the second line) and restart it. Automatic updates don't replace the compose file.

  watchtower:
    # … existing entries stay …
    environment:
      DOCKER_API_VERSION: "1.44"
docker compose up -d watchtower

Translation (optional, from 1.1.42)

The “🌐 Translate” button in the inbox translates on your own server: LibreTranslate in the Docker network translate-internal without internet access and without any port to the outside. No text goes to an outside service. The service is an optional profile because it needs up to 1.5 GB RAM.

NeedsValue
RAMidle ~0.1 GB, German/English/Turkish fully loaded up to ~0.9 GB; hard limit 1.5 GB (TRANSLATE_MEM). Each further language +0.2–0.4 GB.
Disklanguage models once ~0.6 GB (German, English, Turkish), plus ~0.6 GB for the image
Internetonly once to download the models (one-off service translate-models); the running service has no internet access

Switch it on

Run this in the installation folder (e.g. ~/the-y-crm). If you installed with docker-compose.self-host.yml, add -f docker-compose.self-host.yml after docker compose each time.

# 1) enable the profile (languages optional, default de,en,tr)
echo 'COMPOSE_PROFILES=translate' >> .env
# echo 'TRANSLATE_LANGS=de,en,tr' >> .env
# 2) download the language models once (~0.6 GB, ~1 minute, needs internet)
docker compose --profile translate-models run --rm translate-models
# 3) start the service and reconnect app/worker
docker compose up -d translate app worker

Check: Settings → AI → Translation shows “Translation service is running · Languages: EN, DE, TR”, and “Test translation” tries it out. Without the service the inbox hides the “Translate” buttons since 1.1.59 (no waiting, no outside service), and the card in the settings shows the steps. There you can choose your own AI or DeepL key instead (DeepL Free keys are not used for WhatsApp, Messenger and Instagram).

Installation from before 1.1.42?

Automatic updates (Watchtower, or update.sh from 1.1.53) only replace the images, not the compose file. Your file therefore lacks the services translate + translate-models, the network translate-internal and TRANSLATE_URL for app/worker. The app keeps running normally (the “Translate” buttons are then hidden). To switch it on, back up the file, add the blocks below (they are the same in docker-compose.self-host.yml and in the compose file the installer writes from 1.1.42 on) and then run the three steps above.

cp docker-compose.yml docker-compose.yml.bak-pre-translate
# a) for app AND worker: add one line under environment:, plus networks: at the level of environment:
    environment:
      # … your existing lines …
      TRANSLATE_URL: ${TRANSLATE_URL:-http://translate:5000}
    networks: [default, translate-internal]

# b) two new services under services: (e.g. after minio)
  translate:
    image: libretranslate/libretranslate:v1.9.6@sha256:1de2d7056bb8ad607a412f4563d9abe324ff632b43b5be9428bcc8e213aebb32
    profiles: ["translate"]
    environment:
      LT_LOAD_ONLY: ${TRANSLATE_LANGS:-de,en,tr}
      LT_THREADS: "1"
      LT_DISABLE_WEB_UI: "true"
      LT_DISABLE_FILES_TRANSLATION: "true"
      LT_CHAR_LIMIT: "5000"
      ARGOS_INTRA_THREADS: "2"
    volumes: ["translate-models:/home/libretranslate/.local"]
    networks: [translate-internal]
    mem_limit: ${TRANSLATE_MEM:-1536m}
    memswap_limit: ${TRANSLATE_MEM:-1536m}
    healthcheck:
      test: ["CMD", "./venv/bin/python", "scripts/healthcheck.py"]
      interval: 30s
      timeout: 15s
      retries: 3
      start_period: 60s
    restart: unless-stopped
  translate-models:
    image: libretranslate/libretranslate:v1.9.6@sha256:1de2d7056bb8ad607a412f4563d9abe324ff632b43b5be9428bcc8e213aebb32
    profiles: ["translate-models"]
    entrypoint: ["./venv/bin/python", "scripts/install_models.py", "--update", "--load_only_lang_codes", "${TRANSLATE_LANGS:-de,en,tr}"]
    volumes: ["translate-models:/home/libretranslate/.local"]
    networks: [default]
    restart: "no"

# c) at the very bottom: add translate-models to volumes: and create the internal network
volumes: { postgres-data: {}, redis-data: {}, minio-data: {}, translate-models: {} }   # keep your existing entries (e.g. caddy-data)
networks: { default: {}, translate-internal: { internal: true } }

Then run docker compose config -q && echo OK — it must print “OK”. More languages: extend TRANSLATE_LANGS (e.g. de,en,tr,ar), repeat step 2 and raise TRANSLATE_MEM (+0.2–0.4 GB per language). Croatian, Bosnian and Serbian are not available in LibreTranslate — use your own AI key for them. MYMEMORY_EMAIL is no longer used.

Backup & restore

Regularly back up three things: the Postgres database, the MinIO volume (files/media) and your .env (contains, among others, the encryption key — without it, encrypted secrets are lost!).

Backup

# 1) database dump
cd ~/the-y-crm
docker compose exec -T postgres pg_dump -U crm crm | gzip > db-$(date +%F).sql.gz
# 2) MinIO volume (files/media) — check the name: docker volume ls | grep minio
docker run --rm -v theycrm_minio-data:/data -v "$PWD":/backup alpine \
  tar czf /backup/minio-$(date +%F).tgz -C /data .
# 3) back up .env (contains APP_ENCRYPTION_KEY!)
cp .env env-$(date +%F).bak

Store the three files in a separate location (different server/storage). Automate it with cron.

Restore

# restore the DB
gunzip -c db-YYYY-MM-DD.sql.gz | docker compose exec -T postgres psql -U crm crm
# restore the MinIO volume (stop the stack first)
docker run --rm -v theycrm_minio-data:/data -v "$PWD":/backup alpine \
  sh -c "rm -rf /data/* && tar xzf /backup/minio-YYYY-MM-DD.tgz -C /data"
The product also has a per-tenant snapshot export/import (Settings → Data backup) — handy for migrations and as an application-level backup. The snapshot contains no files (attachments, receipts, invoice PDFs, recordings) and no passwords, 2FA keys or webhook keys. Restored users are deactivated and set their password via “Forgot password?”. Plan, billing and security settings are not taken over. The DB+MinIO backup above is the full infrastructure backup.
⚠ The encryption key is critical. Mailbox/SIP/API/channel tokens are stored encrypted with APP_ENCRYPTION_KEY (AES-256). If you change or lose this key, all existing secrets become undecryptable. Set it once, fixed, and back it up (part of your .env backup).

Key with passphrase next to the backups (optional, from 1.1.53)

In addition to keeping it separately, you can store the encryption key encrypted with a passphrase (a long password) next to your backups. After a total loss, the backup from your second storage location plus your passphrase is then enough. Without the passphrase the file is worthless – for you and for anyone who gets hold of your backups. The passphrase is never stored on the server (not in files, not in the log); it is only asked for in the terminal. If you lose it, the file no longer helps – then the separately kept .env applies again. If you don’t set this up, everything stays as before.

New installations (from 1.1.53) have schluessel.sh in the installation folder; older ones fetch it once with curl -fsSL https://the-y.at/schluessel.sh -o schluessel.sh.

bash schluessel.sh --sichern     # save: enter the passphrase twice (at least 12 characters) → backups/schluessel.enc
bash schluessel.sh --pruefen     # check, now and then: still know the passphrase? does the file match the key in .env?

Environment variables (reference)

The key values in your .env (template: .env.self-host.example). Critical ones in bold.

VariablePurpose
APP_ENCRYPTION_KEYFixed 32-byte hex key (openssl rand -hex 32) for field encryption. Never change.
DATABASE_URLPostgres superuser connection (migration/scripts)
APP_DATABASE_URLapp connection as non-superuser crm_app (enforces row-level security = tenant isolation)
BASE_DOMAINyour domain (e.g. crm.yourcompany.com) — used e.g. for the softphone (WSS)
TENANT_MODEself-host: single (one tenant, no external signup)
LICENSE_KEYyour founder/licence key; validated online (14-day offline tolerance)
CONTROL_PLANE_URLlicence check endpoint (default: crm.the-y.at)
S3_ENDPOINT / S3_BUCKETMinIO binding (minio:9000 / crm-media)
MINIO_ROOT_USER / _PASSWORDMinIO credentials (generate)
REDIS_PASSWORDoptional (since 1.1.60): password for Redis, lowercase letters and digits only (e.g. openssl rand -hex 24). New installations with install.sh get it automatically. To add it to an existing one: put the line into .env; in the Compose file set REDIS_URL: redis://${REDIS_PASSWORD:+:${REDIS_PASSWORD:-}@}redis:6379 for app and worker and command: redis-server ${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD:-}} for redis, then docker compose up -d. Without a value Redis runs without a password as before.
PLATFORM_SMTP_* / PLATFORM_MAIL_FROMoptional system mailer (password reset/invitations) without your own mailbox
CRM_IMAGE/_WORKER_IMAGE/CRM_TAGDocker Hub images + tag (default :latest)
COMPOSE_PROFILEStranslate switches on the optional translation service (from 1.1.42, translation)
TRANSLATE_LANGSlanguages of your own translation service (default de,en,tr)
TRANSLATE_MEMmemory limit of the translation service (default 1536m)
TRANSLATE_URLonly needed if your LibreTranslate runs elsewhere (default http://translate:5000). MYMEMORY_EMAIL is no longer used since 1.1.42.
DEFAULT_LOCALEoptional (since 1.1.59): interface language before sign-in, de, en or tr. If unset, the browser language applies, otherwise German. After sign-in, the person’s own language applies.
telephony: AMI_SECRET, TURN_SECRET, PBX etc.only with the phone system enabled. If the installer sets it up (from 1.1.58), it generates AMI_SECRET and TURN_SECRET itself and sets PBX=1

Reverse proxy & TLS

The bundle ships Caddy with automatic HTTPS (Let's Encrypt) — you just point your domain at the server via DNS. If you already run nginx/Apache in front, proxy to the app (internal port 3000) and terminate TLS there instead. Make sure WebSocket connections are passed through (softphone/realtime). For the phone in the browser, also forward /asterisk-ws (lines for Nginx and Apache).

Monitoring & health

The endpoint /api/public/health returns {"ok":true} with HTTP 200 (healthy) or {"ok":false} with HTTP 503 (e.g. database unreachable or read-only) — since 1.1.42 only this one field. Wire it into your monitoring (uptime check) and evaluate the HTTP status, not a word in the body. The version is available at /api/public/version. Check container status with docker compose ps, logs with docker compose logs -f app worker.

Licence

The LICENSE_KEY is validated online against the control plane periodically; on outage a 14-day offline tolerance applies. If the licence is invalid/expired, the instance enters a gentle read-only state (no hard lockout, no data deletion). The feature set is identical to the hosted version.

Without a key the instance runs fully for 14 days from the first start and is read-only after that, until you enter a key. With a free key the Free limits apply; with a Paid key there are no limits.

Troubleshooting

After a re-setup, mailbox/WhatsApp/telephony are "broken"

Almost always the APP_ENCRYPTION_KEY changed (e.g. regenerated), which makes existing encrypted secrets unreadable. Restore the original key from your .env backup. If it's gone for good, the external secrets (tokens/passwords) must be re-entered.

Mail doesn't go out (timeout)

Many cloud providers (Hetzner, netcup, DigitalOcean, AWS/GCP/Azure) block outbound SMTP ports (25/465/587) by default as spam protection. Have the ports unblocked at the provider (support ticket) or use an HTTP-API mail send. Details: server requirements §5b.

Instance is in read-only mode

The licence check failed (invalid/expired key, or no successful online check for over 14 days). Check LICENSE_KEY and the reachability of the CONTROL_PLANE_URL.

A container won't start

docker compose logs app shows the cause. Common: wrong DB credentials, a missing APP_ENCRYPTION_KEY, or a busy port. After .env changes, docker compose up -d --force-recreate app worker.

What's next