← All posts

Deploying a Laravel App from a Git Repo: The Production Checklist

October 28, 2026

TL;DR: A Laravel app that runs fine with php artisan serve usually fails its first production deploy for one of a few reasons: no APP_KEY, a database connection still pointing at SQLite, files written to local disk, migrations nobody ran, or a web server that needs Linux capabilities the container doesn’t have. Sort those out before the first push, and on Candor Deploy build from the Dockerfile below, which runs FrankenPHP as an ordinary user.

This guide is written for Laravel 13, deployed from a git repository to a container-based host, with MySQL or Postgres. We tested the recipe below on a fresh laravel new app (laravel/framework 13.39). The settings it relies on are the same in Laravel 12. If you’re still on Laravel 11 or older, upgrade first: Laravel 11 stopped getting security fixes on 12 March 2026, and because of open security advisories, Composer now refuses to pick its releases when it resolves dependencies (composer update, require or create-project). Where Candor Deploy does something specific, we link the doc that says so.

Is your app detected as PHP, and how is it built?

A git-push host has to work out what your repository is before it can build it, and a Laravel repo is easy to misread. Candor Deploy’s detection rules treat a package.json that lists vite as a static site, and a stock Laravel app’s package.json lists vite, so detection calls it a static site, not a PHP app (builds and configuration docs). A static site is built and then served as files, so PHP never runs. Say what the repo is instead: set kind: app in a .candorfile, as in the recipe below.

The default build runs on Nixpacks. For Laravel, don’t rely on it; build from your own Dockerfile instead. Candor Deploy runs app containers with every Linux capability dropped and privilege escalation turned off. A container may still start as root, but root without capabilities can’t switch to another user. The usual PHP setup is nginx with PHP-FPM, and both start as root and then switch their workers to an unprivileged user such as www-data. With no capabilities that switch fails, so the web server never comes up. The recipe below avoids user switching entirely: one FrankenPHP process, started as an ordinary user.

Two things to confirm locally:

  • composer.lock is committed, so the build installs exactly the versions you tested.
  • The PHP version in composer.json ("require": { "php": "^8.3" }) matches what you run in development. Laravel 13 needs PHP 8.3 or later.

If Laravel lives in a subdirectory of a monorepo, set that directory as the app’s root rather than moving files around. In a .candorfile that’s the root key (.candorfile docs).

Where does APP_KEY come from?

Laravel encrypts cookies and sessions with APP_KEY, and it refuses to work without one. Your local .env has a key, but .env shouldn’t be in git, so production starts with nothing.

Generate a fresh key for production instead of reusing your laptop’s:

php artisan key:generate --show

Copy the whole output, including the base64: prefix, into the app’s environment. On Candor Deploy, add it on the app’s Environment tab as a variable named APP_KEY, with the key as its value. Environment changes reach the app on its next deploy (builds and configuration docs).

Mark APP_KEY as secret. That matters if you use preview deployments: a preview gets every production variable except the ones you marked secret (preview deployments). An unmarked APP_KEY goes to every preview built from a branch, along with anything else you didn’t mark, such as database credentials you typed in yourself. Give previews their own values under Environment → Preview. Once a production variable is marked secret, a preview won’t deploy until its preview set has a value for that variable, even an empty one. Don’t leave APP_KEY empty, though: with no key, Laravel answers every request with a 500. Generate a separate key for previews with key:generate --show and use it as the preview value. The same rule covers a secret Candor Deploy adds for you, CANDOR_CRON_SECRET, when you add a scheduled job (below).

Use key:generate rather than making up a key. The default cipher, AES-256-CBC, needs exactly 32 bytes, and the base64: form is how key:generate writes 32 random bytes. Once the key is set, don’t change it casually. Data stored with the encrypted cast or Crypt::encrypt can only be read with the key that wrote it, and a new key also signs everyone out. To rotate, move the old key to APP_PREVIOUS_KEYS (comma-separated) when you set the new one, so Laravel can still decrypt what the old key wrote.

Is debug mode off?

Set these explicitly in production:

APP_ENV=production
APP_DEBUG=false
APP_URL=https://your-domain.example

APP_DEBUG=true in production shows Laravel’s detailed error page to anyone who triggers an error: the stack trace, snippets of your source code, the request headers and body, the matched route and the SQL queries that ran. It’s the single most dangerous default to carry over from local development. APP_URL matters too: Laravel uses it for links in emails, queued notifications and anything generated outside a web request.

Is the database connection pointed at the right engine?

New Laravel apps default to SQLite. A SQLite file inside a container lives on a filesystem that gets replaced on the next deploy, so your data goes with it. Set the connection explicitly:

DB_CONNECTION=mysql

Laravel can read a whole connection string instead of separate host, user and password variables. It reads it from DB_URL (see the url lines in config/database.php).

On Candor Deploy, you can run managed MySQL 8.4 or Postgres next to your app. Attaching a database writes its connection string into the app’s environment as DATABASE_URL, marked secret. Databases attached on or before 30 September 2026 stored it unmarked, and the Environment tab doesn’t show the difference: it hides any connection string and shows it with the lock on, whether or not it’s stored as secret. Previews still inherit an unmarked one, so if your app’s database was attached on or before that date, attach it again: on the app’s Environment tab, under Production, pick the database under Attach a database and click Attach. That rewrites DATABASE_URL with the database’s current connection string, marked secret. A preview then won’t deploy until DATABASE_URL has a value under Environment → Preview: a separate database’s connection string, or an empty value so previews run without one. Laravel doesn’t read that name, so the start script in the recipe below copies it into DB_URL when DB_URL isn’t set. You still need DB_CONNECTION: if a database is configured but DB_CONNECTION is missing or sqlite, the script stops with an error. Otherwise Laravel would use its default sqlite connection block. Given a connection string, that block connects to your MySQL or Postgres anyway, but without the mysql or pgsql block’s charset, collation and strict-mode settings. Given only DB_HOST, it migrates a SQLite file that the next deploy throws away. For MySQL, the user and database are both named app. The database is never exposed to the internet, so only your own services can reach it (databases docs).

If you set the connection yourself instead (your own DB_URL, or DB_HOST and DB_PASSWORD), mark those variables secret, for the same reason as APP_KEY: anything unmarked is copied into previews, and the start script runs migrations whenever it finds a database. A preview with your production connection would run a branch’s migrations against your production database. In the preview set, point them at a separate database, or set them to an empty value so the preview runs without one. Previews also inherit unmarked settings such as DB_CONNECTION=mysql and SESSION_DRIVER=database, and a preview with no database would fail every request trying to load its session. So when no database is configured, the start script skips migrations and replaces any session, cache or queue driver that needs one (database, or redis when no Redis is configured) with cookie sessions, the file cache and the sync queue. The preview then serves pages, but anything that queries the database still fails.

Is the web server listening on PORT?

Container hosts tell your app which port to listen on through the PORT environment variable, and they check that something answers there before sending traffic. An app listening on the wrong port or on 127.0.0.1 builds fine and then never goes live.

Candor Deploy always sets PORT (8080 by default) and health-checks the app by connecting to it. If nothing answers, the deploy never goes live (builds and configuration docs). Whether the previous version is still serving at that point depends on whether a volume is attached and whether your memory pool has room for a second copy of the app; see when migrations run below.

Whatever serves your app has to bind to all interfaces (0.0.0.0) on that port. Don’t use php artisan serve in production; it’s a single-process development server. The recipe below runs FrankenPHP on 0.0.0.0:$PORT with public/ as the web root. The general version of the port problem is in the $PORT and 0.0.0.0 fix.

Where do uploads, sessions and cache live?

Anything written to storage/ inside a container is temporary. That includes user uploads on the local or public disk, file-based sessions and the file cache. It all works until the next deploy, then it’s gone, and users find themselves signed out.

Move each of them somewhere that outlives the container:

  • Uploads: an external S3-compatible object store, through Laravel’s s3 filesystem driver. Candor Deploy’s own object storage (Buckets) isn’t available yet. A volume keeps files across deploys, but while a volume is attached, every deploy stops the old version before starting the new one, so expect a few seconds of downtime, and a volume isn’t replicated or backed up (v1.2.48 changelog).
  • Sessions and cache: the database driver, or Redis. New Laravel apps already use the database for both, and the sessions and cache tables are in the default migrations.

If you use Redis, Laravel’s config already reads REDIS_URL. On Candor Deploy, attaching a Redis database writes exactly that variable (databases docs). Then set SESSION_DRIVER=redis and CACHE_STORE=redis. Laravel’s default Redis client, phpredis, needs PHP’s redis extension, which the Dockerfile below doesn’t install. Run composer require predis/predis and set REDIS_CLIENT=predis, or add redis to the Dockerfile’s install-php-extensions line. Without one of them, every request fails with Class "Redis" not found. Redis counts as a database, though, and Solo allows one, so on Solo use the database driver and keep MySQL or Postgres as that one.

Where do the logs go?

Laravel writes to storage/logs/laravel.log by default, which is the wrong place in a container: nobody reads it, and it vanishes on redeploy. Send logs to standard error instead:

LOG_CHANNEL=stderr

The Dockerfile below sets this as the image’s default.

On Candor Deploy, build output is on the Deployments tab and everything the app prints after it starts is on the Logs tab (troubleshooting docs). A missing APP_KEY or a refused database connection then shows up as one readable line instead of a blank 500 page.

When do migrations run?

On many hosts, migrations are your job. Candor Deploy has no separate release-phase hook: schema migrations run from your app’s start command or Dockerfile entrypoint (databases docs).

In production, php artisan migrate asks for confirmation. A container has no terminal to answer it, so the command prints “Command cancelled.” and exits with an error without migrating anything. In a start script with set -e, that stops the container, and it restarts over and over. Always pass --force.

The start script in the recipe below runs them before the web server starts, and only when a database is configured. set -e stops it at the first failure, so a failed migration never leads to a half-started app. If the old version was already stopped (see below), a failed migration means the site stays down until you roll back.

Whether the old version keeps serving during a deploy depends on two things. If the app has a volume attached, the old version always stops before the new one starts. Without a volume, the new version starts while the old one is still serving, as long as your memory pool has room for a second copy of the app. On Solo with a 512 MB app and a 512 MB database the pool is full, so the old version stops first (and scheduled jobs can’t run; see below). Whenever the old version stops first, the site is down while migrations and the app’s boot run, and a failed deploy means downtime until you roll back (v1.2.48 changelog). Keep startup migrations short: the start script runs them before the server listens on PORT, and the health check may restart a container that’s slow to start listening, cutting a migration off partway. Either way, write migrations so old code still works against the new schema, since the old version may still be serving and a rollback runs old code against it: add columns first, remove them in a later deploy. Our guide to running migrations on deploy safely covers that expand-then-contract pattern.

Run config:cache at startup rather than at build time, since it captures the environment values present when it runs.

A recipe that runs without capabilities

This is the whole recipe: a Dockerfile, a start script, a .dockerignore and a short .candorfile. It runs Laravel on FrankenPHP, a PHP app server built on Caddy, as a single process started as an ordinary user, so nothing ever needs to switch users. We built it on a stock Laravel 13 app and ran it under Docker with --cap-drop=ALL --security-opt no-new-privileges, once as the image’s own user and once forced to root with --user root. Both answered GET / with a 200.

# Dockerfile
# Install PHP dependencies in a throwaway stage, so Composer isn't in the final image.
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --no-interaction --prefer-dist --ignore-platform-reqs
COPY . .
RUN composer dump-autoload --optimize --no-dev --no-scripts

FROM dunglas/frankenphp:1-php8.4

# Database drivers for MySQL and Postgres (pdo_sqlite is already built in).
RUN install-php-extensions pdo_mysql pdo_pgsql

# Run as an ordinary user. The binary ships with a file capability
# (cap_net_bind_service, for ports below 1024); with every capability dropped
# the kernel refuses to exec a binary that carries one, and on port 8080 it
# isn't needed, so remove it. `setcap -r` exits 1 if the capability is already
# gone (a builder may drop it while unpacking the image), so allow that, then
# fail the build if one is still there. The user's group is 0 so a container
# started as root (which, with no capabilities, can't bypass file permissions)
# can still write where the app writes.
RUN useradd --uid 1000 --gid 0 --create-home app \
 && { setcap -r /usr/local/bin/frankenphp || true; } \
 && [ -z "$(getcap /usr/local/bin/frankenphp)" ] \
 && chown -R app:0 /config/caddy /data/caddy \
 && chmod -R g=u /config/caddy /data/caddy

WORKDIR /app
COPY --from=vendor --chown=app:0 /app /app
RUN php artisan package:discover --ansi \
 && chown -R app:0 storage bootstrap/cache \
 && chmod -R g=u storage bootstrap/cache

USER app
ENV PORT=8080 LOG_CHANNEL=stderr
CMD ["sh", "scripts/start.sh"]
#!/bin/sh
# scripts/start.sh: prepare the app, then run FrankenPHP on $PORT.
set -e

# Attaching a database on Candor Deploy writes DATABASE_URL; Laravel reads DB_URL.
if [ -z "${DB_URL:-}" ] && [ -n "${DATABASE_URL:-}" ]; then
  export DB_URL="$DATABASE_URL"
fi

if [ -n "${DB_URL:-}${DB_HOST:-}" ]; then
  # Laravel's default connection is sqlite. Left at that, a DB_URL runs your
  # MySQL or Postgres through the sqlite block's settings, and DB_HOST alone
  # migrates a SQLite file that the next deploy throws away. Refuse instead.
  if [ "${DB_CONNECTION:-sqlite}" = "sqlite" ]; then
    echo "start.sh: a database is configured but DB_CONNECTION is ${DB_CONNECTION:-not set}; set DB_CONNECTION=mysql or pgsql" >&2
    exit 1
  fi
  php artisan migrate --force
else
  echo "start.sh: no DB_URL, DATABASE_URL or DB_HOST set; skipping migrations" >&2
  # Laravel keeps sessions, cache and queued jobs in the database by default,
  # and a preview inherits drivers set in production. Replace any driver that
  # needs a database, or Redis when no Redis is configured, with one that doesn't.
  pick() {
    case "$1" in
      ""|database) echo "$2" ;;
      redis) if [ -n "${REDIS_URL:-}${REDIS_HOST:-}" ]; then echo redis; else echo "$2"; fi ;;
      *) echo "$1" ;;
    esac
  }
  export SESSION_DRIVER="$(pick "${SESSION_DRIVER:-}" cookie)"
  export CACHE_STORE="$(pick "${CACHE_STORE:-}" file)"
  export QUEUE_CONNECTION="$(pick "${QUEUE_CONNECTION:-}" sync)"
  echo "start.sh: SESSION_DRIVER=$SESSION_DRIVER CACHE_STORE=$CACHE_STORE QUEUE_CONNECTION=$QUEUE_CONNECTION" >&2
fi

php artisan config:cache

# Plain HTTP on all interfaces: no domain is given, so Caddy's automatic HTTPS stays off.
exec frankenphp php-server --listen "0.0.0.0:${PORT:-8080}" --root public/
# .dockerignore
.git
.env
vendor
node_modules
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*.php
database/database.sqlite

Add this .candorfile at the repository root (.candorfile docs):

services:
  web:
    kind: app
    builder: dockerfile
    port: 8080
  • kind: app overrides detection, which would otherwise make a stock Laravel repo a static site because of the vite in its package.json.
  • builder: dockerfile opts in to the Dockerfile. Without it, a Dockerfile in your repo is ignored and the build runs on Nixpacks.
  • port: 8080 matches the Dockerfile. It’s also the default PORT, so this line only makes it explicit.

A repo with one app can name its block anything; web here doesn’t need to match the app’s name in the portal.

However you create the app, plan on deploying it twice. Creating an app starts its first build straight away, and neither way of creating one asks for environment variables. Settings that aren’t secret can go in the .candorfile block under env: (for example APP_ENV: production, APP_DEBUG: "false", DB_CONNECTION: mysql, QUEUE_CONNECTION: sync, LOG_CHANNEL: stderr); they’re written when the app is created, before that first build. APP_KEY is a secret and doesn’t belong in git, so the first deploy runs without it. Don’t use the file’s {generate: hex32} for it either: that makes a 64-character hex string, not a valid Laravel key. Laravel answers every request with a 500 (No application encryption key has been specified on the Logs tab), but the deploy can still show as live, since the health check only checks that something is listening. Don’t judge the app by it. Once the app exists, set its variables on the Environment tab and attach its database, then deploy again: push a commit, or click Redeploy.

The better way to create it is to open a project and click + App (New project on the dashboard makes an empty one), then pick the repository. That form always creates an app, with 512 MB of memory, and the .candorfile’s builder and port still apply, so every deploy builds and runs your Dockerfile as an app (quickstart).

If you create it with Detect instead, the first deploy is also the wrong kind. The Detect review screen has no way to choose App over static site, and the kind in a .candorfile isn’t applied when the app is created. So the app starts out as a static site. The builder and port lines are applied at creation, so that first deploy already builds and runs your Dockerfile, but as a static site with 64 MB of memory. A static site also doesn’t receive the app’s environment variables, so Laravel has no APP_KEY and no database, and answers every request with a 500. In our test under a 64 MB limit, the stock app started and stayed up at about 44 MB, so that deploy can show as live, since the health check only checks that something is listening. A bigger app may run out of memory instead, and that deploy fails. Either way, don’t judge the app by it. The next production deploy, the one you make after setting the variables, reads kind: app and turns the static site into an app in place, at 512 MB unless the file sets memory. After that, every deploy builds an app. If your memory pool has no room for the app, that deploy fails with the reason and the static site stays as it was (v1.2.48 changelog).

The parts that matter when there are no capabilities:

  • setcap -r. The official FrankenPHP image gives its binary a file capability so it can bind ports below 1024. With every capability dropped, the kernel refuses to run a binary that carries one, so the container fails at its first command with operation not permitted. On port 8080 the capability isn’t needed, so the Dockerfile removes it. setcap -r exits with an error when there’s no capability left to remove, so the step allows that and then fails the build only if getcap still finds one.
  • USER app with group 0. The server runs as an ordinary user from the first instruction, so there’s no user switch to fail. If the container is started as root anyway, root without capabilities can’t bypass file permissions either, which is why storage/, bootstrap/cache and Caddy’s directories belong to group 0 and are group-writable.
  • --listen "0.0.0.0:${PORT:-8080}" with no domain. Caddy only turns on automatic HTTPS when it’s given a domain name. With just an address it serves plain HTTP on PORT, and HTTPS stays with the platform in front of it.
  • DATABASE_URL becomes DB_URL. Attaching a database on Candor Deploy writes DATABASE_URL, and Laravel reads DB_URL, so the script copies one into the other unless you’ve set DB_URL yourself.
  • No sqlite connection by accident. If a database is configured but DB_CONNECTION is unset or sqlite, Laravel would use its default sqlite connection block. With DB_URL set, that block connects to your MySQL or Postgres without that engine’s own settings; with only DB_HOST, migrate creates a SQLite file inside the container. The script exits with an error that names the fix instead, and the error is on the Logs tab.
  • No database, no migrations. Without DB_URL, DATABASE_URL or DB_HOST, the script skips migrate. A new Laravel app keeps sessions, cache and queued jobs in the database by default, so in that case the script switches to cookie sessions, the file cache and the sync queue. It does that whether SESSION_DRIVER, CACHE_STORE and QUEUE_CONNECTION are unset or set to database (as a preview inherits them from production), and for redis when neither REDIS_URL nor REDIS_HOST is set. Any other driver you set is kept, and the Logs tab shows the drivers it used. Once a database is attached, set sessions and cache to database or redis, as above.
  • exec. The server replaces the shell, so it receives the platform’s stop signal directly.

A few things to adjust for your own app. The PHP version in the FROM line has to satisfy your composer.lock: ours resolved packages that need PHP 8.4. And the Dockerfile doesn’t build front-end assets (see below).

A note on --ignore-platform-reqs. The composer:2 stage runs its own PHP, not the one in the final image, so the recipe tells Composer to skip its checks of the PHP version and extensions your packages require. The risk is that Composer then installs packages the runtime can’t run, and you only find out when the app starts. Avoid it when you can: build with the same PHP as the runtime (for example, run composer install in a stage based on the same dunglas/frankenphp image), or set config.platform.php in composer.json to the runtime’s version, and then drop the flag. If you keep it, make sure the FROM line’s PHP and extensions satisfy everything in composer.lock.

Who runs the queue and the scheduler?

Queued jobs need php artisan queue:work running somewhere, and scheduled tasks need something to trigger schedule:run every minute. Starting either in the web container with & hides crashes and cuts off jobs mid-run during a deploy.

On many hosts you’d run each as its own background worker. Candor Deploy’s docs describe a worker kind (kind: worker: no port, no URL, no port health check), but the portal can’t create one yet (how Candor Deploy works). Setting kind: worker in the .candorfile of an existing app doesn’t turn it into a worker either: a change of kind isn’t applied on deploy (v1.2.48 changelog). Until the portal can create workers:

  • Queued jobs: set QUEUE_CONNECTION=sync. Don’t leave the default: with a database attached, the start script leaves QUEUE_CONNECTION alone and Laravel uses the database queue, so every queued job, including queued mail and notifications, is written to the jobs table and never runs, because nothing runs queue:work. Nothing logs an error either. With sync, Laravel runs each job immediately, inside the request that dispatched it, so nothing needs a separate process. The trade-off is that the request waits for the job, a failed job fails that request, and a job has the same 30-second limit as the request (see below). Jobs that take longer have no home on Candor Deploy until the portal can create workers, so keep them short or run them elsewhere.

  • Scheduled tasks: use scheduled jobs. You give a cron schedule and a path, and Candor Deploy calls that path in your app on time, with run history and a run-now button in the portal. Point it at a route that runs Artisan::call('schedule:run'), or one route per task. Give the schedule:run job the schedule * * * * * (every minute): schedule:run only runs the tasks due in the minute it’s called, so on the Cron tab’s hourly or every-15-minutes presets, a task like dailyAt('13:30') never runs and everyFiveMinutes() runs a third as often. No preset runs every minute, so type the schedule in. That route runs inside an ordinary web request, so PHP’s web time limit applies (max_execution_time, 30 seconds by default; the image ships no php.ini that changes it), and Candor Deploy stops each run after 5 minutes. A long task gets cut off partway, so keep scheduled tasks short. Avoid sub-minute tasks such as everyTenSeconds(): they keep schedule:run going until the minute ends, past the 30 seconds, and the request dies with a fatal error. Your plan also limits how many scheduled jobs each app can have (two on Solo), so one schedule:run route usually beats one route per task.

    Each run is a small container that counts against your memory pool and needs 64 MB of it free. On Solo, a 512 MB app and a 512 MB database fill the 1024 MB pool, so every run is refused and shows as failed on the Runs list, and your scheduled tasks never run. Leave room: for example, set memory: 448 in the app’s .candorfile block, which takes effect on the next deploy.

The scheduled requests go to your app’s internal address, but the same route is also reachable at your public URL, so check who’s calling. Each request carries an X-Candor-Cron header. When you add your first scheduled job, Candor Deploy creates a random CANDOR_CRON_SECRET in the app’s environment, marked secret, and sends it in that header (the app’s Cron tab says so too). The app only sees the new variable after its next deploy, so redeploy once after adding the first job. Read the variable through a config file, since config:cache makes env() return null outside config files. In config/services.php:

'cron' => [
    'secret' => env('CANDOR_CRON_SECRET'),
],

And in routes/web.php, with the scheduled job’s path set to /cron/schedule:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;

Route::get('/cron/schedule', function (Request $request) {
    $secret = (string) config('services.cron.secret');
    $header = (string) $request->header('X-Candor-Cron');
    abort_unless($secret !== '' && hash_equals($secret, $header), 404);

    Artisan::call('schedule:run');

    return response()->noContent();
})->withoutMiddleware('web');

hash_equals compares in constant time, and the empty-secret check keeps the route closed when the variable is missing or empty. withoutMiddleware('web') keeps the route out of the web group, so a call every minute doesn’t start a session (with the default database driver, a new row in the sessions table each time) or set cookies. That matters for previews: CANDOR_CRON_SECRET is a production secret like APP_KEY, so once you add a scheduled job, no preview deploys until CANDOR_CRON_SECRET has a value under Environment → Preview. An empty value is fine, and with this route it means nobody can trigger the preview’s scheduler. Don’t put a token in the job’s path instead: the portal shows paths in plain text to everyone with access to the project, and they end up in access logs.

Do generated URLs use HTTPS?

Candor Deploy issues and renews HTTPS certificates for every app automatically (domains docs), and your app itself serves plain HTTP on PORT. Laravel only sees the original HTTPS scheme if it trusts the proxy headers. Without that, url(), redirects and asset links come out as http://, and browsers block the mixed content.

Trust the proxy in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '*');
})

Are front-end assets built?

If your app uses Vite, the production assets in public/build come from npm run build. Run it locally once to make sure it passes. The Dockerfile above doesn’t run npm, so if your app uses Vite, add a build stage that runs npm ci and npm run build and copy its public/build into the final image. If they weren’t built, any page whose view calls @vite(...) fails with a 500 error. The error page is generic when APP_DEBUG=false, and the Logs tab shows Vite manifest not found at: /app/public/build/manifest.json. Laravel’s stock welcome page checks for the manifest first, so it renders either way; don’t take it as proof the build worked.

The checklist

Before your first push, confirm each of these:

  • composer.json and composer.lock are committed, with the PHP version you test on.
  • APP_KEY is set from key:generate --show and marked secret, previews have a key of their own, and .env is not in git.
  • Database credentials you set yourself are marked secret, a database attached on or before 30 September 2026 has been attached again, and previews have their own values (or empty ones), never production’s.
  • APP_ENV=production, APP_DEBUG=false and APP_URL are set.
  • DB_CONNECTION names your engine (mysql or pgsql), and the connection string is in DB_URL or, when attached, DATABASE_URL.
  • The .candorfile sets kind: app and builder: dockerfile, and after creating the app (preferably with + App) you set its environment and deploy a second time. The server runs as an ordinary user, with no user switching, on PORT on all interfaces, not via artisan serve.
  • Uploads live in an external object store; sessions and cache in the database or Redis.
  • If your views use @vite, the image builds the front-end assets.
  • LOG_CHANNEL=stderr.
  • Migrations run with --force at startup and follow expand, then contract.
  • Queued jobs run with the sync driver (the default database queue never runs them), and scheduled tasks are triggered by a scheduled job that runs every minute (* * * * *), whose route checks X-Candor-Cron, with 64 MB of the memory pool left free for its runs, not by & in the web container.
  • You know when a deploy can mean downtime for your app (v1.2.48 changelog).
  • Trusted proxies are configured so URLs stay on HTTPS.

Ready to ship it?

If your app passes the list above, the Candor Deploy quickstart walks you from connecting the repo to a live URL.