Guides · Deployment
Laravel hosting: deploy Laravel with PostgreSQL or MySQL
What a Laravel 12 app needs in production, the two config changes that connect it to a hosted database, and the Dockerfile Staxa builds.
By Jonathan Pitter ·
The short answer
A Laravel 12 app needs five things in production:
APP_KEYset in the environment, andAPP_ENV=productionwithAPP_DEBUG=false.- A database connection from the environment. Most hosts give you one
DATABASE_URL; Laravel readsDB_URL, so point it at the right variable. - Trusted proxies, so URLs and redirects say
https. - Config, routes and views cached when the container starts, not when the image is built.
php artisan migrate --forceon each deploy.
Everything below was tested on a fresh laravel new app (Laravel 12, PHP 8.4) against PostgreSQL 16: config caching from the runtime environment, migrations, a database write, and URL generation behind a proxy.
APP_KEY
Laravel encrypts cookies and sessions with APP_KEY, and without it every request fails with "No application encryption key has been specified". Generate one locally:
php artisan key:generate --show
Set the output (it starts with base64:) as APP_KEY in your host's environment. Keep it stable: changing it logs everyone out and makes anything you encrypted unreadable. Don't commit .env; in production, configuration comes from real environment variables, which Laravel reads when there's no .env file.
The database connection
Laravel's config/database.php reads DB_URL and the separate DB_HOST, DB_DATABASE, DB_USERNAME and DB_PASSWORD. Hosts usually provide DATABASE_URL. Accept either, in the pgsql and mysql connections:
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL', env('DATABASE_URL')),
// ...the rest, unchanged
],
Then set DB_CONNECTION=pgsql (or mysql) in the environment, since the default connection in a new app is SQLite. Laravel understands the postgres:// and mysql:// schemes, and query options such as sslmode=disable.
Trusted proxies
TLS usually ends at a proxy in front of your container, so the request reaching PHP is plain HTTP with an X-Forwarded-Proto: https header. Tell Laravel to trust it in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
})
Without this, url(), route() and redirects produce http:// links, and browsers block the mixed content. We checked: with the line, url('/') returned https:// for a proxied request. '*' is appropriate when only the proxy can reach the container, as on container platforms.
Cache config at start, not at build
php artisan config:cache reads the environment once and freezes it into bootstrap/cache/config.php. Run it during the image build and it freezes the build's environment, which has none of your production variables: the cached config says SQLite on 127.0.0.1 and an empty APP_KEY, and Laravel ignores the real values from then on. We checked: with DB_CONNECTION=pgsql and a real host set at runtime, a config cached without them still read sqlite and 127.0.0.1. Cache when the container starts instead:
php artisan config:cache && php artisan route:cache && php artisan view:cache && php artisan serve --host=0.0.0.0 --port=$PORT
Migrations
Run php artisan migrate --force (production refuses to migrate without --force) before the new version serves traffic. With one instance, the simplest place is the start command, before serve. The Laravel and MySQL demo does it in its own entrypoint script.
Deploying Laravel on Staxa
If your repository has a Dockerfile, Staxa builds it. If not, Staxa detects Laravel from artisan and generates this one (port 8000, PHP 8.4):
FROM php:8.4-cli-alpine AS build
WORKDIR /app
RUN apk add --no-cache git unzip libpq-dev icu-dev && docker-php-ext-install pdo pdo_mysql pdo_pgsql intl
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --optimize-autoloader
COPY . .
RUN composer dump-autoload --optimize
FROM php:8.4-cli-alpine
WORKDIR /app
RUN apk add --no-cache libpq icu-libs
# The extensions are compiled in the build stage (same base image), which has
# the headers this stage does not.
COPY --from=build /usr/local/lib/php/extensions /usr/local/lib/php/extensions
COPY --from=build /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
COPY --from=build /app .
RUN adduser -D -u 1001 app && chown -R app:app storage bootstrap/cache
USER app
ENV PORT=8000
EXPOSE 8000
# config:cache runs at start, not at build: the build has none of the tenant's
# env vars, so a config cached there would ignore DB_* and APP_KEY.
CMD ["sh", "-c", "php artisan config:cache && php artisan route:cache && php artisan view:cache && exec php artisan serve --host=0.0.0.0 --port=$PORT"]
What this means for your app:
- Set
APP_KEY,APP_ENV=productionandDB_CONNECTIONin the service's environment.DATABASE_URL(withDB_HOST,DB_PORT,DB_NAME,DB_USERandDB_PASSWORD) is set for you when the tenant has a PostgreSQL or MySQL database, so make theconfig/database.phpchange above. - PostgreSQL and MySQL drivers are installed, with
intl. Other extensions (gd,redis) need your own Dockerfile. - Frontend assets aren't built. The image has no Node.js, so a page using
@vitefails until the build output exists: commitpublic/build, or use your own Dockerfile with a Node stage. - It doesn't run migrations. Add
php artisan migrate --force &&in front ofservein your own Dockerfile. - It serves with
php artisan serve, PHP's built-in server, one request at a time. That's fine for small apps and internal tools. For real traffic, use your own Dockerfile with FrankenPHP (Laravel Octane), or nginx and PHP-FPM.
Common problems
No application encryption key has been specified
APP_KEY isn't set in the environment, or the config was cached before it was. Set it and restart; the cache is rebuilt at start.
The app ignores the database variables
Config was cached without them, usually by config:cache in a Dockerfile RUN. Move it to the start command. Also check DB_CONNECTION: the default is SQLite.
could not find driver
The PHP extension for your database isn't installed: pdo_pgsql for PostgreSQL, pdo_mysql for MySQL.
Vite manifest not found at: /app/public/build/manifest.json
A view uses @vite, and the Vite build output isn't in the image, so the page fails with a 500. Build it in your Dockerfile, or commit public/build. (The welcome page of a new app has a fallback and still renders, so this shows up on the first page of your own.)
Links and redirects use http://
Trusted proxies aren't configured; see above.
Frequently asked questions
Do I need Laravel Forge or Vapor?
No. Forge provisions and manages servers you rent; Vapor deploys to AWS Lambda. A container platform runs the app from your repository with neither.
Where should sessions and the cache live?
The new-app defaults (the database driver for sessions, cache and queues) work once migrations have created their tables. Don't use the file driver with more than one instance, since each container has its own disk.
How do I run queue workers?
As a second service running php artisan queue:work, with its own Dockerfile, rather than a second process in the web container.