Skip to content

02 · Production Dart Services

Building APIs with shelf got a server running. Running that server in production needs three more things this module covers: configuration from the environment (not hardcoded values), structured logging that a log aggregator can parse, and graceful shutdown so in-flight requests finish instead of getting dropped when the process is stopped.

Configuration from environment variables

Hardcoded ports and hosts don't survive contact with a real deployment — containers, staging vs. production, and secrets all flow through environment variables. Platform.environment is Dart's read-only view of the process environment.

class Config {
  Config({required this.port, required this.logLevel});

  final int port;
  final String logLevel;

  factory Config.fromEnv(Map<String, String> env) {
    final portStr = env['PORT'];
    final port = portStr != null ? int.tryParse(portStr) ?? 8080 : 8080;
    final logLevel = env['LOG_LEVEL'] ?? 'info';
    return Config(port: port, logLevel: logLevel);
  }
}

Taking Map<String, String> env as a parameter — rather than reading Platform.environment directly inside the factory — is the detail that matters: tests can now call Config.fromEnv({'PORT': '9000'}) without touching real process state at all.

Structured (JSON) logging

A production service's logs are read by machines (log aggregators, alerting rules) as often as by humans — one JSON object per line is the standard format, versus print()'s unstructured text.

import 'dart:convert';
import 'dart:io';

void logJson(String level, String message, [Map<String, dynamic>? fields]) {
  final entry = {
    'timestamp': DateTime.now().toUtc().toIso8601String(),
    'level': level,
    'message': message,
    ...?fields, // spread additional structured fields if provided
  };
  stdout.writeln(jsonEncode(entry));
}
{"timestamp":"2026-08-26T16:49:22.313734Z","level":"info","message":"listening","port":8087}

Using UTC (.toUtc()) for the timestamp avoids a whole category of bugs where logs from servers in different timezones (or a server vs. the aggregator collecting them) can't be correlated by time.

Graceful shutdown on SIGTERM

Container orchestrators (Docker, Kubernetes) stop a process by sending SIGTERM and then, after a grace period, SIGKILL if it hasn't exited. ProcessSignal.sigterm.watch() lets you catch the first signal and shut down cleanly — close the listening socket, let in-flight requests finish, then exit — instead of connections being severed mid-response.

import 'dart:async';
import 'dart:convert';
import 'dart:io';

void logJson(String level, String message, [Map<String, dynamic>? fields]) {
  final entry = {
    'timestamp': DateTime.now().toUtc().toIso8601String(),
    'level': level,
    'message': message,
    ...?fields,
  };
  stdout.writeln(jsonEncode(entry));
}

Future<void> main() async {
  final server = await HttpServer.bind('localhost', 8087);
  logJson('info', 'listening', {'port': server.port});
  server.listen((request) {
    request.response
      ..statusCode = 200
      ..write('ok')
      ..close();
  });

  final done = Completer<void>();
  late StreamSubscription sub;
  sub = ProcessSignal.sigterm.watch().listen((signal) async {
    logJson('info', 'shutdown initiated', {'signal': signal.toString()});
    await sub.cancel();
    await server.close(force: false); // let pending requests finish
    logJson('info', 'shutdown complete');
    done.complete();
  });

  await done.future;
}

Compiled to a native executable and sent a real SIGTERM from another process:

{"timestamp":"2026-08-26T16:49:22.313734Z","level":"info","message":"listening","port":8087}
{"timestamp":"2026-08-26T16:49:22.675617Z","level":"info","message":"shutdown initiated","signal":"SIGTERM"}
{"timestamp":"2026-08-26T16:49:22.675982Z","level":"info","message":"shutdown complete"}

The trap: ProcessSignal.watch() doesn't work the same everywhere

ProcessSignal.sigterm.watch() and .sigint.watch() work on Linux and macOS. sigterm specifically is not supported on Windows (it throws a SignalException if you try to watch it there) — only sigint (Ctrl+C) is. A service meant to run cross-platform needs to either only rely on sigint, or wrap the sigterm watch in a try/catch and accept that graceful shutdown on SIGTERM simply isn't available on that platform. This is also why dart run (which runs your script inside a wrapper process/daemon for hot-reload support) can behave differently under signals than a dart compile exe binary receiving them directly — always verify signal handling against the actual deployed artifact, not just dart run.

Health checks

A minimal but real production requirement: an endpoint an orchestrator can poll to know the service is alive and ready to receive traffic.

Response healthCheck(Request request) {
  return Response.ok(jsonEncode({'status': 'ok', 'uptime_s': _uptime.elapsedSeconds}));
}

final _uptime = Stopwatch()..start();
extension on Stopwatch {
  int get elapsedSeconds => elapsed.inSeconds;
}

Wire this at GET /health alongside the real routes from module 02 of Level 3 — orchestrators typically distinguish liveness (is the process up at all) from readiness (is it able to serve traffic, e.g. is its database connection established); a single /health endpoint is a fine start, two endpoints is the more complete production pattern.

Cheat sheet

Concept Meaning
Config.fromEnv(Map<String,String>) Take env as a parameter, not Platform.environment directly — testable
JSON-per-line logging Machine-parseable; include a UTC timestamp on every entry
ProcessSignal.sigterm.watch() Catch shutdown signals to close cleanly instead of being killed
server.close(force: false) Stop accepting new connections, let in-flight ones finish
sigterm on Windows Not supported — only sigint works there
dart run vs dart compile exe Signal delivery can differ — test against the real deployed artifact
/health endpoint Minimal liveness/readiness check for orchestrators

How It Actually Works

Platform.environment is a read-only snapshot of the process's environment variables taken once, at isolate startup, from the underlying OS process — it's populated via dart:io's FFI-level call into the C runtime's environment table, not re-read on every access, which is why mutating the OS environment after your program starts (e.g., another process changing a variable) has no effect on values already read through Platform.environment.

ProcessSignal.watch() works by registering a signal handler with the underlying OS through dart:io, and delivering the signal to your isolate as a Stream event, translated from the OS's native asynchronous-signal mechanism into Dart's cooperative event loop — the OS calls into the Dart VM's signal-handling C code the instant the signal arrives, but that code can't safely run arbitrary Dart immediately (Dart code can only run when the VM is at a safe point), so it defers by scheduling an event that the isolate's event loop picks up on its next iteration. This scheduling detour is exactly why signal handling behaves differently across platforms — POSIX signals (SIGTERM, SIGINT on Linux/macOS) map cleanly onto this model, while Windows has no direct signal equivalent and Dart's implementation there is layered on Windows console control events, which is why SIGTERM-style graceful shutdown is far less reliable on Windows than on POSIX systems.

Graceful shutdown itself is a coordination problem solved entirely in your own code, not by the VM: once the signal stream fires, your handler typically stops accepting new connections (closing the HttpServer's listen socket), then waits for in-flight requests to finish — this waiting is just ordinary Future/completion tracking (e.g., a counter of active requests plus a Completer that resolves when it hits zero), not a built-in "drain" primitive.

Exercise

Write a GracefulServer class that wraps HttpServer.bind, exposes a Future<void> waitForShutdown() that completes when SIGINT is received, and a Future<void> shutdown() that closes the server and logs (via logJson) both the start and completion of shutdown. Add a GET /health route that returns {"status": "ok", "uptime_s": N} using a Stopwatch started at server creation. Test it manually by running the compiled executable and sending it Ctrl+C, confirming the shutdown log lines appear before the process exits.