LayBuild's source is on GitHub and runs as a docker-compose stack of six services. Self-hosting keeps conversations, knowledge documents and vectors on machines you control. It does not, by default, keep model traffic in: the compose file points at OpenRouter, so every question and the retrieved context go to a third-party model API until you change that. This post covers what the stack contains, the settings you must get right, every outbound connection we know of, and how to point chat and embeddings at a model server on your own network.

We ship docker-compose only. There's no Helm chart or Kubernetes manifest in the repository.

What's in the stack

text
                 internet
                    |
             nginx (proxy) :80 / :443
              /             \
      web (Next.js) :3000   api (Fastify, Bun) :4000
                              |      |       |
                      postgres:16  redis:7  qdrant
                    (conversations, (cache,  (vectors,
                     knowledge,     rate     laybuild_knowledge_docs)
                     full-text)     limits,
                                    locks)

Postgres, Redis and Qdrant publish their ports on 127.0.0.1 only, so they aren't reachable from other hosts. The API and web containers run with no-new-privileges and all Linux capabilities dropped, and the web container has a read-only root filesystem. The web container gets no backend secrets: it only reads NEXT_PUBLIC_* values.

Getting it running

  • Clone the repository and copy .env.example to .env.production (the compose file reads that by default).
  • Set the required values in the next section.
  • Run docker compose --env-file .env.production up -d --build, or bun run deploy, which runs the same command.

On first boot the API waits for Postgres, applies the schema and the retrieval indexes, embeds any documents that are missing vectors, and seeds three accounts: [email protected], [email protected] and [email protected]. Their password is BOOTSTRAP_ADMIN_PASSWORD; if you leave it empty, a random password is generated and printed once in the API log. The health check allows up to five minutes for this first start.

Settings you must set

  • JWT_SECRET: in production the API refuses to start unless it's at least 32 characters and doesn't contain change-me (the placeholder in .env.example does).
  • ENCRYPTION_KEY: optional in the schema, but set it. See the next section.
  • POSTGRES_PASSWORD: the compose default is laybuild. DATABASE_URL is built from the POSTGRES_* values unless you set it yourself.
  • One LLM provider. The compose default is LLM_PROVIDER=openrouter with OPENROUTER_API_KEY. The alternatives are below.
  • WEB_URL and NEXT_PUBLIC_API_URL: the public URLs of the web app and API. NEXT_PUBLIC_API_URL is baked into the web build, so rebuild after changing it.
  • BOOTSTRAP_ADMIN_PASSWORD, unless you're happy to copy a generated one from the log.

Why ENCRYPTION_KEY matters more than it looks

LayBuild encrypts stored LLM API keys, SMTP passwords, widget keys and uploaded files with AES-256-GCM (files use a key derived per organization). The master secret is ENCRYPTION_KEY, and if that's empty it falls back to JWT_SECRET.

That fallback couples two secrets with different lifecycles. The standard response to a leaked JWT secret is to rotate it, which signs everyone out. If your encryption also hangs off that value, rotating it leaves every stored provider key, SMTP password and uploaded file undecryptable. Set a separate ENCRYPTION_KEY from the first boot, and back it up somewhere other than the server, because losing it has the same effect.

The source also contains a fixed development key, public in the repository, used only when no secret can be loaded outside production. Keep NODE_ENV=production (the compose default) and set both secrets, and it never applies.

What leaves your network

Self-hosted doesn't mean silent. These are the outbound connections in the code, and what controls each one:

  • LLM chat and embeddings go to the provider you configure. Customer questions, recent history and retrieved knowledge are in those requests.
  • Fallback providers. Every other provider key present in the environment (Gemini, OpenAI, DeepSeek, Qwen, Cloudflare, OpenRouter, NVIDIA, Hugging Face) is added as a fallback tier behind the primary, for chat and for embeddings. A key you set for testing and forgot about is a route out.
  • Model downloads. The API's NLP package fetches two small models from Hugging Face on first use (onnx-community/all-MiniLM-L6-v2-ONNX and Xenova/bert-base-NER) and caches them in the api_model_cache volume.
  • Knowledge URLs. When an admin adds a URL to the knowledge base, the API fetches that page.
  • API tools and outbound webhooks call the endpoints your admins configure. Webhook payloads include message text.
  • WhatsApp, if you connect it, talks to Meta's Cloud API.
  • SMTP, PayU billing, Langfuse tracing and Cloudflare R2 storage connect out only when their credentials are set. Google Analytics and Microsoft Clarity load in the web app only if you set their IDs at build time.
  • Building the images pulls base images and packages from their public registries.

Keeping model traffic on your own server

LayBuild can use any server that speaks the OpenAI API (/v1/chat/completions and /v1/embeddings), such as llama.cpp's server, vLLM or Ollama's OpenAI-compatible endpoint. Set:

bash
LLM_PROVIDER=openai-compatible
LLM_API_URL=http://your-model-host:8080/v1
LLM_API_KEY=sk-local          # required by LayBuild; any value if your server ignores it
LLM_MODEL=your-chat-model
EMBEDDING_MODEL=your-embedding-model

All three of LLM_API_URL, LLM_API_KEY and LLM_MODEL are required, or the API won't build the provider. With this provider selected, embeddings go to the same URL using EMBEDDING_MODEL. Then remove every other provider key from the env file, so no fallback tier points outside.

Two cautions. First, LLM_API_URL is resolved from inside the API container, so localhost means the container itself; use a hostname on the Docker network or your LAN. Second, answer quality depends heavily on the model. LayBuild answers only from your knowledge base, and when an answer doesn't overlap enough with the retrieved sources it replaces it with a fixed "I do not have specific information about that..." reply. A small local model tends to trip that check more often. Test with your real questions before switching customers over.

If you change the embedding model on an existing install, the stored vectors were made with the old one. Re-embed your knowledge base.

Storage without R2

Uploaded files go to Cloudflare R2 when its credentials are set. Without them, LayBuild keeps the original files in the API process's memory, so they're gone after a restart. The extracted text, chunks and vectors are in Postgres and Qdrant and survive; what you lose is the ability to download the original file. The R2 client accepts a custom endpoint, but we haven't tested it against other S3-compatible stores.

Hardening checklist

  • TLS. The compose file publishes port 443, but the bundled nginx config only listens on 80. Add your certificates to the nginx config or terminate TLS at a load balancer in front of it.
  • Set QDRANT_API_KEY. Qdrant is bound to localhost on the host, but anything on the Docker network can reach it without a key otherwise.
  • Pin the Qdrant image. The compose file uses qdrant/qdrant:latest.
  • Turn on two-factor for the seeded owner and admin accounts, change their passwords, and delete the seeded agent account if you don't use it. See LayBuild's four roles for what the owner account can do.
  • Back up the postgres_data and qdrant_data volumes, and ENCRYPTION_KEY separately.
  • Check the compose defaults against what you want. It enables human handoff (ALLOW_HUMAN_HANDOFF=true) and sets RAG_MIN_SCORE=0.2, where the code default is 0.4. The OpenRouter model defaults to openrouter/free; check the data terms of whatever model that routes to, or set your own.

Frequently asked questions

Can it run fully air-gapped?

Not out of the box. With a local model server and no other provider keys, day-to-day traffic stays inside. But the image build needs registry access, and the NLP models download from Hugging Face on first use. You'd need to build images on a connected machine and pre-populate the model cache volume. We haven't documented or tested that path.

Does self-hosting make LayBuild compliant with anything?

No. It changes who operates the infrastructure, which may help your own compliance work, but LayBuild holds no SOC 2, HIPAA, ISO 27001 or GDPR certification, self-hosted or not.

Related reading