Skip to content

Deployment

Compiling

Maat applications build to a native CLI bundle:

Terminal window
dart build cli --target=bin/server.dart --output=build

The result is under build/bundle: bin/server is the executable and lib/ contains native assets such as SQLite. Copy the whole bundle to a compatible machine and run it without the Dart SDK.

This works because the framework uses no reflection anywhere — no dart:mirrors, no code generation, no build step. Route handler arity, middleware resolution and validation rules are all dispatched with type checks and string keys rather than runtime introspection.

Running

The binary reads its host and port from the environment:

Terminal window
APP_HOST=0.0.0.0 APP_PORT=8000 ./build/bundle/bin/server

The generated server waits for SIGINT or SIGTERM. It stops accepting new requests, waits for active requests, then calls every service provider’s shutdown() in reverse order.

APP_BASE_PATH sets the base path when the working directory is not the application root.

Environment and Secrets

Before you ship:

APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:...

Disable debug mode. With it enabled, a 500 response includes the exception class, its message and a full stack trace. That is exactly what you want locally and exactly what you must not expose publicly.

Set an application key. Generate one with maat key:generate, and set it as an environment variable in production rather than shipping a .env file.

Real environment variables take precedence over the .env file, so a container or an orchestrator can supply configuration without the file existing at all.

Compiling CSS

public/css/app.css is not committed by default — only a .gitkeep is. Build it as part of every deploy, before the native bundle:

Terminal window
maat tailwind --minify

The compiled binary reads public/css/app.css off disk at request time, the same way it reads resources/views/*.khnum.html; public/ has to ship with the application, not just the executable. See Frontend for the pipeline that produces it.

Container Image

The skeleton ships a Dockerfile that compiles the CSS, compiles the application, and copies both into a scratch image:

FROM dart:stable AS build
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
COPY . .
RUN dart run bin/maat.dart tailwind --minify
RUN dart pub get --offline && dart build cli --target=bin/server.dart --output=build
FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /app/build/bundle /app
COPY --from=build /app/public /app/public
WORKDIR /app
ENV APP_ENV=production APP_DEBUG=false APP_HOST=0.0.0.0 APP_PORT=8000
EXPOSE 8000
CMD ["/app/bin/server"]

dart run bin/maat.dart tailwind boots the full application, so this stage has to succeed with no .env and no reachable database — sqlite needs neither. If that stops holding, build the CSS in CI instead and COPY public/ straight in.

The complete bundle is copied so native build-hook assets stay beside the executable. /runtime/ supplies the minimal Dart runtime files expected by the official image.

Terminal window
docker build -t blog .
docker run -p 8080:8080 -e APP_KEY="$APP_KEY" -e APP_DEBUG=false blog

Logging in Production

Leave app.log_path unset in a container and let the runtime collect standard output — that is what every log aggregator expects. Set it only when you genuinely want a file on disk, and make sure the directory is writable by the process.

Health Checks

The skeleton’s /api/health route is there for your load balancer or orchestrator:

livenessProbe:
httpGet:
path: /api/health
port: 8080

Behind a Reverse Proxy

Bind the application to 127.0.0.1 and let the proxy hold the public port and terminate TLS:

Terminal window
APP_HOST=127.0.0.1 APP_PORT=8000 ./build/bundle/bin/server

request.ip reports the socket’s peer address. Behind a proxy, that is the proxy — read the forwarded header if you need the original client:

final clientIp = request.header('x-forwarded-for')?.split(',').first.trim() ?? request.ip;

Only trust that header when your proxy sets it, since a client can send it too.

Worker Processes

Use multiple CPU cores by setting the worker count:

Terminal window
APP_WORKERS=4 APP_HOST=0.0.0.0 APP_PORT=8000 ./build/bundle/bin/server

Each worker boots an isolated application and shares the same port. Size APP_WORKERS to available CPU cores. Database pools are also per worker, so the possible PostgreSQL connection count is APP_WORKERS × DB_POOL_MAX.

The framework’s global helpers — app(), config(), env(), and route() — resolve inside that worker’s application. In custom process layouts, create one application per process; do not host unrelated applications in one isolate.

A Deployment Checklist

  • APP_DEBUG=false
  • APP_KEY set from the environment, not committed
  • .env excluded from the image
  • dart build cli --target=bin/server.dart succeeds
  • maat tailwind --minify run, and public/ shipped with the image
  • dart test passes
  • Health check wired to /api/health
  • Log destination decided — stdout, or a writable path