Skip to content

Production checklist

This page lists what to check before you deploy a Dox app.

Items marked Dox limit are things Dox does not do for you. You must handle them outside Dox.

Secrets and config

If APP_ENV is empty or not set, Dox treats the app as production: /docs is off and error details are hidden. Set APP_ENV=development in your local .env and in CI.

Check How Read more
APP_KEY is set, 32 characters and secret dox key:generate, or a random value of exactly 32 characters in the real env. It signs JWT tokens and encrypts cookies. Never use the value from .env.example. Encryption
APP_ENV=production Set it, or leave it empty. In production, a bad APP_KEY stops the app at start with a StateError: an empty key, a key that is not exactly 32 characters, or a public sample key. Only with APP_ENV development, local, test or testing does Dox log a warning instead. Env reference
The env is complete Run dox env:check in CI or before the deploy. It exits with code 1 when a key is missing or bad. CLI
No secrets in the image or in git .env in .dockerignore and .gitignore. Pass values as env vars (docker run -e, env_file:, systemd EnvironmentFile=). dox build copies .env.example only. Docker
No comments after values in .env The app strips # note, but migrations keep it in the value. Put comments on their own line. Env reference

Auth and passwords

Check How Read more
JWT tokens expire JwtAuthDriver.expiresIn is 1 hour by default. Pick a time that fits your app, like JwtAuthDriver(secret: SecretKey(env.appKey), expiresIn: Duration(hours: 12)). Authentication
A JWT works until it expires By default there is no token list on the server. Turn on revocation: true in JwtAuthDriver to use revokeToken and revokeAllTokens. Or use AccessTokenAuthDriver: its tokens live in a table and you can revoke each one. To log out everyone, change APP_KEY with no previousKeys. Authentication
Passwords are hashed user.password = await Hash.makeAsync(input) (bcrypt). In a request, use makeAsync: it runs bcrypt in another isolate, so other requests do not wait. Never store or encrypt a plain password. Hashing
The hash is never sent List<String> get hidden => <String>['password']; in the user model. The JWT payload is user.toJson(), and anyone can read it. Authentication
Passwords have a max length bcrypt takes at most 72 bytes. Hash.make and Hash.makeAsync throw an ArgumentError on longer input. Add @MaxLength(72) to the password field of the DTO. It counts characters, so non-ASCII text can still pass 72 bytes. Troubleshooting

HTTP and network

Check How Read more
HTTPS at the proxy Dox has no TLS server. Run Caddy or nginx in front. Keep the app port private. Deployment
trustedProxies is set behind a proxy AppConfig(trustedProxies: <String>['127.0.0.1', '::1']), or the proxy network, like '172.16.0.0/12'. Without it, req.ip() is the proxy and req.scheme() is http. Do not use ['*'] when the app port is public. App config
Body limits are on Keep maxBodySize (10 MB) and maxMultipartSize (100 MB), or lower them. Set the same limit in the proxy: client_max_body_size 100m; Request
CORS has real origins CORSConfig(origin: 'https://my.app'), or a list like <String>['https://my.app', 'https://admin.my.app']. With a list, Dox sends back only the request origin when it is in the list. CORS
No credentials: true with origin: '*' The new project's cors.dart has origin: <String>['http://localhost:3000'], credentials: true and enabled: false. When you turn CORS on, put your real origins in the list. CORS
Rate limiting Add RateLimit(...) to login and other busy routes. Use a Redis cache store, so all servers share the counts. Or limit at the proxy, see below. Rate limiting
Security headers Add const SecurityHeaders() to globalMiddleware. Security headers
gzip at the proxy Keep compress: false. Turn on gzip in Caddy (encode gzip) or nginx (gzip on;). Performance
OpenAPI docs Off unless APP_ENV is development or local. Do not set enabled: true for a private API. OpenAPI

Build and run

Check How Read more
AOT binary, not dart run dox build, then run build/bundle/bin/server. Keep bin/ and lib/ together. Not dart compile exe. Deployment
Native server loads The app starts and the start line says (native). When the library does not load, the app stops with dox_http_native did not load and the fix. Native server
Native database driver loads The app starts and the database works. When the library does not load, the app stops with dox_postgres_native did not load (or mysql) and the fix. Postgres native
Isolates = cores Keep the default totalIsolate (Platform.numberOfProcessors), or set APP_TOTAL_ISOLATE to the cores the container really gets. Use fewer when the database runs on the same machine. Isolate
Shared state across isolates Cache: redis, not memory. Socket.IO: transports: ['websocket'] on the client, and a Redis adapter with many servers. Isolate
A supervisor restarts the process Docker restart: unless-stopped, or systemd Restart=always. Dox restarts a dead worker isolate, but not the main isolate. Deployment
Writable temp folder Uploads over 1 MB go to a temp file. A FROM scratch image needs /tmp with mode 1777. Docker
Uploads live on a volume or S3 The local disk writes to storage/. Mount it as a volume, or use the s3 disk. File storage

Database

Check How Read more
Postgres or MySQL for a busy app SQLite has one connection per isolate, and a query blocks the isolate. Use it for small apps only. With SQLite use an absolute file path, never :memory: with many isolates. Database
Migrations run once per deploy The new project runs Migrator(...).migrate() in bin/server.dart before startServer(). Or run dox migrate (same as dart run bin/migrate.dart run) once in CI. With many machines, run migrations in one place only, so two machines do not migrate at the same time. Migration
Migrations use the right database dox_migration reads .env and the real env. A real env var wins, but a key that is only in an old .env is still used. Do not ship a .env next to the binary, or give Migrator(config: ...) your own config. Migration
Small MySQL migrations MySQL saves CREATE, ALTER and DROP at once. A failed migration can leave half the changes. One table change per migration. Migration
Pool size fits the database extra: {'maxConnectionCount': 10}. The Postgres and MySQL drivers share one pool per process, so the total is the pool size, not isolates x pool size. Keep it under the database max_connections. Database
Query logging is off debug: true prints every query. The new project's database.dart sets debug only when APP_ENV=development. Database
Backups Dox limit. Dox has no backup tool. Use pg_dump, mysqldump, managed database snapshots, or a copy of the SQLite file (with the -wal file, or sqlite3 app.db ".backup x.db"). Test a restore.

Logs and errors

Check How Read more
JSON logs LoggerConfig(enabled: true, prettyPrint: false). The new project sets prettyPrint only when APP_ENV=development. App config
Log level Set LOG_LEVEL=warn to print only warnings and errors. App config
Access logs in one place LogMiddleware writes one line per request. It writes them in batches, every 20 ms, so a hard kill can lose the last lines. The new project turns it off when APP_ENV=production, because the proxy already logs requests. Performance
Errors go to your tracker Extend ExceptionHandler and set AppConfig.exceptionHandler. Any non-HTTP error is passed to its report method. The client gets 500 with server_error. The error text is sent only when APP_ENV is development or local. Error handling

Health and shutdown

Check How Read more
Health route Add Route.health('/health', checks: ...) with a database check, so the load balancer knows when the app can not serve. Health check
Graceful shutdown On SIGTERM or SIGINT, Dox stops taking connections and lets open requests finish, in every isolate. Set shutdownTimeout below the kill timeout of your platform. The ORMProvider closes the database in its shutdown(), and Dox closes the cache stores. Close your own connections in the shutdown() of a provider. Deployment
Zero downtime deploys Run two processes (or machines) behind the proxy. Restart one at a time. Deployment

Rate limit at the proxy

You can also limit at the proxy. Then bad clients never reach the app.

nginx:

# in http { }
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

# in server { location / { } }
limit_req zone=api burst=20 nodelay;
limit_req_status 429;

Caddy needs the caddy-ratelimit plugin (build Caddy with xcaddy build --with github.com/mholt/caddy-ratelimit):

example.com {
  rate_limit {
    zone api {
      key    {remote_host}
      events 100
      window 1m
    }
  }
  reverse_proxy 127.0.0.1:3000
}

Note

The proxy must see the real client IP. When there is a load balancer in front of the proxy, set the real IP module of the proxy first.