Choosing the right build strategy on Sevalla
Learn how to pick the best build strategy for your Laravel app on Sevalla, whether it's Dockerfile, Nixpacks, or Buildpacks, plus storage options and process configuration tips.
When you push a Laravel application to Sevalla, the platform needs to turn your source code into a running container.
The way it does that is through one of three build strategies: Nixpacks, Buildpacks, or a Dockerfile. Each one approaches that problem differently, and choosing the right one from the start saves you a lot of head-scratching later.
To make this concrete, let's use the Laravel and Svelte starter kit as the working example throughout. Something like this is a great test case because it requires two runtimes at build time: PHP and Composer for the backend, and Node.js with Vite to compile the frontend assets. It is not the most exotic stack in the world, but it is just complex enough to expose the differences between each approach.
Dockerfile: The recommended approach
Before diving into the alternatives, it is worth stating clearly: Sevalla recommends using a Dockerfile as your build strategy. The reasons are practical.
A Dockerfile gives you a deterministic, reproducible build that behaves identically on your laptop, in CI, and on the platform itself. You are not relying on auto-detection logic or hoping the platform infers the right thing from your repository structure.
For Laravel and Svelte, the best approach is a multi-stage build. Stage one handles the Node.js compilation, and stage two picks up the compiled assets and sets up the PHP environment:
# Stage 1: compile Svelte assets
FROM node:20-alpine AS frontend
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: PHP application
FROM php:8.4-apache AS app
WORKDIR /var/www/html
RUN docker-php-ext-install pdo pdo_mysql
COPY --from=frontend /app/public/build ./public/build
COPY . .
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& composer install --no-dev --optimize-autoloader --no-interaction
RUN sed -i 's|/var/www/html|/var/www/html/public|g' /etc/apache2/sites-available/000-default.conf \
&& a2enmod rewriteA couple of things to watch for here. Sevalla automatically injects a PORT environment variable, so make sure your web server is listening on $PORT rather than hardcoding port 80 or 8080. You also do not need an EXPOSE instruction; Sevalla handles the routing externally.
To enable Dockerfile builds, go to Settings > Build strategy, select Dockerfile, and provide the path to your file relative to the repository root.
The portability argument is worth taking seriously, too. As your team grows, having a Dockerfile means every developer can run an environment that mirrors production without any platform-specific tooling. That consistency pays off.
Nixpacks: Fast and zero-config
Nixpacks is what Sevalla uses out of the box for any new application if you do not provide a Dockerfile. It is an open-source project that inspects your repository and automatically generates a container image based on what it finds. You do not write any configuration; it just figures things out.
For a hybrid app like Laravel and Svelte, that detection is genuinely impressive. Nixpacks sees both a composer.json and a package.json in the same repository and understands it needs to install PHP dependencies and Node.js dependencies, then run your Vite build before starting the application. No hints required.
There are a few things worth knowing before you ship:
PHP version detection. Nixpacks only supports PHP 8.1 and above, and it reads the version from the require key in your composer.json. If that key is missing, the build will fail. Make sure you have something like this in place:
{
"require": {
"php": "^8.4"
}
}Node.js version. Nixpacks reads the engines field in your package.json. You can also set NIXPACKS_NODE_VERSION as an environment variable during the build if you prefer to keep that out of your package file.
Start command. Once the build completes, head to Processes > Web Process in the Sevalla dashboard and confirm the start command is pointing at your public directory. Nixpacks usually detects this correctly for Laravel, but it is worth verifying.
Custom extensions. If your application needs specific PHP extensions, you can add a nixpacks.toml to the root of your repository:
[phases.setup]
nixPkgs = ["php84Extensions.pdo_mysql", "php84Extensions.redis"]This is a much lighter touch than writing a full Dockerfile when all you need is a missing extension. Nixpacks is a reasonable starting point for solo projects or prototypes, but for anything going to production, the Dockerfile approach gives you more confidence in what is actually running.
You can switch strategies at any time from Settings > Build strategy > Update build strategy.
Buildpacks: The Heroku-compatible fallback
Buildpacks come from Heroku's world and work by running a series of detection scripts against your repo to figure out what to install and how to start your app. The upside is that if you have ever deployed to Heroku, this workflow is already familiar to you.
The catch on Sevalla is the narrower language support. Where Nixpacks handles 20+ languages, Buildpacks covers seven: Node.js, Ruby, Python, Java, Scala, PHP, and Go.
For a Laravel and Svelte app, you need to configure two buildpacks:
- Go to Settings > Build strategy > Add buildpack.
- Add the Node.js buildpack.
- Add the PHP buildpack.
- Drag them so that Node.js is first and PHP is last.
That ordering is not optional. The documentation is explicit: the buildpack for your primary language must be last. Since Laravel is what actually runs the application, PHP goes at the bottom of the list.
With Buildpacks, your web process start command needs to be explicit:
heroku-php-apache2 /publicOr swap Apache for Nginx if you prefer:
heroku-php-nginx /publicReach for Buildpacks primarily when you are migrating an existing Heroku application and want to preserve the same Procfile-based setup without rewriting your build configuration from scratch.
Storage: Two different problems, two different solutions
Once your application is running, you almost certainly need to think about storage. Sevalla gives you two distinct options here, and understanding what each one is actually for matters before you reach for either.
Persistent storage: For stateful processes
Containers are ephemeral by design. When your application redeploys, the container is rebuilt from scratch. Anything written to the container's file system during the previous run, uploaded files, cached data written to disk, anything like that, is gone. Persistent storage solves this by attaching a dedicated storage volume to your process that survives deployments and restarts.
You configure it under Applications > Disks > Create disk. The fields are straightforward: which process the disk attaches to, the mount path, and the size. For a Nixpack or Buildpack application, the mount path must be prefixed with /app. So if your application writes uploads to storage/app/public, your mount path should be /app/storage/app/public.
A few constraints worth knowing up front. Processes with persistent storage are limited to a single instance, so you lose the ability to scale horizontally. The disk can be resized upward at any time, but you cannot shrink it once it is created, so start conservatively. Sevalla takes daily backups of persistent storage volumes and retains them for seven days; restoration requires a support request rather than a self-service UI.
Pricing is straightforward at $0.363 per GB per month, with sizes starting at 10 GB for $3/month up to 1 TB for $300/month.
Persistent storage makes sense for things like a SQLite database file, application logs that need to survive deployments, or a process that generates files locally that other processes on the same instance need to access immediately. For user-uploaded files in a typical Laravel application, it is usually the wrong choice. That is what object storage is for.
Object Storage: For files that belong outside your container
Sevalla's object storage is S3-compatible, powered by Cloudflare R2, and priced at a flat $0.02 per GB with no charges for ingress or egress.
That last part is worth pausing on: most object storage providers charge for data transfer out, which can become a significant and unpredictable cost as traffic grows. R2's zero egress model removes that entirely.
Because it is S3-compatible, you can use Laravel's built-in s3 filesystem driver to talk to it without any additional packages or custom adapters beyond the Flysystem S3 package:
composer require league/flysystem-aws-s3-v3 "^3.0"Then add a disk configuration in config/filesystems.php:
'sevalla' => [
'driver' => 's3',
'key' => env('OBJECT_STORAGE_ACCESS_KEY_ID'),
'secret' => env('OBJECT_STORAGE_SECRET_ACCESS_KEY'),
'region' => env('OBJECT_STORAGE_REGION'),
'bucket' => env('OBJECT_STORAGE_BUCKET'),
'endpoint' => env('OBJECT_STORAGE_ENDPOINT'),
'use_path_style_endpoint' => true,
],The use_path_style_endpoint flag is required for R2. Without it, the SDK tries to address the bucket as a subdomain (bucket.endpoint), which does not work here.
With that in place, the Storage facade works exactly as it does with any other disk:
Storage::disk('sevalla')->put('avatars/user-123.jpg', $fileContents);
$url = Storage::disk('sevalla')->url('avatars/user-123.jpg');If you want to make this your default disk, set FILESYSTEM_DISK=sevalla in your environment variables and calls to Storage::put() will route there automatically.
You create buckets from the Sevalla dashboard under Object Storage, and you manage users and credentials per bucket. That per-bucket access control is useful if you are running multiple applications or environments and want to keep credentials scoped appropriately. You also get six geographic regions to choose from, so you can keep data close to your users or satisfy data residency requirements.
One practical note on public files: objects are stored privately by default. If you need to serve files publicly, such as user-uploaded profile images, you can generate temporary signed URLs using Storage::temporaryUrl(), which is the safer default. If your use case genuinely requires public access for specific paths, you can configure the bucket accordingly.
Knowing which one to reach for
The question to ask is whether a file needs to be accessed by a specific process on a specific instance, or whether it just needs to be available to your application from anywhere.
User uploads, media files, generated PDFs, exports: object storage. SQLite databases, lock files, or anything tightly coupled to a single running process: persistent storage. For most Laravel applications on Sevalla, that means object storage for files, and persistent storage almost never, because databases go in a managed database service and files go in the bucket.
A few Laravel-specific things worth getting right
Build strategy is only part of the picture. Once Sevalla has built your container, you still need to configure how your application actually runs. Sevalla models this through Processes, and understanding what each process type is for will save you from some common mistakes.
The web process
Every application gets exactly one web process and you cannot remove it. This is the process that receives HTTP traffic. When Sevalla detects your application for the first time, it attempts to set the start command automatically. For Laravel, that will typically resolve to something serving the public directory. It is worth confirming this after your first deployment by clicking the ellipsis next to the web process and checking the custom start command field.
The web process also supports health checks, which is genuinely useful in production. You can configure a readiness probe to tell Sevalla when your container is ready to receive traffic, and a liveness probe to restart the container if it stops responding. For a Laravel app, pointing either probe at /up (which Laravel ships with out of the box) is a reasonable starting point.
Database migrations
Do not put php artisan migrate in your web process start command. If the migration fails, the web process never starts, and you end up in a broken deployment with no clean way out.
The right tool here is a Job process. Go to Processes > Create new process > Job, set the start command to php artisan migrate --force, and set the start policy to "After successful deployment". The migration runs once the new container is healthy, then the job shuts itself down. You only pay for the time it is running, so the cost is negligible. A failed migration also does not take your application down; the previous deployment stays up while the job fails cleanly.
The task scheduler
Sevalla has a cron job process type, but you should not use it for Laravel's scheduler. The docs are explicit about this. Laravel's scheduler must run as a background worker with the command php artisan schedule:work.
The reason is how each process type behaves. A cron job process launches on a schedule, runs, and shuts down. A background worker runs continuously. php artisan schedule:work is a long-running command that polls internally and dispatches tasks at the right intervals. If you put it inside a cron job process, you will end up with overlapping or missed tasks depending on timing.
Set it up under Processes > Create new process > Background worker with the start command php artisan schedule:work.
Queue workers
If you are running queued jobs, you need a background worker for those too. Create new process > Background worker, start command php artisan queue:work. If you need multiple workers for throughput, increase the instance count on the process rather than creating separate processes for each one.
Background workers support horizontal auto-scaling, so if your queue depth grows and CPU climbs past 80%, Sevalla can spin up additional instances automatically up to the maximum you configure.
Internal networking
If you are running a Sevalla-managed database alongside your application, connect using the internal connection details from the Networking tab rather than the public hostname. This routes traffic over a private network, which means lower latency and no egress bandwidth costs. When you link a database to an application in the dashboard, Sevalla can inject the connection environment variables automatically, which is a small quality-of-life touch that adds up over time.
WebSockets. If you are running Laravel Reverb for real-time features, you can run it as a background worker process and expose it publicly through Sevalla's TCP proxy feature. Another option is to run it as a Web process so it sits behind the Cloudflare CDN.
The recommended path for most Laravel applications is a Dockerfile for the build, object storage for files, a managed database over the internal network, and a handful of well-configured processes for migrations, scheduling, and queues. The other strategies exist for real reasons, but they are not the starting point.
Get something deployed, see where things fall over if they do, and reach for extra control only when you actually need it.