Skip to content

Docker

Build a small production image with dox build, and run it with Postgres in Docker Compose, with a health check.

What you need

  • A Dox app with a database.
  • Docker with Compose.

1. Add a health route

The health check asks this route. It answers 503 when the database does not answer. Put it in register() of WebRouter. QueryBuilder comes from package:dox_orm/dox_orm.dart.

lib/routes/web.dart
Route.health(
  '/health',
  checks: <String, HealthCheckFunction>{
    'database': () => QueryBuilder.query('select 1'),
  },
);

See Health check for more checks.

2. Add the health check binary

The final image has no shell and no curl. So the health check is a second small Dart binary.

bin/health.dart
import 'dart:io';

/// Docker health check. The final image has no shell and no curl,
/// so this small binary asks the app itself. Exit 0 = healthy.
Future<void> main() async {
  String port = Platform.environment['APP_PORT'] ?? '3000';
  HttpClient client = HttpClient()
    ..connectionTimeout = const Duration(seconds: 3);
  try {
    HttpClientRequest request = await client.getUrl(
      Uri.parse('http://127.0.0.1:$port/health'),
    );
    HttpClientResponse response = await request.close();
    exit(response.statusCode == 200 ? 0 : 1);
  } catch (_) {
    exit(1);
  }
}

3. Write the Dockerfile

Two stages: the first builds the AOT bundle, the second holds only the Dart runtime and the bundle. For the blog API the final image is about 17 MB.

Dockerfile
# Stage 1: build the AOT bundle with the Dart SDK and the dox CLI.
FROM dart:stable AS build

WORKDIR /app

# Resolve dependencies first, so Docker caches this layer.
COPY pubspec.* ./
RUN dart pub get

COPY . .
RUN dart pub get --offline

# `dox build` runs build_runner, then `dart build cli`.
# The bundle is in build/bundle: bin/server, lib/, db/, .env.example
# (and bin/queue, bin/schedule when the app has them)
RUN dart pub global activate dox \
  && /root/.pub-cache/bin/dox build

# The health check binary. Plain dart:io, so `dart compile exe` is fine.
RUN dart compile exe bin/health.dart -o build/bundle/bin/health

# Lay out the final file tree. /tmp must be writable: uploads bigger
# than 1 MB are streamed to a temp file.
RUN mkdir -p /out/app/storage /out/app/public /out/tmp \
  && chmod 1777 /out/tmp \
  && cp -r build/bundle/. /out/app/ \
  && cp -r public/. /out/app/public/ \
  # the native HTTP server (Rust) needs libgcc_s, which /runtime/ does not have
  && LIBGCC=$(find / -name libgcc_s.so.1 -path '*lib*' | head -n 1) \
  && mkdir -p /out$(dirname $LIBGCC) && cp $LIBGCC /out$LIBGCC

# Stage 2: the Dart AOT runtime and the bundle only. No SDK, no source.
FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /out/ /
WORKDIR /app

# Config comes from env variables. The image has no .env file.
EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
  CMD ["/app/bin/health"]
CMD ["/app/bin/server"]

Delete the public lines when your app has no public folder.

Check the start log in the container. It must end with (native):

Server started at http://[::]:3000 with 8 isolate (native)

Without the libgcc_s lines, the native HTTP server does not load. Dox then stops at start with dox_http_native did not load and the reason.

dox build, not dart compile exe

dox_orm bundles SQLite with a build hook. dart compile exe does not run build hooks and stops with an error. dox build runs dart build cli, which does. It puts the native libraries in build/bundle/lib, next to bin/server.

Keep the build context small:

.dockerignore
.dart_tool/
.git/
.github/
.idea/
build/
storage/
.env
.env.*
!.env.example
Dockerfile
docker-compose.yml
pubspec_overrides.yaml

4. Write the compose file

env_file: .env gives the app your .env. The lines under environment win over the file, so the app always talks to the postgres service.

docker-compose.yml
services:
  app:
    build: .
    # secrets and config come from .env, never from the image
    env_file: .env
    environment:
      APP_ENV: production
      APP_PORT: 3000
      DB_CONNECTION: postgres
      DB_HOST: postgres
      DB_PORT: 5432
    ports:
      - "3000:3000"
    volumes:
      # uploads survive a new image
      - uploads:/app/storage
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:18
    environment:
      POSTGRES_USER: ${DB_USERNAME:-postgres}
      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
      POSTGRES_DB: ${DB_NAME:-dox}
    volumes:
      - postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD", "pg_isready", "-q", "-U", "${DB_USERNAME:-postgres}"]
      interval: 5s
      retries: 10
    restart: unless-stopped

volumes:
  postgres:
  uploads:
  • depends_on with service_healthy: the app starts after Postgres answers. The app runs the migrations at start, so the database must be up.
  • ${DB_PASSWORD:?...}: compose stops with a clear message when .env has no password.
  • restart: unless-stopped: when the main isolate dies, the process exits and Docker starts it again.

5. Write the .env

.env
APP_NAME=my_app
APP_ENV=production
APP_KEY=<output of dox key:generate>
APP_PORT=3000

DB_CONNECTION=postgres
DB_NAME=my_app
DB_USERNAME=postgres
DB_PASSWORD=<a real password>

.env is in .dockerignore and .gitignore. It never goes into the image or into git.

The app now uses Postgres, so the build needs the Postgres driver. A new app skips it in pubspec.yaml. Remove the dox_postgres_native skip lines. See Skip unused drivers.

6. Build and run

docker compose up -d --build
docker compose ps

Check it

docker compose ps
NAME             STATUS
my_app-app-1       Up 30 seconds (healthy)
my_app-postgres-1  Up 35 seconds (healthy)
curl http://localhost:3000/health
{"status":"ok","checks":{"database":"ok"}}

Stop Postgres with docker compose stop postgres. After about 45 seconds, docker compose ps shows the app as unhealthy.

HTTPS

Do not add TLS to the app. Put Caddy or nginx in front, and set trustedProxies in AppConfig. See Deployment: HTTPS.

See also