Skip to content
Scalpels home

Scalpels is officially launching on October 31, 2026. Special early adopter pricing is available now.

← All guides

Guide

A Comprehensive Agent's Guide to Laravel Cloud

Provision 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 says defaultEnvironment: null. If you then create production yourself, you get EnvironmentNameTaken. Wait, re-read the application, and adopt the environment it made.
  • The application's branch is ignored on create. Through the API, POST /api/applications puts the default environment on the repository's default branch whatever you send. To track another branch, create an environment with POST /api/applications/{application}/environments and a branch.
  • 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-command or 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 401 for 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:create needs --name, --region, --visibility, --key-name, --key-permission and --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 ContentType on 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:update could 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-v3 is required for buckets. Cloud refuses to deploy an application with a bucket attached without it.
  • aws/aws-sdk-php is 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
  • --name and --size are required when you are non-interactive. Sizes come from instance:sizes, such as the mq.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-timeout was ignored when we set it, and there is no managed-queue:update. Read every queue's settings back from instance: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 422 responses was: type: managed_queue, a size, scaling_type: custom, min_replicas: 0, a max_replicas your plan allows (3 on Hobby, 25 on the higher plan we used), and background_processes with one worker whose config.connection is cloud. 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. ShouldBeUnique protects 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:create defaults --type to service. Pass --type=worker when you mean a worker.
  • background-process:delete has 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 deploy refuses 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.running and deployment.succeeded. build.succeeded is not the end. Poll until deployment.succeeded or a failure.
  • deploy:monitor after deploy -n has failed after about five minutes for us. Poll deployment:get instead.
  • 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 in GET /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, tar and xz are present. jq is 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_AUTH variable 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 nproc or free. They report the host. Read /sys/fs/cgroup/cpu.max and /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. Binding 0.0.0.0 surfaces only as deploy.app.crashing.
  • X-Forwarded-Proto says http on HTTPS requests. Do not use it to decide whether a request was secure.
  • The proxy strips the Upgrade and Connection request 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 65536 in your start script.
  • Cloud sits behind Cloudflare, so CF-Ray appears 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:verify takes the domain's ID from the create response in v0.6.3. Older notes pass the hostname.
  • Create the DNS records listed under dnsRecords in 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.
  • originStatus can sit at pending while the domain already serves 200.
  • Cloud's edge injects a JavaScript beacon into HTML, even on custom domains. Cache-Control: no-transform prevents 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.cloud hostname shipped in a build can never change.

Want some help?

After you buy, we will pay for an hour with one of these experts to help you get everything up and running.

  • Artisan Build

    USA

    We run Scalpels, as well as a suite of other tools for developers and development teams.

    Agency Ask for help
    Ask Artisan Build for help