Get Started
Run Customermates on your own server with Docker Compose: install, .env values, email, sign-up, updates, backups and restore, and what differs from Cloud.
Self-hosting runs Customermates from two files, docker-compose.yml and .env, with docker compose up -d. You fetch both from the Customermates repo with curl; no git clone and no build step are needed. The Compose file starts two containers: PostgreSQL 16 (postgres:16-alpine) and the app image ghcr.io/customermates/customermates:latest, which applies pending database migrations every time it starts. The migrations create the pg_trgm extension: it is trusted on PostgreSQL 13 and later, but some managed providers require you to allow-list it first.
Should I self-host or use the cloud?
| Decision factor | Managed cloud | Self-hosted |
|---|---|---|
| Application and database operations | Customermates operates them | You operate Docker, PostgreSQL, network access, TLS, updates and backups |
| Core CRM records and views | Included by plan | Starter baseline |
| Unified inbox and connected calendars | Available on entitled cloud plans | Not included |
| AI access | External MCP, plus the hosted assistant Mate with the hosted AI credits of your plan | External MCP with your own client and provider |
| Connected providers | Configured within the managed product | You configure and assess every external provider |
| Pricing | Per-user cloud subscription from €12 per month | Your infrastructure, providers and operator time |
Self-hosting lets you choose where the application and database run. It does not create a security, compliance, privacy or air-gap guarantee by itself; your organization remains responsible for configuration, providers, contracts, retention and operating controls. Compare total operating cost rather than assuming one model is cheaper: infrastructure, backups, restore tests, monitoring, updates, incident response and external providers.
Records move between cloud and self-hosted as Excel workbooks, one list at a time. A whole-platform migration is a separately designed and validated project.
Is self-hosting free, and do I need a license?
A self-hosted instance holds any number of workspaces, and one login can belong to several of them (Workspaces). No workspace has a subscription or a trial: each starts active, counts no seats and bills nothing. You pay only for your own infrastructure and providers.
Customermates is open core. Code outside ee/, and material in ee/ that is served to the browser, is licensed under AGPL-3.0-only. The server-side code in ee/ is under the Customermates Commercial License, which allows running the official image with APP_MODE=self-hosted, including in production, as a Community Build, as long as the cloud-only features stay off. The exact terms are in LICENSE and ee/LICENSE.md. Buying a cloud plan does not unlock anything in a self-hosted installation.
Which features are not available when self-hosting?
Every self-hosted installation uses the Starter feature set. These cloud-only features stay off, even if you also pay for a cloud plan: connected messaging accounts (email, LinkedIn, WhatsApp, Instagram, Telegram), the unified Inbox, connected calendars and their events, the hosted assistant Mate with its hosted AI credits, Routines, which Mate runs, and the AI workspace setup in onboarding, so a new workspace starts with no lists. External AI clients still work through MCP with your own AI provider. Enterprise SSO (SAML) and white-labeling are not implemented in either deployment model.
The app hides what does not apply: the sidebar shows no Inbox or Routines, Settings has no Billing or Channels page, and the role editor hides the Inbox messages and Routines rows. Opening one of those pages by URL returns you to the Dashboard.
Everything else works as in the cloud: lists and views, Excel import and export, Configure, dashboard widgets, members and roles, the Activity log, REST, webhooks and MCP.
How do I install and self-host Customermates with Docker Compose?
You need Docker with Docker Compose v2; the image is built for linux/amd64 and linux/arm64. A domain name is needed only for HTTPS.
Fetch the two files
mkdir customermates && cd customermates curl -fsSL https://raw.githubusercontent.com/customermates/customermates/main/docker-compose.yml -o docker-compose.yml curl -fsSL https://raw.githubusercontent.com/customermates/customermates/main/.env.selfhost.template -o .envFill in .env
Replace the placeholder values in
.envbefore the first start: at leastPOSTGRES_PASSWORD,BETTER_AUTH_SECRET(for example fromopenssl rand -hex 32),BASE_URL,RESEND_API_KEYandRESEND_OPERATOR_EMAIL. Without a workingRESEND_API_KEY, no email goes out, so nobody can confirm their email address and every new account is held after 24 hours.Start
docker compose up -d docker compose logs -f appThe first start takes about a minute while the migrations run. Then open
<BASE_URL>/auth/signup, by defaulthttp://localhost:4000/auth/signup.Register
Register, choose Create a new workspace and complete the wizard; your account becomes Admin. See How do I create the first account on my instance?.
Which environment variables go into .env?
POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DB: database user, password and name (template:postgres,change-this-password,customermates). Set them before the first start. PostgreSQL applies them once, when it creates the database volume, so a later change in.envdoes not change the existing database, and the app can then no longer connect.BETTER_AUTH_SECRET: a long random string that signs sign-in sessions. Changing it later signs everyone out, so keep a copy with your backups.BASE_URL: the exact address people open in the browser, see What must BASE_URL be set to?.APP_PORT: the host port the app is published on,4000by default.RESEND_API_KEY,RESEND_OPERATOR_EMAIL: email delivery;RESEND_API_KEYmust not be empty. See How do I set up email with Resend?.APP_MODE: leave it atself-hosted; the Compose file sets this value for the app itself.CRON_SECRET: optional, commented out in the template; the secret for the scheduled maintenance calls, see Which scheduled jobs must I run?.GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,AZURE_AD_CLIENT_ID,AZURE_AD_CLIENT_SECRET: optional, commented out in the template, see Can users sign in with Google or Microsoft?.ADDRESS_LOOKUP_URL: optional, commented out in the template; adds address search from a Photon service you run yourself, see How are addresses checked, and how do I add address search?.POSTAL_CODE_DATA_URL: optional, commented out in the template; the source of the postal codes for city and region suggestions: GeoNames when unset, the URL of a mirror, oroff, see How are addresses checked, and how do I add address search?.FILE_STORAGE_DRIVER,FILE_MAX_UPLOAD_BYTESand theS3_variables: optional, commented out in the template; where file fields keep their files and how large one may be, see Where are files stored when I self-host?.
Keep .env out of source control. Apply changes with docker compose up -d.
What must BASE_URL be set to?
Set BASE_URL to exactly the address people type into the browser: scheme, host name and, unless it is 80 or 443, the port, with no path. Examples: https://crm.example.com or http://192.168.1.20:4000. The template value http://localhost:4000 only works on the machine that runs Docker. The app refuses to start if BASE_URL contains a path, a query or credentials.
Customermates builds these addresses from BASE_URL:
- sign-in redirects, the links in invitation and other emails, and the MCP OAuth address;
- on a documentation page, the MCP address in the install snippets and behind Copy MCP Config, Copy MCP Command, Connect to Cursor and Connect to VS Code, and the page address that Open in ChatGPT and Open in Claude pass on;
- in the REST API reference, the Server URL, the code samples and the address that Send calls;
- the links in
llms.txtandllms-full.txt(raw markdown pages), the addresses insitemap.xmlandrobots.txt, and the canonical links of the public pages; - the
urlthatsearch_docs,get_docs_page,searchandfetchreturn, whose origin agents put in front of an app route to build a full link.
A request that arrives under another host name, IP address or port still gets addresses that point to BASE_URL: after signing in, users can land on the wrong address, and Send in the REST API reference fails, because the browser does not let a page opened under another address call the API at BASE_URL.
Three addresses the app shows you to copy use the address the browser opened the app with instead: the Invitation link on the Share link tab of Invite User and of the onboarding invite step, the MCP address under Quick connections and in the onboarding step Connect the AI you already use, and links from Copy link on a saved view. Copy them from a browser that uses the public address.
When you change the domain or APP_PORT, update BASE_URL as well and run docker compose up -d. With an https:// address, sign-in cookies are marked Secure, so the app must then be opened through HTTPS.
How do I put Customermates behind a reverse proxy with HTTPS?
Point a reverse proxy such as Caddy, nginx or Traefik at the app port (4000, or your APP_PORT) and set BASE_URL to the public https:// address. Caddy obtains the TLS certificate itself:
crm.example.com {
reverse_proxy localhost:4000
}The app takes the scheme and host for its links from BASE_URL, so forwarded headers such as X-Forwarded-Proto are not required. The proxy must pass on the original Host header, because the app rejects form submissions whose origin does not match it, including sign-in and sign-up. Caddy and Traefik do this by default; in nginx add proxy_set_header Host $host;. The Compose file publishes the app port on all network interfaces; if only the proxy should reach it, change the mapping in docker-compose.yml to "127.0.0.1:${APP_PORT:-4000}:4000" or block the port in your firewall.
How do I set up email with Resend, and can I use SMTP?
Customermates sends email only through Resend; there is no SMTP option. Verify your sending domain in Resend, create an API key, and set:
RESEND_API_KEY: the Resend API key.RESEND_OPERATOR_EMAIL: the sender address, on a domain verified in Resend. Every email goes out asCustomermates <RESEND_OPERATOR_EMAIL>. The same address receives a "New User Registration" email when someone saves the profile step of the wizard (see Who can sign up) and the messages sent through Send feedback in the personal menu.
The app emails verification links, password resets and invitations. When an email cannot go out, registering still works: new users can use their account for 24 hours and are then held on Email Verification, together with their API keys and MCP connections, until they verify. Forgot password? sends nothing, Send feedback fails, and the Share link tab of Invite User keeps working.
- Without
RESEND_API_KEY, every send fails withRESEND_API_KEY is not configuredin the app log, and email invitations fail with an error. - If Resend rejects an email, or the server cannot reach Resend at all, for example behind a firewall that drops outbound traffic or without internet access, the send gives up after at most 10 seconds and the app log shows
[EmailService] Email not sentwith the subject, the recipient's domain and Resend's reason. Send invitations then reports the invitations that could not be sent and keeps only their addresses in the form, and the REST and MCP invite calls return their number asfailedand the addresses asfailedEmails. Sending an invitation again, for example after a timeout, does not email an address twice within 24 hours. Without a connection to Resend, registering and Forgot password? take those 10 seconds.
If emails are not arriving, check the app log, check that the sender domain is verified, and look for rejected sends in the Resend dashboard.
How do I create the first account on my instance?
Open <BASE_URL>/auth/signup and register with email and password under Create account, or with Google or Microsoft if you configured them. On the next screen choose Create a new workspace. The wizard's first step asks for your first name, last name and display language and creates the workspace; you can skip the remaining steps and close the wizard with Finish. The account that creates the workspace gets the built-in Admin role, which has every permission. The new workspace has no lists yet: create the ones you need in Configure, or with Create list on the empty Dashboard; see Core concepts.
Click the verification link in the email within 24 hours. An account that is still unverified 24 hours after registering is held on Email Verification, where Resend verification email sends a fresh link.
Then open Members, invite teammates with Add, approve them there once they have registered, and set what they may do in Roles; see Members and roles.
Who can sign up on my instance?
Anyone who can reach your BASE_URL can register; registration cannot be switched off. A new user who chooses Create a new workspace gets a separate workspace with its own Admin, isolated from yours but stored in your database; only an Admin of a workspace can delete it, see How do I delete a workspace?. Every signed-in user can also add workspaces at any time with Create workspace in the workspace menu. A "New User Registration" email goes to RESEND_OPERATOR_EMAIL when someone saves the wizard's profile step, and for a Google or Microsoft sign-in also when someone signs in before saving it. Someone who registers with email and password but never saves that step sends none, and the notification only arrives if Resend accepts it, so it is not a complete record of sign-ups.
If the instance should serve one team only, limit who can reach it, for example with a VPN, an IP allowlist or authentication at the reverse proxy; MCP and REST clients then need the same access.
Teammates join through an invitation: Add on Members opens Invite User with a Share link tab and a Send emails tab. New members wait with status Waiting for approval until a member with Edit on Users & Roles opens them, gives them a Role and sets Status to Active.
Can users sign in with Google or Microsoft?
Yes, once you configure them. Register an OAuth client with Google, or an app registration in Microsoft Entra ID, with the redirect URI <BASE_URL>/api/auth/callback/google or <BASE_URL>/api/auth/callback/microsoft. The Microsoft provider uses the common endpoint, so register the app for accounts in any organizational directory.
In .env, remove the # in front of the lines for the providers you use, fill in GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, or AZURE_AD_CLIENT_ID and AZURE_AD_CLIENT_SECRET, then run docker compose up -d. The Compose file passes all four values to the app; if your docker-compose.yml does not list them yet, fetch it again with the curl command from the installation steps. A provider appears only when both of its values are set: the sign-in page then shows Continue with Google or Continue with Microsoft, and the sign-up page Register with Google or Register with Microsoft. Enterprise SSO over SAML is not implemented.
How do I connect an AI client to my self-hosted instance?
The MCP endpoint is <BASE_URL>/api/v1/mcp, for example https://crm.example.com/api/v1/mcp. There are two ways to authenticate:
- API key in the
x-api-keyheader, for clients that run on your machine: Claude Code, Claude Desktop, Codex, Cursor and Gemini CLI. Create the key with Add on API keys; Quick connections there shows the setup snippet with the address you opened the app under. The client only has to reach the URL, so a local network address works. See Connect CLI and editor clients. - OAuth sign-in, for clients that connect from their provider's cloud, such as a Claude custom connector. These need a
BASE_URLthat is reachable from the internet over HTTPS. See Connect Claude and ChatGPT.
The hosted in-app Assistant is cloud-only: Mate is not available on self-hosted instances. External MCP uses your own AI provider. Enterprise SSO and white-labeling are not implemented in either deployment model.
How are addresses checked, and how do I add address search?
Address checks need no setup. Every server checks addresses against the address format of their country, which ships with the app, and fills in city and region from the postal code, see How do I enter an address?. Nothing about a record leaves your server for this.
The postal codes are loaded from GeoNames. The first time someone enters an address in a country, a background job downloads that country's public file from https://download.geonames.org/export/zip, checks it and keeps the postal codes in your PostgreSQL database; after 90 days it downloads the file again. Only the public file is downloaded: no record data, search text or IP address of your users is sent. Until a country is loaded, or while its download fails, the format checks still run, and only the city and region suggestions and the city check are missing. POSTAL_CODE_DATA_URL sets the source of the files:
- Unset: GeoNames. The server needs outbound HTTPS access to
download.geonames.org. off: no downloads. The format checks still run, but nothing suggests a city or region, and the city is not checked.- The URL of a mirror, for example
https://mirror.example.com/geonames: a server that offers the GeoNames files under the same names, such asDE.zip,US.ziporCA_full.csv.zip.
As with ADDRESS_LOOKUP_URL below, Customermates never follows a redirect, and a host name that resolves to a private address is accepted only when the configured host is itself private.
A self-hosted server can also search addresses while people type, with a Photon service that you run yourself on OpenStreetMap data. Set ADDRESS_LOOKUP_URL to it, and address fields get Search address and Check address: an address picked or matched there is stored as verified, with the position of the building. Your server sends Photon only the search text or the address being checked, the person's language and the chosen country, so Photon never sees people's IP addresses or sessions. Each app container allows each person 30 lookups in 10 seconds and answers identical lookups from its own cache for 10 minutes. Without ADDRESS_LOOKUP_URL, the default, people type the address themselves; Customermates Cloud has no address search.
To run Photon, download the Photon jar from its releases, save it as photon.jar, and download a search index for the whole planet or one country from download1.graphhopper.com/public that matches the Photon version. Photon needs Java 21 or later and memory to match the index: for the planet index, about 95 GB on disk, Photon recommends at least 64 GB of RAM, while a country index needs far less. Unpack the index so that photon_data sits next to the jar in /srv/photon, then start Photon in the network of the Compose project, which is named after its folder, here customermates, with a Java heap (-Xmx) that fits your index:
docker run -d --name photon --restart unless-stopped \
--network customermates_default \
-v /srv/photon:/photon -w /photon \
eclipse-temurin:21-jre \
java -Xmx4g -jar photon.jar serve -listen-ip 0.0.0.0Then set ADDRESS_LOOKUP_URL=http://photon:2322 in .env and run docker compose up -d. Customermates calls only the configured host and never follows a redirect. A host name that resolves to a private address is accepted only when the configured host is itself private: an IP address in a private range, localhost, a name without a dot such as photon, or a name under .local, .internal, .lan or .home.arpa. When the service does not answer within 2 seconds, people see that address search is unavailable and can still type the address. Unset ADDRESS_LOOKUP_URL to turn the search off again; stored addresses keep their verification.
How do I update a self-hosted installation?
Take a backup first, then pull the new images and recreate the containers:
docker compose pull
docker compose up -d
docker compose psThe latest tag is rebuilt from the main branch whenever it changes, so every pull can bring new features and database migrations. Migrations run at every start and only move forward: starting an older image after a migration is not a safe rollback. To roll back, restore the backup taken before the update and start the previous image by its sha- tag. docker compose pull also refreshes postgres:16-alpine within PostgreSQL 16.
To decide when updates happen, pin the image in docker-compose.yml to a build tag instead of latest. Every image is also tagged sha- followed by the short commit id, for example ghcr.io/customermates/customermates:sha-<commit>.
Updating from a release older than 14 October 2026: the release that made lists, fields and calculations configurable converts the CRM data of every workspace when the new image first starts. Records, values, links, assignees, Knowledge Base pages, members, roles and API keys are kept. Every dashboard widget, the saved views and personal settings of the record lists, the terminology chosen for the lists and the audit log are removed, so history starts again at the update, and every webhook is turned off until you turn it on again with the record events it should send. Before this update, take the backup and run the read-only inventory from the migration notes, for example in docker compose exec postgres sh -c 'psql -U "$POSTGRES_USER" "$POSTGRES_DB"', to list the webhooks it turns off. If the conversion refuses data it cannot convert, it changes nothing, the app does not start and its log names the fields and the number of values it refused: restore the backup, start the previous image, correct those records and update again.
From a clone of the repository, scripts/selfhost-update.sh runs this update for you: it writes a backup to backups/, then pulls the images and recreates the containers. Next to it, scripts/selfhost-setup.sh creates .env from the template, checks the required values and starts the instance, scripts/selfhost-restart.sh recreates the app container so it picks up .env changes, and scripts/selfhost-reset.sh deletes all data like docker compose down -v once you type RESET.
How do I apply .env changes?
Run docker compose up -d. It recreates the containers whose configuration changed, so they start with the new .env values. docker compose restart keeps the old environment and does not apply .env changes.
Where are the logs, and how do I troubleshoot?
docker compose logs -f app
docker compose logs -f postgres
docker compose ps
docker compose exec app shThe app writes its logs to the container output. Background jobs such as webhook deliveries run inside the app container through a PostgreSQL-backed queue in the same database; there is no separate worker service. The published image turns off Next.js and Prisma telemetry and sets no error-reporting address. Errors the app writes to its output appear only in these logs; errors it only reports to an error-reporting service are not recorded anywhere. Every email that Resend rejected or did not answer in time is logged as [EmailService] Email not sent with Resend's reason.
Where are files stored when I self-host?
File fields keep their files in the PostgreSQL database by default, so the pg_dump backup includes them. A file can be at most FILE_MAX_UPLOAD_BYTES bytes, 26214400 (25 MB) when unset; beyond that, a self-hosted workspace has no storage limit other than your disk. For many or large files, keep them in an S3-compatible bucket such as AWS S3, MinIO, Cloudflare R2 or the object storage of Hetzner or OVH:
FILE_STORAGE_DRIVER=s3, plusS3_BUCKET,S3_ACCESS_KEY_IDandS3_SECRET_ACCESS_KEYof a private bucket.S3_ENDPOINTfor every provider other than AWS, for examplehttps://fsn1.your-objectstorage.com, andS3_REGIONwhen the provider needs one (us-east-1when unset).S3_FORCE_PATH_STYLE=truefor MinIO and other servers that address the bucket in the path instead of the host name.
Browsers send new files straight to the bucket, so its CORS rules must allow PUT with the Content-Type and If-None-Match headers from your BASE_URL. An upload link writes a file only once: the bucket refuses a second upload to it, so a file cannot be replaced after it was saved. AWS S3, MinIO and Cloudflare R2 support this; a store that ignores If-None-Match accepts a second upload until the link expires after 15 minutes. Downloads always pass through the app, which checks access first. A file stays where it was uploaded: after a switch to s3, earlier files remain in the database and still open, and the S3 variables must stay set as long as the bucket holds files. Back up the bucket together with the database. The daily /api/cron/lifecycle call deletes files that were removed from records 30 days ago and uploads that were never saved, see Which scheduled jobs must I run?.
How do I create and restore database backups?
All data lives in the PostgreSQL database: records, users, settings, the Activity log and the background job queue, and the files of file fields unless FILE_STORAGE_DRIVER is s3 (then back up the bucket as well). The app container keeps no data. Back up the database with pg_dump through the Postgres container, whose port the Compose file does not publish:
(
umask 077
mkdir -p backups && chmod 700 backups
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' \
| gzip > backups/customermates-$(date +%Y%m%d).sql.gz
)Run it daily from a scheduler such as cron and copy the files to separate or off-site storage. Keep a copy of .env with them, stored securely and out of source control: restoring with a different BETTER_AUTH_SECRET signs everyone out.
To restore, stop the app, replace the database with an empty one, load the dump and start again:
docker compose stop app
docker compose exec -T postgres sh -c 'dropdb -U "$POSTGRES_USER" "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
gunzip -c backups/customermates-20260923.sql.gz \
| docker compose exec -T postgres sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" "$POSTGRES_DB"'
docker compose up -dTest the restore regularly on a separate machine. Moving to a new PostgreSQL major version, which the Compose file pins at 16, also needs a dump and a restore into the new version.
Which scheduled jobs must I run?
The cloud calls two maintenance endpoints on a schedule; a self-hosted instance runs them only when you call them. Without the daily one, items in Trash are never deleted permanently after their 30 days.
/api/cron/lifecycle, once a day: deletes Trash items whose 30 days have passed, files removed from records 30 days ago and uploads that were never saved, and runs the other daily housekeeping. On a self-hosted instance it skips cloud jobs for trials, subscriptions and connected accounts./api/cron/record-deliveries, every 5 minutes (optional): starts due webhook deliveries, event processing, text extraction of attached files and other background record work that no running job picked up, for example after a container restart.
Both answer HTTP 401 unless the app has a CRON_SECRET and the request sends it as Authorization: Bearer <secret>. The template has a commented CRON_SECRET line next to BETTER_AUTH_SECRET, and the Compose file passes it to the app; if your docker-compose.yml does not list CRON_SECRET yet, download it again with the curl command from the installation steps. Uncomment the line, or add it to an .env created before it existed, set a long random value, for example from openssl rand -hex 32, run docker compose up -d, and schedule the calls on the host, for example with crontab -e:
0 3 * * * curl -fsS -H "Authorization: Bearer YOUR_CRON_SECRET" http://localhost:4000/api/cron/lifecycle
*/5 * * * * curl -fsS -H "Authorization: Bearer YOUR_CRON_SECRET" http://localhost:4000/api/cron/record-deliveriesUse your APP_PORT instead of 4000. A successful call answers {"ok":true,...}. If one of its jobs fails, the call answers HTTP 500 and the app log names the failed job; with curl -fsS, cron then reports the failure.
How do I reset all data?
docker compose down -v
docker compose up -d-v deletes the PostgreSQL volume with every workspace, user and record on the instance. This cannot be undone, so take a backup first if you might need the data. The next start creates an empty database, and the first person to register creates a new workspace.
Next
- Architecture and security: what you are running.
- API keys: key hygiene.
- Connect your AI: point an MCP client at your instance.