Guide
A Comprehensive Agent's Guide to Laravel CloudProvision and deploy
· Ed Grosvenor
Most provisioning failures we hit came from the same assumption: that a resource exists and works once the call creating it returns. On Cloud, creation is asynchronous, attachment is a separate step, and a deployment can report success while the application crash-loops.
Applications and environments
- Creating an application also creates an environment named
production, about two seconds after the call returns. The create response saysdefaultEnvironment: null. If you then createproductionyourself, you getEnvironmentNameTaken. Wait, re-read the application, and adopt the environment it made. - The application's branch is ignored on create. Through the API,
POST /api/applicationsputs the default environment on the repository's default branch whatever you send. To track another branch, create an environment withPOST /api/applications/{application}/environmentsand abranch. - The runtime is detected once, when the environment is created, from that environment's branch. It is never detected again. A wrong guess means deleting and recreating the environment.
- The start command is fixed at creation too. Afterwards it can only be changed in the dashboard. The build command can be changed with
environment:update --build-commandor the API. - Changing an environment's branch does not deploy it. Deploy explicitly afterwards.
Resources are not ready when the call returns
Databases, caches and buckets come back from their create call while still being provisioned. Attaching one too early fails with a 422 whose error key is global, not a field name. The message, in this case, is "The selected database is still being provisioned. Please wait a few seconds."
Poll each resource's status until it is available, with a bounded backoff, before attaching it. Some more of the same:
- New bucket keys return
401for about five seconds after they are created. - Compound creates can half-succeed. A database cluster can be created while the database inside it fails. Read back everything you meant to create.
- Database names must be unique across the organization, not just within a cluster. A retry that repeats a create gets a
422, and the first create is still billing.
Attaching resources
cloud environment:update <environment-id> --database-id=<database-id> --force -n
cloud environment:update <environment-id> --cache-id=<cache-id> --force -n
cloud environment:update <environment-id> --websocket-application-id=<websocket-application-id> --force -n
These work from CLI v0.5.2. Older notes, and v0.5.0 itself, said attachment was dashboard-only, because the read-back showed null even when the write had worked. Pass an empty string to detach.
Buckets have no CLI attach. Use the API: PATCH /api/environments/{id} with filesystem_keys, where each item has id, disk and is_default_disk. Nothing in the CLI or API reports which environment a bucket is attached to afterwards, so record it yourself.
Bucket details worth knowing:
bucket:createneeds--name,--region,--visibility,--key-name,--key-permissionand--allowed-origins. The last is required even for a private bucket, and older CLIs asked for each one in turn as the previous error was fixed.- Buckets are S3-compatible object storage. Visibility applies to the whole bucket, and per-object ACLs are rejected.
- Set
ContentTypeon the first write. The edge caches whatever type it first sees. - If uploads fail checksum validation, configure the AWS SDK to send checksums only
when_required. - New WebSocket applications start with no allowed origins, which denies every connection after a clean handshake.
websocket-application:updatecould not set origins when we tried.
Required packages
The build only contains what Composer installs, and your test suite, running on SQLite with array and sync drivers, will not notice what is missing.
league/flysystem-aws-s3-v3is required for buckets. Cloud refuses to deploy an application with a bucket attached without it.aws/aws-sdk-phpis required for managed queues. The Flysystem package pulls it in.- Managed queues need Laravel 11.55.0, 12.63.0 or 13.19.0 or later. Applications on older versions failed to deploy with a managed queue attached.
- Test against the production database engine. PostgreSQL behaviour does not show up on SQLite.
Managed queues
Always use managed queues rather than a worker process you run yourself.
cloud instance:sizes --json -n
cloud managed-queue:create <environment-id> --name=default --size=<size> --json -n --fields=id,name
cloud managed-queue:set-default <queue-instance-id> --json -n
--nameand--sizeare required when you are non-interactive. Sizes come frominstance:sizes, such as themq.flex.*family.- A queue name is a provisioned resource. Dispatching to any other name, with
->onQueue('emails'), fails because that queue does not exist. Create every queue the application dispatches to. - New queues default to
minReplicas: 1. One worker is always running and always billed. Read the queue back after creating it and decide deliberately whether it should scale to zero. The 8 GB size never scales to zero, which is a floor of about $149 a month. - Options can be dropped rather than rejected.
--visibility-timeoutwas ignored when we set it, and there is nomanaged-queue:update. Read every queue's settings back frominstance:list <environment-id>instead of assuming them. The visibility timeout we read on live queues was 180 seconds. - Through the API, the body that worked after seven rounds of
422responses was:type: managed_queue, asize,scaling_type: custom,min_replicas: 0, amax_replicasyour plan allows (3 on Hobby, 25 on the higher plan we used), andbackground_processeswith one worker whoseconfig.connectioniscloud. The specification disagreed with the live API on several of these. - Managed queues are SQS underneath. Delays and backoffs over 900 seconds are rejected, and messages are limited to 256 KiB.
- Flex workers can be stopped at any time with 90 seconds' grace. A job still running is terminated and redelivered. With
tries = 1, the redelivery counts as a second attempt and the job fails without running.ShouldBeUniqueprotects dispatch, not redelivery. Keep jobs on flex workers under 90 seconds. - A worker without the database or cache it needs crash-loops, while the deployment still reports success.
The scheduler and other instances
The scheduler is a flag on the application instance, not a separate process:
cloud instance:list <environment-id> --json -n --fields=id,type,name
cloud instance:update <app-instance-id> --uses-scheduler=true --force --json -n
instance:createdefaults--typetoservice. Pass--type=workerwhen you mean a worker.background-process:deletehas been seen to exit successfully without deleting anything. Confirm every delete with a fresh listing.- Application instances cannot have zero minimum replicas. Scale-to-zero is a separate setting. See Hibernation and idle cost.
Deploying
Merging to a push-to-deploy branch is a production deploy. Push-to-deploy is on by default. Some repositories also call a deploy hook from CI on every push to main. One of our agents, authorised to "merge, but no deploys", shipped production by merging. Before merging anything, check the environment's usesPushToDeploy and look for deploy hooks in the repository's workflows.
To deploy without a checkout of the application:
cloud deploy <application-id> <environment-name> --no-wait --json -n
cloud deployment:get <deployment-id> --json -n --fields=id,status
cloud deployrefuses to run in a directory without a git remote, though nothing is pushed. The scratch directory on Work in the right organization has one.- Statuses progress through
build.running,deployment.runninganddeployment.succeeded.build.succeededis not the end. Poll untildeployment.succeededor a failure. deploy:monitorafterdeploy -nhas failed after about five minutes for us. Polldeployment:getinstead.- Confirm the result with
environment:get: its current deployment should have moved to the one you started. - When a deployment fails, read its log first. The status only says something like
deploy.app.crashing. The reason is inGET /api/deployments/{id}/logs, which the CLI does not wrap. We spent fifteen deploys guessing before we found it. - A green deploy proves only that
/answered the health check. Exercise the routes that matter.
Inside the build
- Builds are capped at 15 minutes.
- The image is Debian 12 on arm64. You are not root, so there is no
apt.curl,git,unzip,tarandxzare present.jqis not. - Only files written during the build, inside the application directory, survive. Everything written after deploy, or outside
/var/www/html, disappears. - Private Composer repositories: the only authentication that worked for us was inline in the build command, because neither a Cloud
COMPOSER_AUTHvariable nor CI secrets reached the build. Build commands are printed in clear by several listings, so use a read-only token scoped to that repository, and rotate it if a listing ever reaches a transcript. A build that seems to pass may be reusing a cached download rather than proving the credential works.
Runtime traps
- Never size workloads from
nprocorfree. They report the host. Read/sys/fs/cgroup/cpu.maxand/sys/fs/cgroup/memory.max. Our 2 GB instances had one vCPU. - The container network is IPv6-only. A non-PHP server must bind
[::]on$PORT. Binding0.0.0.0surfaces only asdeploy.app.crashing. X-Forwarded-Protosayshttpon HTTPS requests. Do not use it to decide whether a request was secure.- The proxy strips the
UpgradeandConnectionrequest headers and has a 20-second read timeout. Self-hosted WebSockets do not work through it. Use Cloud's WebSocket service. - The open file limit is about 1.07 billion. Runtimes that size internal tables from it, including the Erlang VM and some JVM configurations, allocate around a gigabyte at start and are killed. Cloud reports the crash as out of memory and suggests more memory, which does not help. Set
ulimit -n 65536in your start script. - Cloud sits behind Cloudflare, so
CF-Rayappears on every request whether or not the customer's domain uses Cloudflare. While an application is starting, Cloudflare can cache the 404 it serves for a few minutes. Re-check with a cache-busting query string before you debug.
Domains
cloud domain:create <environment-id> --name=shop.example.com --wildcard-enabled=false --json -n --fields=id,name,dnsRecords
cloud domain:verify <domain-id> -n
domain:verifytakes the domain's ID from the create response in v0.6.3. Older notes pass the hostname.- Create the DNS records listed under
dnsRecordsin the create response. The record set we were asked for changed between two setups weeks apart, so trust the response, not a note. - If you proxy the domain through your own Cloudflare zone, enable Cloud's Cloudflare proxying option as well. A mismatch shows up as a failed origin or a
403. originStatuscan sit at pending while the domain already serves200.- Cloud's edge injects a JavaScript beacon into HTML, even on custom domains.
Cache-Control: no-transformprevents it. - Leave wildcard domains to a human.
- If a URL will be compiled into something you cannot update, such as a mobile app, set the custom domain first. A
*.laravel.cloudhostname shipped in a build can never change.