Deployment
Native bundle#
dart run bin/dartisan.dart build # → build/server/bundle
dart run bin/dartisan.dart build --with-cli # also bin/dartisan in the bundle
dartisan build runs dart build cli, which compiles AOT and runs build
hooks: SQLite is downloaded (hash-verified) and placed in bundle/lib/.
Copy the whole bundle/ directory to the server and start bin/server.
(dart compile exe cannot be used: it refuses packages with build hooks.)
Docker#
dartisan build --docker writes a multi-stage Dockerfile (Dart SDK
builder, debian:bookworm-slim runtime, non-root user, no system SQLite
needed) and .dockerignore. The api template also ships
docker-compose.yml with PostgreSQL 16. The reference app in this repository ships the same
runtime stage in apps/daravel_starter/Dockerfile, built from the repository
root (docker build -f apps/daravel_starter/Dockerfile .) because it is part
of the pub workspace.
Ports and container platforms#
The server listens on APP_HOST:APP_PORT (127.0.0.1:8000 by default). On
Cloud Run, Railway, Render, Fly.io and similar platforms, which inject a
PORT variable, Daravel uses that port when APP_PORT is not set and then
binds 0.0.0.0 so the platform can reach it; the generated Dockerfile sets
PORT=8080 as a default the platform can override. SIGTERM drains
in-flight requests within SHUTDOWN_TIMEOUT.
Concurrency#
APP_ISOLATES=N (or serve --isolates=N) starts a supervisor and N worker
isolates sharing one port. Per-process state (rate limits, the process
cache store, locks, metrics) goes through the coordinator. Start with one
isolate per vCPU and measure (see Performance).
SQLite :memory: cannot be shared between isolates; use a file or
PostgreSQL.
Topology#
Run Daravel behind a reverse proxy (Caddy, nginx) that terminates TLS and compresses responses, and trust that proxy explicitly:
TrustProxiesMiddleware(proxies: ['10.0.0.0/8'])
Without it, X-Forwarded-* headers are ignored.
Secrets: encrypted environment files#
dartisan env:encrypt writes .env.encrypted (or .env.production.encrypted
with --env=production) in the format of php artisan env:encrypt, so either
framework can read the file the other wrote. The encrypted file can be
committed; the key stays in your secret manager:
dart run bin/dartisan.dart env:encrypt --env=production # prints a new key
dart run bin/dartisan.dart env:encrypt --readable # names stay visible in diffs
dart run bin/dartisan.dart env:decrypt --key=base64:... # back to .env
At runtime, set DARAVEL_ENV_ENCRYPTION_KEY (or LARAVEL_ENV_ENCRYPTION_KEY)
and the application decrypts .env.encrypted and .env.{APP_ENV}.encrypted
in memory at boot; no plain .env is written to disk. The generated
.dockerignore lets encrypted files into the image. A wrong key stops the
boot with a clear error, and dartisan doctor warns when an encrypted file
has no key.
dartisan env:obfuscate --only=API_TOKEN writes a Dart class whose values are
XOR-masked, so they do not show up in strings bin/server (useful for keys
compiled into a client). This is obfuscation, not encryption: the mask ships
in the same binary, so it does not replace env:encrypt for real secrets.
One process: embedded queue and scheduler#
Small deployments (one VPS, one container) can run HTTP, a queue worker and the scheduler in one process:
dart run bin/dartisan.dart serve --embed-queue --embed-scheduler
SERVER_EMBED_QUEUE=true SERVER_EMBED_SCHEDULER=true bin/server # same, from the bundle
The queue worker (on SERVER_EMBED_QUEUES, default the default queue) and
the scheduler each run in their own isolate, so jobs never block the HTTP
event loop. A crashed isolate is respawned; on SIGTERM the current job or
tick finishes before the process exits. Scheduled console commands run when
the server is started with dartisan serve; started as bin/server, the
embedded scheduler runs jobs, closures and exec tasks. Embedding needs a
file or PostgreSQL database (an in-memory SQLite database is refused). For
several instances, keep one embedded scheduler (or run schedule:work
separately) and scale queue workers on their own.
Google Cloud Run#
The generated Dockerfile runs on Cloud Run unchanged: it listens on the
injected PORT, logs JSON to stdout (Cloud Logging parses it) and ships
bin/dartisan next to bin/server for one-off commands.
REGION=asia-southeast2
IMAGE=$REGION-docker.pkg.dev/$PROJECT/daravel/api:$(git rev-parse --short HEAD)
SQL=$PROJECT:$REGION:main # Cloud SQL for PostgreSQL instance
gcloud artifacts repositories create daravel --repository-format=docker --location=$REGION
gcloud builds submit --tag $IMAGE
# Secrets once: the APP_KEY from `dartisan key:generate --show` and the DB password
printf '%s' 'base64:...' | gcloud secrets create app-key --data-file=-
printf '%s' '...' | gcloud secrets create db-password --data-file=-
gcloud run deploy api --image $IMAGE --region $REGION \
--add-cloudsql-instances $SQL \
--set-env-vars "DB_CONNECTION=pgsql,DB_HOST=/cloudsql/$SQL,DB_DATABASE=app,DB_USERNAME=app,APP_ISOLATES=2,DB_POOL_SIZE=5,SHUTDOWN_TIMEOUT=8s,QUEUE_CONNECTION=database" \
--set-secrets "APP_KEY=app-key:latest,DB_PASSWORD=db-password:latest" \
--cpu 2 --memory 512Mi --concurrency 200
- Cloud SQL.
--add-cloudsql-instancesmounts the instance's Unix socket under/cloudsql/;DB_HOSTset to that directory connects through it (as in Laravel). KeepDB_SSLMODE=disablefor the socket; with a private IP instead, setDB_HOSTto the IP andDB_SSLMODE=require. - Connections. Each isolate has its own pool, so the database sees up to
instances × APP_ISOLATES × DB_POOL_SIZEconnections. Cap--max-instancesto fit the instance'smax_connections. - Isolates. Match
APP_ISOLATESto--cpu. - Shutdown. Cloud Run sends
SIGTERMand kills the container 10 seconds later;SHUTDOWN_TIMEOUT=8sdrains in-flight requests inside that window. - Probes. Point the startup probe at
/up/ready(it checks the database) and the liveness probe at/up, in the service YAML or the console.
Migrations run as a Cloud Run job from the same image, before traffic moves to the new revision:
gcloud run jobs deploy migrate --image $IMAGE --region $REGION \
--set-cloudsql-instances $SQL \
--set-env-vars "DB_CONNECTION=pgsql,DB_HOST=/cloudsql/$SQL,DB_DATABASE=app,DB_USERNAME=app" \
--set-secrets "APP_KEY=app-key:latest,DB_PASSWORD=db-password:latest" \
--command /app/bin/dartisan --args migrate
gcloud run jobs execute migrate --region $REGION --wait
Cloud Run services only run while they handle requests, so background work
goes to jobs started by Cloud Scheduler, built the same way: the scheduler
as --args schedule:run every minute, and queue workers as
--args queue:work,--stop-when-empty,--max-time=540 every few minutes. For
a steady queue, run queue:work on a VM instead (next section).
VPS with systemd and Caddy#
Build the bundle on a machine with the same OS and CPU architecture as the
server (or in CI), with bin/dartisan included:
dart run bin/dartisan.dart build --with-cli
RELEASE=/srv/api/releases/$(date +%Y%m%d%H%M%S)
rsync -a build/server/bundle/ deploy@vps:$RELEASE/
ssh deploy@vps "cd $RELEASE && ln -s /srv/api/shared/.env .env \
&& ln -s /srv/api/shared/storage storage \
&& ./bin/dartisan migrate && ./bin/dartisan doctor --production \
&& ln -sfn $RELEASE /srv/api/current \
&& sudo systemctl restart api api-queue api-schedule"
The application reads .env from its working directory, so each release
links the shared .env and storage/. Rolling back means pointing
current at the previous release and restarting.
/etc/systemd/system/api.service:
[Unit]
Description=Daravel API
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
User=daravel
WorkingDirectory=/srv/api/current
ExecStart=/srv/api/current/bin/server
Environment=APP_ISOLATES=4 SHUTDOWN_TIMEOUT=25s
Restart=always
RestartSec=2
KillSignal=SIGTERM
TimeoutStopSec=30
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/srv/api/shared/storage
PrivateTmp=true
[Install]
WantedBy=multi-user.target
The workers are the same unit with another command; both stop cleanly on
SIGTERM after the current job:
# /etc/systemd/system/api-queue.service ([Unit]/[Install] as above)
[Service]
User=daravel
WorkingDirectory=/srv/api/current
ExecStart=/srv/api/current/bin/dartisan queue:work --max-time=3600
Restart=always
TimeoutStopSec=90
# /etc/systemd/system/api-schedule.service
[Service]
User=daravel
WorkingDirectory=/srv/api/current
ExecStart=/srv/api/current/bin/dartisan schedule:work
Restart=always
--max-time restarts the queue worker every hour, and dartisan queue:restart asks running workers to exit after their current job (for
example at the end of a deploy). Set TimeoutStopSec above the longest job.
Caddy terminates TLS (certificates are automatic) and compresses responses.
/etc/caddy/Caddyfile:
api.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8000 {
health_uri /up
}
}
Keep APP_HOST=127.0.0.1 (the default) so the server is only reachable
through Caddy, and trust it with TrustProxiesMiddleware(proxies: ['127.0.0.1/32']). To move a Laravel application over endpoint by endpoint
behind the same Caddy, see Migrating from Laravel.
Production checklist#
APP_ENV=production,APP_DEBUG=false(boot fails otherwise unless explicitly allowed), a 32-byteAPP_KEYfrom a secret manager.dart run bin/dartisan.dart doctor --productionchecks configuration, database reachability, public/metrics,QUEUE_CONNECTION=syncand more; run it in the container entrypoint.- Health endpoints:
/up(liveness) and/up/ready(readiness). - Run
queue:workandschedule:workas separate processes. - Logs:
LOG_STACK=jsonfor structured logs; secrets are redacted.