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| Image | Base | Description |
|---|---|---|
almyty/api | node:26-alpine | Backend API server |
almyty/frontend | nginx:1.31-alpine | Frontend SPA served via nginx |
Tags:
| Tag | Means |
|---|---|
latest | Newest stable release. Minted only by the release workflow on a v* git tag. |
X.Y.Z, X.Y, X | Release 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, dev | Moving 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 -dENCRYPTION_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
| Service | Image | Port (host) | Port (container) |
|---|---|---|---|
postgres | pgvector/pgvector:pg16 | 5433 | 5432 |
redis | redis:7-alpine | 6380 | 6379 |
backend | almyty/api | 4000 | 3000 |
frontend | almyty/frontend | 4001 | 3000 |
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 ./frontendBoth 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
| File | Contents |
|---|---|
namespace.yaml | Namespace |
configmap.yaml | Non-secret backend configuration |
secret.yaml | Secret template with placeholder values, including ENCRYPTION_KEY and JWT_SECRET |
postgres-deployment.yaml, redis-deployment.yaml | In-cluster Postgres and Redis, for a self-contained install |
api-deployment.yaml, frontend-deployment.yaml | The two application Deployments and their Services |
api-network-policy.yaml | NetworkPolicy for the API pods |
ingress.yaml | Ingress |
cert-manager.yaml | Let’s Encrypt ClusterIssuer resources (production and staging ACME endpoints) |
migration-job.yaml | One-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/baseApplied 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
| Variable | Description | Example |
|---|---|---|
DATABASE_HOST | PostgreSQL hostname | localhost |
DATABASE_PORT | PostgreSQL port | 5432 |
DATABASE_USERNAME | PostgreSQL user | almyty |
DATABASE_PASSWORD | PostgreSQL password | — |
DATABASE_NAME | PostgreSQL database name | almyty |
DB_SSL | Enable SSL for managed databases | true or false |
REDIS_HOST | Redis hostname | localhost |
REDIS_PORT | Redis port | 6379 |
JWT_SECRET | Secret for signing JWT tokens | Random 64+ char string |
ENCRYPTION_KEY | AES-256 key for encrypting stored secrets: credentials, LLM provider API keys, SSO config. See the warning below: this one has a silent fallback | Random 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.ymlin this repository, which setsNODE_ENV=development: it falls back to the literal stringdefault-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
| Variable | Description | Default |
|---|---|---|
PORT | Backend listen port | 3000 |
FRONTEND_URL | Frontend origin. Used for CORS and as the base URL in verification, password-reset and invite emails | http://localhost:3002 |
ALMYTY_API_BASE_URL | API origin for the frontend (cross-domain deploys) | (same origin) |
METRICS_RETENTION_DAYS | How long usage metrics and request logs are kept before the retention sweep prunes them (0 disables pruning) | 90 |
MONITORING_STATS_WINDOW_SECONDS | Rolling window the live monitoring dashboard aggregates over | 300 |
DB_MIGRATIONS_RUN | Whether the API runs pending migrations on startup (see Database migrations) | true |
RATE_LIMIT_TTL | Rate-limit window in seconds | 60 |
RATE_LIMIT_MAX | Requests allowed per window, per client address (see Rate limiting) | 100 |
TRUSTED_PROXY_HOPS | How 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_HOST | Separate Redis for BullMQ (if desired) | Falls back to REDIS_HOST |
RESEND_API_KEY | Resend API key. The only outbound-email transport. Without it no mail is sent at all (see Outbound email) | — |
MAIL_FROM | Sender address for outbound mail. EMAIL_FROM is accepted as a fallback | almyty <notifications@almyty.com> |
PLATFORM_METRICS_TOKEN | Bearer token for /monitoring/health/details and /monitoring/metrics*. Unset means those endpoints refuse every request | — (endpoints disabled) |
OLLAMA_ALLOW_PRIVATE_URLS | Allow 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 linksFRONTEND_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=trueSet 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
| Endpoint | Auth | What it actually checks |
|---|---|---|
GET /health | none | Nothing. Returns {"status":"ok"} unconditionally. |
GET /health/live | none | Node heap is under 3.4 GB. No dependency checks. |
GET /health/ready | none | Postgres ping and Redis ping. Non-200 if either is unreachable. |
GET /monitoring/health | none | Computed status only: healthy, degraded or unhealthy. |
GET /monitoring/health/details | Bearer $PLATFORM_METRICS_TOKEN | Full 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: 10Monitoring 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:
| Endpoint | Purpose | Alert when |
|---|---|---|
GET /health/ready | Readiness: 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/live | Liveness: heap headroom only. Does not check dependencies. | Use for restart detection, not paging. |
GET /monitoring/health | Computed platform status (healthy / degraded / unhealthy). Public, no component breakdown. | Useful for a status dashboard. |
GET /monitoring/health/details | Full 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 optionallySENTRY_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 thealmyty-secretssecret. - Frontend: set the
ALMYTY_SENTRY_DSNbuild-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/basegives 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 -dReview the Database migrations note above before upgrading a clustered deployment.