# Deploy with Docker

### Building a Lowdefy app image

A Lowdefy app can be self-hosted or deployed to any hosting provider that runs Docker containers. The image contains the built app configuration, the [Hono](https://hono.dev) server, and the built client assets — a self-contained Node.js server with no external build steps at runtime.

Run the `init-docker` CLI command in your project to create a `Dockerfile` and `.dockerignore`:

```
pnpx lowdefy@6 init-docker
```

The generated Dockerfile:

```text
# syntax=docker/dockerfile:1

FROM node:24-slim AS builder

WORKDIR /lowdefy

# pnpm is required by the Lowdefy build to install server dependencies. Installed
# through npm rather than corepack — corepack is not bundled from Node.js 25.
RUN npm install --global pnpm@10

# pnpm stores packages in $PNPM_HOME/store — mounted as a BuildKit cache below so
# repeat builds only download new or changed packages.
ENV PNPM_HOME=/pnpm
ENV PATH="$PNPM_HOME/bin:$PATH"

COPY . .

# Build the app then assemble a minimal runtime at .lowdefy/docker. docker-output
# traces the server's actual dependency graph (via @vercel/nft), so the runtime
# ships only the files the server imports — not the full node_modules, which
# includes the client block packages already compiled into dist/client. The CLI
# is pinned to the `lowdefy:` version in lowdefy.yaml so builds are reproducible.
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
  LOWDEFY_VERSION=$(sed -nE "s/^lowdefy:(.*)/\1/p" lowdefy.yaml lowdefy.yml 2>/dev/null | tr -d '[:space:]') \
  && test -n "$LOWDEFY_VERSION" || { echo "Could not read the lowdefy version from lowdefy.yaml"; exit 1; } \
  && npx lowdefy@"$LOWDEFY_VERSION" build \
  && npx lowdefy@"$LOWDEFY_VERSION" docker-output

FROM node:24-slim AS runner

ENV NODE_ENV=production
ENV PORT=3000

WORKDIR /lowdefy

# The node user (uid 1000) ships with the official Node.js images.
COPY --from=builder --chown=node:node /lowdefy/.lowdefy/docker ./

USER node

EXPOSE 3000

# The Lowdefy server serves a liveness endpoint at /api/lowdefy-health. If your app sets a
# basePath, prepend it to the path below. Orchestrators with their own probes
# (e.g. Kubernetes) ignore this and should point their probes at the same path.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD ["node", "-e", "fetch('http://localhost:'+(process.env.PORT||3000)+'/api/lowdefy-health').then((res)=>process.exit(res.ok?0:1)).catch(()=>process.exit(1))"]

# start.mjs (written by docker-output) chdirs into the server directory and boots
# it. Exec form keeps node as PID 1 — the server handles SIGTERM/SIGINT for
# graceful shutdown, so no init wrapper is needed.
CMD ["node", "start.mjs"]
```

with a `.dockerignore` file:

```
.git
.lowdefy
.env
.env.*
node_modules
Dockerfile
.dockerignore
README.md
```

Build and run the image:

```
docker build -t my-lowdefy-app .
docker run -p 3000:3000 my-lowdefy-app
```

Key properties of this setup:

- **Reproducible builds**: the CLI version is read from the `lowdefy:` field in `lowdefy.yaml`, so the image always builds with the same Lowdefy version as your app config, and rebuilding an old commit produces the same server.
- **Fast rebuilds**: the pnpm store is kept in a BuildKit cache mount, so changing app config only re-downloads dependencies that actually changed.
- **Small runtime image**: `docker-output` traces the server's real import graph and copies only what the server loads at runtime — a fraction of the installed `node_modules`, since the client-side block packages are already compiled into `dist/client` and never imported by the server.
- **Non-root**: the server runs as the `node` user built into the official Node.js images.
- For stronger supply-chain guarantees, pin the base images by digest (`node:24-slim@sha256:...`) and let a bot like Renovate or Dependabot keep the digest updated.

### Health checks

The Lowdefy server serves `GET /api/lowdefy-health`, which responds `200` with `{"status":"ok"}`. The endpoint skips authentication, session lookup, and request logging, so frequent probes do not pollute logs or create sessions.

The Dockerfile `HEALTHCHECK` uses this endpoint, which makes `docker ps` and Docker Compose report container health. Kubernetes ignores the Dockerfile `HEALTHCHECK` — point liveness and readiness probes at `/api/lowdefy-health` instead:

```yaml
livenessProbe:
  httpGet:
    path: /api/lowdefy-health
    port: 3000
readinessProbe:
  httpGet:
    path: /api/lowdefy-health
    port: 3000
```

If your app configures a `basePath`, the endpoint is served at `{basePath}/api/lowdefy-health`.

### Environment variables and secrets

The server is configured entirely through environment variables at runtime — the image itself contains no secrets:

| Variable            | Purpose                                                                  |
| ------------------- | ------------------------------------------------------------------------ |
| `PORT`              | Port the server listens on (default `3000`).                             |
| `LOWDEFY_SECRET_*`  | App secrets, accessed with the [`_secret`](/_secret) operator.           |
| `AUTH_SECRET`       | Required when using [Lowdefy Auth](/users-introduction).                 |
| `LOWDEFY_ENVIRONMENT` | Read at build time: the environment this image is built for, one of `config.environments`. |
| `BETTER_AUTH_URL`   | The app's canonical origin for auth links and the MCP resource URIs. Defaults to the `url` of the current environment in `config.environments`. The server refuses to start without one of the two when `auth.email` is configured. Set it when a proxy terminates TLS in front of the container: without it, auth trusts only the origin the request reached the server on (`http://…`), and refuses sign-in from the `https://` page. |
| `CRON_SECRET`       | Bearer token protecting `/api/cron/*` and `/api/detached/*` endpoints.   |
| `LOWDEFY_LOG_LEVEL` | Server log level: `debug`, `info` (default), `warn`, `error`.            |
| `SENTRY_DSN`        | Enables Sentry error reporting when set.                                 |

In production, use your hosting provider's secret manager to inject these — never bake them into the image. When running locally, pass a `.env` file:

```
docker run -p 3000:3000 --env-file ./.env my-lowdefy-app
```

The `.dockerignore` excludes `.env` from the build context on purpose, so secrets cannot leak into image layers. If your build needs a secret (for example `SENTRY_AUTH_TOKEN` to upload source maps), use a BuildKit secret mount instead of a build argument — build arguments are recorded in the image history:

```
docker build --secret id=sentry,env=SENTRY_AUTH_TOKEN .
```

and in the Dockerfile:

```text
RUN --mount=type=secret,id=sentry,env=SENTRY_AUTH_TOKEN \
  npx lowdefy@"$LOWDEFY_VERSION" build
```

### Behind a reverse proxy

The server takes each request's client address from its connection. Auth rate limits count attempts per client address (for example 3 sign-in attempts per 10 seconds), sessions record it, and the request log writes it as `client_address`. `X-Forwarded-For` is not read by default, because any client can send it.

Behind a reverse proxy, load balancer or ingress, every connection comes from the proxy, so every client would share its address and its rate limits. List the proxy addresses, or CIDR ranges, in `config.trustedProxies`:

```yaml
config:
  trustedProxies:
    - 10.0.0.0/8
    - 172.16.0.0/12
```

When a request's connection comes from a trusted proxy, the server reads `X-Forwarded-For` from the right, skips every trusted hop, and takes the first address that is not trusted. Entries further left were written by the client and are ignored. Do not list `0.0.0.0/0` or `::/0`: they trust every address, so any client could choose its own, and the build warns about them. The proxy must append to `X-Forwarded-For` (nginx: `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`). The server logs a warning the first time a request carries `X-Forwarded-For` while `trustedProxies` is not set.

### Logging

The server writes structured JSON logs (one JSON object per line, via [pino](https://getpino.io)) to stdout — the format container platforms and log collectors (CloudWatch, Loki, Datadog, etc.) expect. Nothing is written to files inside the container.

Control verbosity with `LOWDEFY_LOG_LEVEL`. To read logs as human-friendly output during local development, pipe them through `pino-pretty`:

```
docker logs -f my-container 2>&1 | npx pino-pretty
```

### Graceful shutdown

On `SIGTERM` (sent by `docker stop`, Compose, and Kubernetes) the server stops accepting new connections, closes websocket clients with code `1001` (clients reconnect and resubscribe automatically), finishes in-flight requests, flushes pending Sentry events, and exits — within Docker's default 10 second stop grace period. No `--init` flag or init wrapper is required.

### Scheduled endpoints

Unlike [Vercel deployments](/vercel), a Docker container has no built-in scheduler for API endpoint `schedules`. Trigger scheduled endpoints from an external scheduler — a host crontab, a Kubernetes CronJob, or your platform's scheduled tasks — by calling the cron route with the `CRON_SECRET` bearer token:

```
curl -H "Authorization: Bearer $CRON_SECRET" https://my-app.example.com/api/cron/my-endpoint-id
```

### Docker Compose

```yaml
services:
  lowdefy:
    build: .
    ports:
      - '3000:3000'
    env_file: ./.env
    restart: unless-stopped
```

The container inherits the Dockerfile `HEALTHCHECK`, so `docker compose ps` reports health. Build and run with:

```
docker compose up --build
```

If you set a container memory limit, Node.js 24+ sizes its heap from the container's cgroup limit automatically. To control it explicitly, set `NODE_OPTIONS=--max-old-space-size=<MiB>` a comfortable margin below the container limit.

### Config files or plugins outside the config directory

Only files inside the Docker build context (the directory containing the Dockerfile) can be accessed during the build.

Sometimes files outside of the config directory need to be accessed by the Lowdefy app during build - for example using the `_ref` operator on shared config files, or when using pnpm local workspace plugins. In this case, move the Dockerfile up to a directory that contains everything the build needs, and point the build at the config directory:

```text
# syntax=docker/dockerfile:1

FROM node:24-slim AS builder

WORKDIR /lowdefy

RUN npm install --global pnpm@10

ENV PNPM_HOME=/pnpm
ENV PATH="$PNPM_HOME/bin:$PATH"

COPY . .

# TODO: Change the config directory (./app) as appropriate here
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
  LOWDEFY_VERSION=$(sed -nE "s/^lowdefy:(.*)/\1/p" app/lowdefy.yaml app/lowdefy.yml 2>/dev/null | tr -d '[:space:]') \
  && test -n "$LOWDEFY_VERSION" || { echo "Could not read the lowdefy version from lowdefy.yaml"; exit 1; } \
  && npx lowdefy@"$LOWDEFY_VERSION" build --config-directory ./app \
  && npx lowdefy@"$LOWDEFY_VERSION" docker-output --config-directory ./app

FROM node:24-slim AS runner

ENV NODE_ENV=production
ENV PORT=3000

WORKDIR /lowdefy

# TODO: Change the from directory (/lowdefy/app/.lowdefy/docker) as appropriate here
COPY --from=builder --chown=node:node /lowdefy/app/.lowdefy/docker ./

USER node

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD ["node", "-e", "fetch('http://localhost:'+(process.env.PORT||3000)+'/api/lowdefy-health').then((res)=>process.exit(res.ok?0:1)).catch(()=>process.exit(1))"]

CMD ["node", "start.mjs"]
```

When the app is part of a pnpm workspace, `docker-output` preserves the paths of any linked local plugin packages relative to the workspace root, so `start.mjs` boots the server with those links intact — no separate install step is needed in the runner stage.
