Daravelv1.0.0
ENID GitHub

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-instances mounts the instance's Unix socket under /cloudsql/; DB_HOST set to that directory connects through it (as in Laravel). Keep DB_SSLMODE=disable for the socket; with a private IP instead, set DB_HOST to the IP and DB_SSLMODE=require.
  • Connections. Each isolate has its own pool, so the database sees up to instances × APP_ISOLATES × DB_POOL_SIZE connections. Cap --max-instances to fit the instance's max_connections.
  • Isolates. Match APP_ISOLATES to --cpu.
  • Shutdown. Cloud Run sends SIGTERM and kills the container 10 seconds later; SHUTDOWN_TIMEOUT=8s drains 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.

Maintenance mode#

dart run bin/dartisan.dart down --retry=60 --with-secret   # prints the bypass path
dart run bin/dartisan.dart up

down writes storage/framework/down in Laravel's format, and running servers answer 503 {"message": "Service Unavailable"} (with Retry-After and Refresh when given) within a second, without a restart. The health routes (/up, /up/ready) and --except=/path stay available. Visiting /<secret> sets a daravel_maintenance cookie (HMAC-signed, valid for 12 hours, as Laravel's) that lets you use the application while it is down. With several servers, the file must be on storage they share, or run down on each.

Production checklist#

  • APP_ENV=production, APP_DEBUG=false (boot fails otherwise unless explicitly allowed), a 32-byte APP_KEY from a secret manager.
  • dart run bin/dartisan.dart doctor --production checks configuration, database reachability, public /metrics, QUEUE_CONNECTION=sync and more; run it in the container entrypoint.
  • Health endpoints: /up (liveness) and /up/ready (readiness).
  • Run queue:work and schedule:work as separate processes.
  • Logs: LOG_STACK=json for structured logs; secrets are redacted.