Skip to Content
Self-hosting

Self-hosting

Self-hosting means running almyty on your own servers instead of using the hosted service at app.almyty.com. Teams choose it when data must stay inside their own network, for example a bank that may not send customer records to an outside service. It needs someone comfortable running servers: this page is for that person. If you use the hosted service, you can skip it.

almyty runs as containers (self-contained packages of a program and everything it needs). Use the Docker Compose file in this repository for a single-server install, or the Kustomize base in k8s/base as the starting point for a Kubernetes cluster. Read the ENCRYPTION_KEY and Outbound email sections before you put real data in it.

Docker Hub

Official images are published to Docker Hub:

docker pull almyty/api:latest docker pull almyty/frontend:latest
ImageBaseDescription
almyty/apinode:26-alpineBackend API server
almyty/frontendnginx:1.31-alpineFrontend SPA served via nginx

Tags:

TagMeans
latestNewest stable release. Minted only by the release workflow on a v* git tag.
X.Y.Z, X.Y, XRelease tags. Note the v is stripped: git tag v1.2.3 publishes 1.2.3, 1.2 and 1. There is no v1.2.3 image tag.
prod, staging, devMoving tags for the head of the master, staging and development branches.

There are no per-commit or digest-suffixed tags. To pin a deployment, pin a release tag such as almyty/api:1.2.3.

Docker Compose

The repository includes a docker-compose.yml that runs Postgres, Redis, the backend API, and the frontend (with an optional nginx profile for production):

services: postgres: # NOT postgres:16-alpine. The memory module stores embeddings in a # pgvector column and its first migration runs CREATE EXTENSION vector, # which stock postgres images do not ship. Substituting one makes the # API fail to boot on migration MemoryCanonicalInit. image: pgvector/pgvector:pg16 environment: POSTGRES_DB: almyty POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} ports: ["5433:5432"] volumes: [postgres_data:/var/lib/postgresql/data] redis: image: redis:7-alpine ports: ["6380:6379"] command: redis-server --appendonly yes backend: image: almyty/api:latest build: { context: ./backend, target: development } ports: ["4000:3000"] environment: - NODE_ENV=development - DATABASE_HOST=postgres - REDIS_HOST=redis - JWT_SECRET=${JWT_SECRET:-dev-secret-change-me} # Not in the shipped file. Add it, or every stored secret is # encrypted with a key that is published in this repository. - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY} # Not in the shipped file. Without it no email is ever sent and # nobody can complete signup. - RESEND_API_KEY=${RESEND_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} depends_on: postgres: { condition: service_healthy } redis: { condition: service_started } frontend: image: almyty/frontend:latest build: { context: ./frontend } ports: ["4001:3000"] depends_on: [backend] volumes: postgres_data: redis_data:

The ENCRYPTION_KEY and RESEND_API_KEY lines above are additions, not a transcription: the docker-compose.yml in the repository omits both, and the sections below explain what that costs you.

Quick start

There is no .env.example at the repository root. Compose interpolates a handful of variables from a root .env if one exists, so create it yourself:

git clone https://github.com/almyty-inc/almyty.git cd almyty cat > .env <<'ENVFILE' POSTGRES_PASSWORD=<a strong password> JWT_SECRET=<openssl rand -hex 48> ENCRYPTION_KEY=<openssl rand -hex 32> RESEND_API_KEY=<your Resend key> ENVFILE docker-compose up -d

ENCRYPTION_KEY and RESEND_API_KEY are not forwarded to the backend by the shipped docker-compose.yml. Add them to the backend service’s environment: block, or the platform will encrypt your secrets with the built-in dev key and send no email. See ENCRYPTION_KEY has a dev fallback that will bite you and Outbound email.

The frontend is available at http://localhost:4001 and the backend API at http://localhost:4000.

Services

ServiceImagePort (host)Port (container)
postgrespgvector/pgvector:pg1654335432
redisredis:7-alpine63806379
backendalmyty/api40003000
frontendalmyty/frontend40013000

Building from source

If you need custom builds instead of pulling from Docker Hub:

docker build -t almyty/api ./backend docker build -t almyty/frontend ./frontend

Both Dockerfiles are multi-stage. The backend builds on node:26-alpine and produces a minimal production image. The frontend builds the Vite app on node:26-alpine and copies the output into nginx:1.31-alpine.

Kubernetes

This repository ships a single Kustomize base, k8s/base. Create an environment-specific overlay on top of this base for your deployment.

What is in the base

FileContents
namespace.yamlNamespace
configmap.yamlNon-secret backend configuration
secret.yamlSecret template with placeholder values, including ENCRYPTION_KEY and JWT_SECRET
postgres-deployment.yaml, redis-deployment.yamlIn-cluster Postgres and Redis, for a self-contained install
api-deployment.yaml, frontend-deployment.yamlThe two application Deployments and their Services
api-network-policy.yamlNetworkPolicy for the API pods
ingress.yamlIngress
cert-manager.yamlLet’s Encrypt ClusterIssuer resources (production and staging ACME endpoints)
migration-job.yamlOne-off Job that runs pending TypeORM migrations

kustomization.yaml deliberately leaves secret.yaml out of its resources list, because the secret values are environment-specific. Your overlay supplies it.

Deploy

# Inspect what the base renders before applying anything kubectl kustomize k8s/base # Apply it directly (in-cluster Postgres and Redis, placeholder secrets) kubectl apply -k k8s/base

Applied as-is the base is not ready for your environment: secret.yaml holds CHANGE_ME_* placeholders, and ingress.yaml is hard-coded to almyty’s own hostnames (api.almyty.com, app.almyty.com, *.almyty.app). Write an overlay that references ../base and patches at minimum the secret values and the ingress hosts. Replica counts (2 each for the API and frontend) and CPU/memory requests and limits are already set in the base; patch those too if your cluster is sized differently.

TLS

k8s/base/cert-manager.yaml defines Let’s Encrypt ClusterIssuer resources. cert-manager itself is not installed by this base, so install it in the cluster first. Point ingress.yaml at your hostname and reference the issuer from the Ingress annotations.

Required environment variables

VariableDescriptionExample
DATABASE_HOSTPostgreSQL hostnamelocalhost
DATABASE_PORTPostgreSQL port5432
DATABASE_USERNAMEPostgreSQL useralmyty
DATABASE_PASSWORDPostgreSQL password—
DATABASE_NAMEPostgreSQL database namealmyty
DB_SSLEnable SSL for managed databasestrue or false
REDIS_HOSTRedis hostnamelocalhost
REDIS_PORTRedis port6379
JWT_SECRETSecret for signing JWT tokensRandom 64+ char string
ENCRYPTION_KEYAES-256 key for encrypting stored secrets: credentials, LLM provider API keys, SSO config. See the warning below: this one has a silent fallbackRandom 32-byte hex string

ENCRYPTION_KEY has a dev fallback that will bite you

If ENCRYPTION_KEY is unset, the backend does one of two things depending on NODE_ENV:

  • NODE_ENV=production: it throws on first use and refuses to encrypt anything. Loud, correct.
  • anything else, including the docker-compose.yml in this repository, which sets NODE_ENV=development: it falls back to the literal string default-encryption-key-change-me!, prints one [SECURITY WARNING] line, and carries on.

That fallback is in the public source. Every credential, provider API key and SSO secret written under it can be decrypted by anyone who can read this repository and get a copy of your database. It is not a placeholder that later gets replaced; it is the key your data is actually encrypted with until you set the variable.

The compose file does not pass ENCRYPTION_KEY through, so set it explicitly on the backend service before you store a single credential:

openssl rand -hex 32
backend: environment: - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY}

The :? form makes Compose refuse to start rather than silently fall back. Rotating this key later does not re-encrypt what is already stored, so anything written under the dev fallback has to be re-entered.

Optional variables

VariableDescriptionDefault
PORTBackend listen port3000
FRONTEND_URLFrontend origin. Used for CORS and as the base URL in verification, password-reset and invite emailshttp://localhost:3002
ALMYTY_API_BASE_URLAPI origin for the frontend (cross-domain deploys)(same origin)
METRICS_RETENTION_DAYSHow long usage metrics and request logs are kept before the retention sweep prunes them (0 disables pruning)90
MONITORING_STATS_WINDOW_SECONDSRolling window the live monitoring dashboard aggregates over300
DB_MIGRATIONS_RUNWhether the API runs pending migrations on startup (see Database migrations)true
RATE_LIMIT_TTLRate-limit window in seconds60
RATE_LIMIT_MAXRequests allowed per window, per client address (see Rate limiting)100
TRUSTED_PROXY_HOPSHow many proxies in front of the API append to X-Forwarded-For. The client address is read this many entries from the right of the header. Keep 1 behind a single ingress or reverse proxy; add one for each extra proxy (a CDN, an L7 load balancer)1
BULL_REDIS_HOSTSeparate Redis for BullMQ (if desired)Falls back to REDIS_HOST
RESEND_API_KEYResend  API key. The only outbound-email transport. Without it no mail is sent at all (see Outbound email)—
MAIL_FROMSender address for outbound mail. EMAIL_FROM is accepted as a fallbackalmyty <notifications@almyty.com>
PLATFORM_METRICS_TOKENBearer token for /monitoring/health/details and /monitoring/metrics*. Unset means those endpoints refuse every request— (endpoints disabled)
OLLAMA_ALLOW_PRIVATE_URLSAllow Ollama providers to target localhost/private-network URLs (see Local models with Ollama)false

Outbound email

There is no SMTP support. The backend sends mail through Resend  and reads exactly one credential, RESEND_API_KEY.

Set it before you invite anyone to your instance. With no key the mail service warns at boot that outbound email is disabled, and every send becomes a log line instead of an email. That breaks signup outright: registration creates the account unverified and sends a verification email, and login refuses an unverified account with EMAIL_NOT_VERIFIED. No key means the verification mail never arrives and the new account can never sign in. Password resets and organization invites fail the same way. Outside NODE_ENV=production the send call still reports success, so nothing in the UI tells you.

Everything mail-related:

RESEND_API_KEY=re_... # required for any mail to leave the box MAIL_FROM="almyty <notifications@example.com>" # sender; must be a domain verified in Resend FRONTEND_URL=https://almyty.example.com # base URL used in verification, reset and invite links

FRONTEND_URL is the same variable the CORS origin uses. If it is wrong, the links in the emails point at the wrong host.

Local models with Ollama

almyty’s SSRF protection refuses private and loopback URLs on outbound LLM calls. That is the right default for a multi-tenant host, but it also blocks a machine-local Ollama  server. Self-hosted deployments can opt out for the ollama provider type only:

OLLAMA_ALLOW_PRIVATE_URLS=true

Set it on the backend API process, then connect Ollama (Models → Connect a provider → Ollama → Your own server) and give its URL, for example http://localhost:11434. Choosing Ollama picks Ollama Cloud first, which needs an API key and none of this. Chat and tool calling use Ollama’s OpenAI-compatible /v1 endpoint, the model list comes from /api/tags, and memory embeddings use /api/embed. No API key is needed for your own server.

Networking note for Docker Compose: inside the backend container, localhost is the container itself, not your machine. Point the provider at the host instead, http://host.docker.internal:11434 (on Linux, add extra_hosts: ["host.docker.internal:host-gateway"] to the backend service).

The private-network exception applies only to the configured provider host. Other hosts remain subject to DNS-pinned address checks; the exception does not authorize redirects to arbitrary internal services. URLs must still use HTTP(S) without embedded credentials. Leave private-network overrides disabled unless the deployment needs them.

Health check endpoints

EndpointAuthWhat it actually checks
GET /healthnoneNothing. Returns {"status":"ok"} unconditionally.
GET /health/livenoneNode heap is under 3.4 GB. No dependency checks.
GET /health/readynonePostgres ping and Redis ping. Non-200 if either is unreachable.
GET /monitoring/healthnoneComputed status only: healthy, degraded or unhealthy.
GET /monitoring/health/detailsBearer $PLATFORM_METRICS_TOKENFull per-component breakdown.

GET /health is a deliberate fixed 200. A full check result would, on failure, serialise the underlying connection error (database hostname, port, sometimes a fragment of the connection string) to anyone who polled it, so it hands out nothing beyond “this process answered”. Treat it as a reachability ping and nothing more.

For anything that needs to know whether the system is working, use GET /health/ready (dependencies) or the token-gated GET /monitoring/health/details (component breakdown). PLATFORM_METRICS_TOKEN fails closed: with it unset, the details endpoint refuses every request rather than serving the breakdown publicly.

Kubernetes probes

livenessProbe: httpGet: path: /health/live port: 3000 initialDelaySeconds: 10 periodSeconds: 15 readinessProbe: httpGet: path: /health/ready port: 3000 initialDelaySeconds: 5 periodSeconds: 10

Monitoring and observability

Kubernetes probes keep a single deployment healthy, but they don’t tell you when something breaks. Wire up external monitoring so failures page you instead of being discovered by hand.

Uptime checks

External uptime pingers (UptimeRobot, Pingdom, Better Uptime, or a Kubernetes-native probe exporter) can’t be provisioned from this repo. Configure them in your monitoring tool of choice against these endpoints:

EndpointPurposeAlert when
GET /health/readyReadiness: API plus its Postgres and Redis dependencies. This is the one to page on.Non-200, or down for more than one check interval.
GET /health/liveLiveness: heap headroom only. Does not check dependencies.Use for restart detection, not paging.
GET /monitoring/healthComputed platform status (healthy / degraded / unhealthy). Public, no component breakdown.Useful for a status dashboard.
GET /monitoring/health/detailsFull component breakdown. Requires Authorization: Bearer $PLATFORM_METRICS_TOKEN; refuses every request when that variable is unset.Diagnostics, not paging.

Do not point a status dashboard at GET /health. It returns {"status":"ok"} unconditionally. It never fails, by design, so that an unauthenticated caller cannot enumerate your dependencies. A dashboard wired to it is permanently green no matter what is broken.

Recommended baseline: poll /health/ready every 30–60s from at least two regions, alert on two consecutive failures. /health/ready returns non-200 when the database or Redis is unreachable, so it catches the dependency outages that a bare liveness check would miss.

Gate your rollout on the migration Job

This repository’s GitHub Actions workflows build and publish images; they do not roll out to a cluster. The rollout pipeline almyty runs for its own hosted deployment is not part of this repository and is not public, so you will be wiring your own.

Whatever you wire it with, run k8s/base/migration-job.yaml to completion first and only continue the rollout if it succeeds (see Database migrations below). A failed migration then stops the deploy instead of shipping pods against a schema they don’t expect, so a broken release surfaces at deploy time rather than in production.

Error tracking (Sentry)

almyty ships with optional Sentry  integration on both the API and the frontend. It is off by default and a complete no-op until you configure a DSN: no client loads, no network calls, no behavior change. Turn it on to have unhandled errors and 5xx responses surface in Sentry instead of buried in logs.

  • Backend: set SENTRY_DSN (and optionally SENTRY_ENVIRONMENT, e.g. production / staging) on the API. The global exception filter reports 5xx responses and unhandled exceptions; client errors (4xx) are never sent. In Kubernetes these are optional keys on the almyty-secrets secret.
  • Frontend: set the ALMYTY_SENTRY_DSN build-arg when building the frontend image. It captures unhandled errors and errors caught by the app’s error boundary. The environment tag is inferred from the host (development stays untracked).

With no DSN set, both stay dark, so you can adopt error tracking without changing anything else about your deployment.

Database migrations

How migrations run depends on how you deploy.

On a single-node or Docker Compose setup the API applies any pending migrations itself when it starts (DB_MIGRATIONS_RUN defaults to true). There’s only one process, so there’s nothing to coordinate.

In a cluster you don’t want several replicas racing to run the same migrations as they boot. Set DB_MIGRATIONS_RUN=false and run migrations once, before the new pods roll out, as a Kubernetes Job, an init container, or npm run typeorm migration:run. k8s/base/migration-job.yaml is that Job. Gate your rollout on it completing successfully, so a failed migration stops the deploy instead of shipping pods against a schema they don’t expect.

Either way, back up the database before upgrading.

Rate limiting

Every API request counts against a limit of RATE_LIMIT_MAX requests per RATE_LIMIT_TTL seconds, kept separately for each client address. The defaults are 100 requests per 60 seconds. Sensitive routes such as login, registration and password reset have their own, tighter limits on top, also per client address.

Limits are backed by Redis, so a client’s count is shared across every API replica rather than kept per process. Point all replicas at the same Redis and the limit holds for the cluster as a whole.

The API does not trust X-Forwarded-For blindly, because the client writes the left end of that header. Every proxy appends the address it received the connection from, so the API counts TRUSTED_PROXY_HOPS entries in from the right: with one ingress in front, the rightmost entry is the address the ingress saw. Set it to match your topology:

  • One ingress or reverse proxy (the default, and what k8s/base gives you): TRUSTED_PROXY_HOPS=1.
  • A CDN or L7 load balancer in front of the ingress that appends its own hop: TRUSTED_PROXY_HOPS=2, and one more for each further proxy.

Set it too high and a client can choose its own rate-limit bucket by sending a longer header. Set it too low and different clients are counted as one, which throttles them harder but never lets anyone past the limit. The ingress also has to see the real client address: if a TCP load balancer in front of it rewrites the source address, enable PROXY protocol (or preserve the source IP) on that load balancer, or every client arrives as the load balancer.

Do not expose the API port directly to the internet alongside a proxy. A request that bypasses the proxy can write whatever X-Forwarded-For it likes.

Because the limit is per client, size RATE_LIMIT_MAX for one busy source rather than for the whole platform. A single dashboard page load issues several API calls at once, many users can share one office NAT address, and inbound chat-channel webhooks arrive from each provider’s small pool of addresses for all of your tenants. A few hundred requests a minute per address is a reasonable starting point; watch for 429 responses and adjust.

Compliance and data residency

Self-hosting keeps every byte (conversations, memory, credentials, logs) in infrastructure you control, which is the simplest answer to GDPR data-residency questions. Secrets are AES-256-GCM encrypted with your ENCRYPTION_KEY; rotate it like any other production secret and never commit it. Remember that BYOK (bring your own key: almyty calls model providers with the keys you connect) means conversation content flows to your model providers under your API keys, so your data-processing agreements with those providers are part of your deployment’s compliance story.

The Compliance page maps platform features to the EU AI Act (transparency disclosure, human oversight, record-keeping, technical documentation export) and the other regimes that typically apply. The repository’s SECURITY.md documents the vulnerability-disclosure process and how to generate an SBOM for your release.

Upgrades

docker-compose pull docker-compose up -d

Review the Database migrations note above before upgrading a clustered deployment.