Production checklist
Check each item before you deploy. Each item says what to check, how to do it, and where to read more.
Items marked Dox limit are things Dox does not do for you. You must handle them outside Dox.
Secrets and config
| Check | How | Read more |
|---|---|---|
APP_KEY is set, long and secret |
dox key:generate, or a 32 character random value in the real env. It signs JWT tokens and encrypts cookies. Never use the value from .env.example. |
Encryption |
APP_ENV=production |
With it, an empty APP_KEY stops the app at start (StateError). Without it, Dox only logs a warning and runs with an empty key. |
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 |
KEY=abc # note gives the value abc # note. Put comments on their own line. |
Troubleshooting |
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 |
| You can not revoke one token | Dox limit. There is no token list on the server. To log out everyone, change APP_KEY. |
Authentication |
| Passwords are hashed | user.password = Hash.make(input) (bcrypt). 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 throws on input over 72 bytes. 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: '*' and credentials: true with enabled: false. When you turn CORS on, set a real origin first. |
CORS |
| Rate limiting | Dox limit. Dox has no rate limiter. Do it at the proxy. See below. | Deployment |
| gzip at the proxy | Keep compress: false. Turn on gzip in Caddy (encode gzip) or nginx (gzip on;). |
Performance |
| OpenAPI docs | Off in production by default (on only for APP_ENV 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 is on | The start line says (native). When it says (dart:io), look for the [dox] warning: dox_http_native did not load line. |
Native server |
| Native database driver is on | No [dox] warning: dox_postgres_native did not load (or mysql) line at start. Do not set DOX_DB_DRIVER=dart. Postgres with TLS uses the Dart driver. |
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. WebSocket: WebsocketRedisAdapter. Socket.IO: a Redis adapter and transports: ['websocket']. |
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(migrations: migrations).migrate() in bin/server.dart before startServer(). Or run dart run bin/migrate.dart run (or dox migrate) in CI. Do one, not both at the same time on many machines. |
Migration |
| Migrations use the right database | dox_migration reads .env when the file exists, and ignores the real env then. Do not ship a .env next to the binary, or give Migrator(connection: ...) your own pool. |
Troubleshooting |
| 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} is per isolate. Total = isolates x pool size. Keep it under the database max_connections. |
Database |
| Query logging is off | The new project's database.dart has debug: true on every connection, which prints every query. Set debug: false in production. |
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, every 20 ms. A hard kill can lose the last lines. Turn it off when the proxy already logs requests. |
Performance |
| Errors go to your tracker | Set AppConfig.errorHandler. Any non-HTTP error is passed to it. The client gets 500 with the error text, so do not put secrets in exception messages. |
HTTP exceptions |
Health and shutdown
| Check | How | Read more |
|---|---|---|
| Health route | Dox limit. Dox has no built-in health route. Add one that asks the database: Route.get('/health', healthController.check) with await QueryBuilder.query('select 1');. |
Docker |
| 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. Close connections in a DoxShutdown service. |
Deployment |
| Zero downtime deploys | Run two processes (or machines) behind the proxy. Restart one at a time. | Deployment |
Rate limit at the proxy
Dox has no rate limiter. Add one in the proxy.
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.