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.
The stack at a glance
The-Y runs as a Docker Compose stack. The services:
| Service | Purpose |
|---|---|
app | the web app (Next.js) — binds internally on port 3000 |
worker | background jobs (email fetch, reminders, sweeps, automation scheduler) |
postgres | database (all tenant data) |
redis | job queue (BullMQ) |
minio | file/media storage (attachments, recordings) — S3-compatible |
caddy | reverse proxy with automatic HTTPS |
watchtower | installations 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, coturn | phone 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-models | translation 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).
- Automatic (from 1.1.53): new installations update at night (03:30 server time, plus up to 45 minutes random delay) with
update.sh: fetch the new version → backup → restart app and worker → check (healthy + new version) → if anything fails, the previous version runs again automatically. Log:update.log. “Update now” in the app starts the same process. Existing installations with Watchtower switch once (about 5 minutes): Switching from Watchtower to update.sh. To stay on a version:CRM_TAGin.env. - Installations from before 1.1.53 only (Watchtower): the
watchtowerservice pulls new:latestimages forapp/workernightly and restarts them – without a backup first. We recommend switching. - Manual (any time): in the installation folder run
bash update.sh(with backup and check; a dry run that changes nothing:bash update.sh --probe). Withoutupdate.sh:
cd ~/the-y-crm docker compose pull app worker docker compose up -d app worker
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):
- 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 - 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 lineCOMPOSE_FILE=docker-compose.yml:docker-compose.backup.ymlto.envand 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, likewiserestore.sh,restore-files.shandrestore-test.sh; the filedocker-compose.backup.ymlwithcurl -fsSL https://the-y.at/compose-backup.yml -o docker-compose.backup.yml); what else to do is inSELF-HOSTING.md, section “Wiederherstellen” (restore). - Stop and remove Watchtower:
docker compose stop watchtower && docker compose rm -f watchtower, then delete the wholewatchtower:block indocker-compose.yml(up to the next service orvolumes:). - Delete the lines
WATCHTOWER_HTTP_API_TOKEN=…andWATCHTOWER_PERIODIC=…from.env(no longer needed). For the “Update now” button,appneeds the line./update-signal:/they-updateundervolumes(installations from 1.1.52 have it); otherwise add it and runmkdir -p update-signal:app: # … existing entries stay … volumes: - ./update-signal:/they-updateThendocker compose up -d app worker. - 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). - Check:
systemctl list-timers the-y-crm-update.timerorcrontab -l, and runbash update.sh --probeonce.
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.
| Needs | Value |
|---|---|
| RAM | idle ~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. |
| Disk | language models once ~0.6 GB (German, English, Turkish), plus ~0.6 GB for the image |
| Internet | only 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"
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?
- The daily backup (
backup.shfrom 1.1.53; older installations:curl -fsSLO https://the-y.at/backup.sh) copiesschluessel.encto your second storage location (BACKUP_REMOTE) – only the encrypted file, never the key in plain text. - Changed the key (new
APP_ENCRYPTION_KEY)? Runbash schluessel.sh --sichernagain. - Restoring (
restore.shfrom 1.1.53; older installations:curl -fsSLO https://the-y.at/restore.sh) checks before changing anything whether the key in.envmatches the file, and stops otherwise. - New server without the old
.env: runinstall.sh, copy the backups includingschluessel.encto~/the-y-crm/backups/and startbash schluessel.sh --zurueckholen(get back) – it asks for the passphrase and writes the old key into.env(a copy of the previous.envis kept). Then restore the backup and start app and worker withdocker compose up -d app worker(“up”, not “start” – that way they read the restored key).
Environment variables (reference)
The key values in your .env (template: .env.self-host.example). Critical ones in bold.
| Variable | Purpose |
|---|---|
APP_ENCRYPTION_KEY | Fixed 32-byte hex key (openssl rand -hex 32) for field encryption. Never change. |
DATABASE_URL | Postgres superuser connection (migration/scripts) |
APP_DATABASE_URL | app connection as non-superuser crm_app (enforces row-level security = tenant isolation) |
BASE_DOMAIN | your domain (e.g. crm.yourcompany.com) — used e.g. for the softphone (WSS) |
TENANT_MODE | self-host: single (one tenant, no external signup) |
LICENSE_KEY | your founder/licence key; validated online (14-day offline tolerance) |
CONTROL_PLANE_URL | licence check endpoint (default: crm.the-y.at) |
S3_ENDPOINT / S3_BUCKET | MinIO binding (minio:9000 / crm-media) |
MINIO_ROOT_USER / _PASSWORD | MinIO credentials (generate) |
REDIS_PASSWORD | optional (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_FROM | optional system mailer (password reset/invitations) without your own mailbox |
CRM_IMAGE/_WORKER_IMAGE/CRM_TAG | Docker Hub images + tag (default :latest) |
COMPOSE_PROFILES | translate switches on the optional translation service (from 1.1.42, translation) |
TRANSLATE_LANGS | languages of your own translation service (default de,en,tr) |
TRANSLATE_MEM | memory limit of the translation service (default 1536m) |
TRANSLATE_URL | only needed if your LibreTranslate runs elsewhere (default http://translate:5000). MYMEMORY_EMAIL is no longer used since 1.1.42. |
DEFAULT_LOCALE | optional (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.
