Skip to content

Deploy your app

You’ll turn a development server into a production deployment: settings from the environment, a compiled binary in a small container, graceful shutdown, health and metrics endpoints, correct client IPs behind a proxy, and optionally several isolates. Then you’ll deploy it, for example on the gisila panel.

  • A working gisila app.
  • Docker, or a host with the Dart SDK for building.
import 'dart:io';
import 'package:gisila/gisila.dart';
Future<void> main() async {
final env = Platform.environment;
final app = GisilaApp(
config: AppConfig(
logFormat: LogFormat.json,
logRequestBody: false,
authenticator: JwtAuthenticator(
key: JwtKey.hmac(env['JWT_SECRET']!),
algorithms: {'HS256'},
issuer: 'notes-api',
),
cors: CorsConfig(allowedOrigins: {env['FRONTEND_ORIGIN']!}, allowCredentials: true),
trustedProxies: {'127.0.0.1', '::1', ...?env['TRUSTED_PROXIES']?.split(',')},
rateLimit: const RateLimitConfig(requestsPerMinute: 600),
maxRequestBodyBytes: 2 * 1024 * 1024,
requestTimeout: const Duration(seconds: 30),
healthCheckPath: '/healthz',
metrics: MetricsConfig(bearerToken: env['METRICS_TOKEN']),
),
);
configureApp(app); // services, singletons, controllers
await app.start(
port: int.parse(env['PORT'] ?? '8080'),
stopOnSignals: true,
drainTimeout: const Duration(seconds: 20),
);
}

Checklist:

  • Secrets come from the environment, never from source or annotations.
  • maxRequestBodyBytes and requestTimeout are unlimited by default; set them.
  • trustedProxies must include your load balancer when it is not on the same host, or every client shares one rate-limit bucket. Never trust all addresses.
  • Security headers are on by default. If you serve HTML, set SecurityHeadersConfig(htmlContentSecurityPolicy: ...).
  • Compression: leave compression off when the proxy compresses.
  • stopOnSignals: true makes SIGTERM (from Docker, systemd or the panel) stop accepting connections, drain in-flight requests, run onStop hooks, dispose singletons and close the database pool before exiting. Keep drainTimeout below your platform’s kill timeout.

Generated .g.dart files must exist at build time: commit them, or run build_runner in the build. Then compile an AOT binary:

Terminal window
dart run build_runner build --delete-conflicting-outputs
dart compile exe bin/server.dart -o build/server
./build/server

The binary has no Dart SDK dependency and starts in milliseconds.

FROM dart:stable AS build
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
COPY . .
RUN dart run build_runner build --delete-conflicting-outputs
RUN dart compile exe bin/server.dart -o /app/server
FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /app/server /app/server
EXPOSE 8080
CMD ["/app/server"]

If you serve static files with app.serveStatic, copy that directory into the image too.

healthCheckPath answers GET /healthz before auth, metrics and the database. Add a readiness check so an instance that lost its database is taken out of rotation:

AppConfig(
healthCheckPath: '/healthz',
healthCheck: orm.isHealthy, // GisilaOrmDatabaseProvider from gisila_orm/web.dart
)

It returns 200 {"status":"ok"} or 503 {"status":"unavailable"}.

With MetricsConfig, Prometheus scrapes /metrics. Without bearerToken or allow, only direct loopback connections may read it (a reverse proxy on the same host doesn’t count), so a sidecar agent works and the public doesn’t see it.

A Dart isolate uses one core. To use more, start one app per isolate on a shared port, with a MetricsHub so scrapes see all of them:

Future<void> main() async {
final hub = MetricsHub();
for (var i = 1; i < Platform.numberOfProcessors; i++) {
await Isolate.spawn(serve, hub.sendPort);
}
await serve(hub.sendPort);
}
Future<void> serve(SendPort hub) async {
final app = GisilaApp(config: buildConfig(metrics: MetricsConfig(hub: hub)));
configureApp(app);
await app.start(port: 8080, shared: true, stopOnSignals: true);
}

Each isolate has its own memory: in-memory caches and rate-limit buckets are per isolate. Use RedisCacheProvider and RedisRateLimitStore from gisila_redis when limits and cached data must be shared.

The gisila panel is a self-hosted platform for running apps like this one. It builds and runs your app from a git repository, a binary or a ZIP upload, manages environment variables, domains and cron jobs, and shows runtime logs and metrics. Set PORT, DATABASE_URL, JWT_SECRET and the rest as app environment variables; the JSON logs your app writes to stdout show up in the panel’s runtime logs. See the panel documentation for the deployment steps.

Terminal window
docker build -t notes-api .
docker run --rm -p 8080:8080 -e JWT_SECRET=$(openssl rand -base64 48) \
-e FRONTEND_ORIGIN=https://app.example.com notes-api &
curl -s localhost:8080/healthz # {"status":"ok"}
docker stop $(docker ps -q --filter ancestor=notes-api)
# logs: "gisila: SIGTERM received, shutting down", then the process exits after draining