Building a lightweight alternative to Laravel Sail
Learn how to build your own container orchestration tool for Laravel that works with both Docker and Podman, using Alpine Linux and FrankenPHP.
Laravel Sail is fantastic. It gets you up and running with a containerized development environment quickly, and for many developers, it's the perfect solution.
But here's my main problem: Sail is built on Ubuntu-based images, which means you're pulling down hefty base images and managing a fairly complex setup. More importantly, if you want to use Podman instead of Docker, you're going to have a bad day.
I wanted something lighter. Something more opinionated. Something that would work seamlessly with either Docker or Podman. So I built Skiff.
But this isn't an article about me releasing yet another Laravel package. This is about understanding what tools like Sail are actually doing under the hood, and how you can build your own opinionated tooling that fits your specific needs. By the end of this article, you'll have built your own lightweight container orchestration tool for Laravel, and more importantly, you'll understand the key decisions and patterns that make these tools work.
Why call it Skiff? A skiff is a small, lightweight boat. While Sail is your full-featured vessel for Laravel development, Skiff is the nimble alternative. Lighter, faster, and built for developers who want something more streamlined. The name reflects the philosophy: take what you need, leave what you don't, and keep things simple.
Why Docker AND Podman matter
Before we dive into building anything, let's talk about why supporting both Docker and Podman is important. Docker is the standard, sure. But Podman is a realistic, production-ready alternative that's gaining serious traction, especially in enterprise environments.
Podman runs daemonless, which means better security. It doesn't require root privileges. It's drop-in compatible with Docker commands. And if you're working in environments where Docker Desktop licensing is a concern, Podman is an attractive option.
The key insight here is that both tools are OCI-compliant. They both speak the same container language. If you design your tooling right from the start, supporting both is almost trivial. You just need to make a few specific choices about how you structure things.
Starting with the wrapper
Every tool like this needs a way for developers to interact with it. Sail has the sail script. We're going to build a skiff script. This is your entry point, your interface, the thing that makes running container commands feel natural.
Let's start simple and build it up piece by piece. Create a file called skiff in your project root:
#!/usr/bin/env bash
CONTAINER_TOOL="${CONTAINER_TOOL:-docker}"
if ! command -v $CONTAINER_TOOL &> /dev/null; then
echo "Error: $CONTAINER_TOOL is not installed."
exit 1
fiThis is our foundation. We're checking for an environment variable called CONTAINER_TOOL, defaulting to docker if it's not set. This single decision is what makes our tool work with both Docker and Podman. Users can set CONTAINER_TOOL=podman and everything just works.
The command -v check is defensive programming. If the tool isn't installed, we bail early with a clear message.
Now let's add the meat of the script:
COMPOSE="$CONTAINER_TOOL compose"
if [ $# -eq 0 ]; then
$COMPOSE ps
exit 0
fi
if [ "$1" == "bash" ] || [ "$1" == "shell" ]; then
shift 1
$COMPOSE exec app bash "$@"
elif [ "$1" == "artisan" ] || [ "$1" == "art" ]; then
shift 1
$COMPOSE exec app php artisan "$@"
elif [ "$1" == "composer" ]; then
shift 1
$COMPOSE exec app composer "$@"
elif [ "$1" == "test" ]; then
shift 1
$COMPOSE exec app php artisan test "$@"
else
$COMPOSE "$@"
fiThis is where things get interesting. We're building shortcuts for common operations. Running ./skiff artisan migrate is much nicer than docker compose exec app php artisan migrate. But we're also passing through any unrecognized commands directly to compose.
The shift 1 calls are removing the first argument (our shortcut name) before passing the rest to the container. So ./skiff artisan migrate --seed becomes docker compose exec app php artisan migrate --seed.
Notice we're using $COMPOSE everywhere. This variable contains either docker compose or podman compose, depending on what the user has set. One variable, one decision point, full compatibility.
Make your script executable:
chmod +x skiffYou now have a working wrapper. But it's not doing anything yet because we haven't defined any containers. That's next.
The opinionated Dockerfile
Sail uses Ubuntu-based images because they're familiar and well-documented. I wanted something different. I wanted Alpine Linux for its tiny footprint, and I wanted FrankenPHP because it's a game-changer for Laravel applications.
FrankenPHP is a modern PHP application server written in Go. It can keep your Laravel application in memory between requests using worker mode, and it comes with built-in support for HTTP/2, HTTP/3, and early hints. More importantly for us, it's distributed as a compact Alpine-based image.
Here's our Dockerfile:
FROM dunglas/frankenphp:latest-php8.4-alpine
RUN apk add --no-cache \
bash \
su-exec \
mysql-client \
postgresql-client \
nodejs \
npm
RUN install-php-extensions \
pdo_mysql \
pdo_pgsql \
redis \
pcntl \
zip \
exif \
gd \
intl \
opcache
RUN addgroup -g 1000 skiff && \
adduser -D -u 1000 -G skiff skiff
WORKDIR /var/www/html
COPY docker/php.ini /usr/local/etc/php/conf.d/99-custom.ini
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]Let's break down the important decisions here.
First, we're using su-exec instead of gosu. This is critical for Podman compatibility. Podman doesn't handle gosu well, but su-exec works perfectly with both Docker and Podman. It's a tiny Alpine package that does one thing: execute commands as a different user. That's all we need.
Second, we're creating a skiff user with UID 1000 and GID 1000. This is our default, but we'll override it at runtime to match the host user. This solves the permissions nightmare that plagues so many development environments.
Third, we're installing only the PHP extensions we actually need for Laravel. No bloat. If you need more, add them. If you don't use something, remove it. This is what I mean by opinionated.
The custom php.ini file is small but important:
[PHP]
post_max_size = 100M
upload_max_filesize = 100M
variables_order = EGPCS
expose_php = Off
[opcache]
opcache.enable_cli=1
opcache.validate_timestamps=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=10000
opcache.jit=tracing
opcache.jit_buffer_size=100MThe OPcache configuration here is tuned for FrankenPHP's worker mode. We're enabling JIT compilation with tracing mode and giving it 100MB of buffer space. This keeps your Laravel application compiled and in memory, dramatically improving performance.
The entrypoint script
The entrypoint script is where we handle user ID mapping. This is one of those things that seems like magic until you understand what's happening:
#!/bin/sh
if [ ! -z "$WWWUSER" ]; then
usermod -u "$WWWUSER" skiff
fi
if [ ! -z "$WWWGROUP" ]; then
groupmod -g "$WWWGROUP" skiff
fi
chown -R skiff:skiff /var/www/html/storage /var/www/html/bootstrap/cache
exec su-exec skiff "$@"When the container starts, this script runs first. If you've set WWWUSER and WWWGROUP environment variables (which we will in our Docker Compose file), it changes the skiff user's UID and GID to match your host user.
This means files created inside the container are owned by you, not by some random container user. No more sudo chown commands to fix permissions. No more confusion about why you can't edit files your container created.
The exec su-exec skiff "$@" line is the handoff. We're executing whatever command was passed to the container as the skiff user, and because we used exec, this replaces the shell process entirely. Clean and efficient.
Understanding service composition
This is the key insight I want you to walk away with. Sail provides a monolithic docker-compose.yml file with all possible services defined. You comment out what you don't need. That's fine, but it means you're always looking at services you're not using.
We're going to build our docker-compose.yml dynamically. We start with a stub that defines our app container and has placeholders for services:
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
volumes:
- .:/var/www/html
networks:
- skiff
environment:
WWWUSER: "${WWWUSER:-1000}"
WWWGROUP: "${WWWGROUP:-1000}"
depends_on:
{{depends_on}}
{{services}}
networks:
skiff:
driver: bridge
volumes:
{{volumes}}Notice the placeholders: {{services}}, {{depends_on}}, and {{volumes}}. When someone installs Skiff and chooses their services, we're going to inject the actual service definitions into these spots.
Let's look at two service examples. First, MySQL:
mysql:
image: mysql:8.0
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: laravel
MYSQL_USER: laravel
MYSQL_PASSWORD: password
volumes:
- skiff-mysql:/var/lib/mysql
networks:
- skiffAnd Redis:
redis:
image: redis:alpine
ports:
- "6379:6379"
volumes:
- skiff-redis:/data
networks:
- skiffEach service is self-contained. It has its own image, ports, environment variables, and volume. When we compose these together, we get exactly the services we need and nothing more.
The beauty of this approach is extensibility. Want to add Meilisearch? Create a new stub file. Want to customize MySQL? Edit that one file. Everything is modular and predictable.
The installation command
Now we need a way to tie this all together. We need an Artisan command that takes user input and generates the final docker-compose.yml file. This is where Laravel package development shines.
First, our service provider:
<?php
declare(strict_types=1);
namespace JustSteveKing\Skiff;
use Illuminate\Support\ServiceProvider;
final class SkiffServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([Console\InstallCommand::class]);
$this->publishes([
__DIR__ . "/../stubs/skiff" => base_path("skiff"),
__DIR__ . "/../stubs/Dockerfile" => base_path("Dockerfile"),
__DIR__ . "/../stubs/docker-compose.yml" => base_path("docker-compose.yml"),
], "skiff-config");
}
}
}Nothing fancy here. We're registering our install command and making our stub files publishable. Standard Laravel package stuff.
The install command is where it gets interesting:
<?php
namespace JustSteveKing\Skiff\Console;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class InstallCommand extends Command
{
protected $signature = "skiff:install {--with= : The services that should be installed}";
protected $description = "Install Laravel Skiff";
public function handle()
{
$services = $this->option("with")
? explode(",", $this->option("with"))
: $this->gatherServicesInteractively();
$this->info("Building your Skiff with: " . implode(", ", $services));
$this->buildDockerCompose($services);
$this->updateEnvironmentFile($services);
$this->copyCoreResources();
$this->installBinary();
$this->info("Skiff installed successfully.");
$this->comment("Get moving with: ./skiff up");
}
}The flow is deliberate. We assemble the user's requested services, build the Docker Compose file, update their .env file with container-friendly settings, copy the Dockerfile and entrypoint script, and install the skiff binary.
The buildDockerCompose method is where the magic happens:
protected function buildDockerCompose(array $services)
{
$stub = File::get(__DIR__ . "/../../stubs/docker-compose.stub");
$serviceDefinitions = collect($services)
->map(fn($service) => File::get(__DIR__ . "/../../stubs/services/{$service}.stub"))
->implode("\n");
$dependsOn = collect($services)
->map(fn($service) => " - {$service}")
->implode("\n");
$persistentServices = ["mysql", "pgsql", "redis", "valkey", "typesense", "meilisearch"];
$volumes = collect($services)
->filter(fn($s) => in_array($s, $persistentServices))
->map(fn($s) => " skiff-{$s}:\n driver: local")
->implode("\n");
$stub = str_replace(
["{{services}}", "{{depends_on}}", "{{volumes}}"],
[trim($serviceDefinitions), $dependsOn, $volumes],
$stub
);
File::put(base_path("docker-compose.yml"), $stub);
}We're reading the stub file, loading each requested service stub, building the depends_on list, creating volume definitions for services that need persistence, and then doing a simple string replacement. The result is a clean, focused docker-compose.yml with only what the user needs.
The environment file update is a helpful automation:
protected function updateEnvironmentFile(array $services)
{
if (!File::exists(base_path(".env"))) {
return;
}
$env = File::get(base_path(".env"));
if (in_array("pgsql", $services)) {
$env = preg_replace("/DB_CONNECTION=.*/", "DB_CONNECTION=pgsql", $env);
$env = preg_replace("/DB_HOST=.*/", "DB_HOST=pgsql", $env);
$env = preg_replace("/DB_PORT=.*/", "DB_PORT=5432", $env);
} elseif (in_array("mysql", $services)) {
$env = preg_replace("/DB_HOST=.*/", "DB_HOST=mysql", $env);
}
if (in_array("redis", $services)) {
$env = preg_replace("/REDIS_HOST=.*/", "REDIS_HOST=redis", $env);
}
File::put(base_path(".env"), $env);
}The database host becomes the service name. The Redis host becomes the service name. Everything just works.
How this differs from Sail
Sail gives you everything in one package. It's comprehensive, well-tested, and production-ready. But it's also opinionated in ways that might not match your preferences.
Sail uses Ubuntu-based images. We're using Alpine. Sail uses PHP-FPM with Nginx. We're using FrankenPHP. Sail has a monolithic docker-compose file. We build ours dynamically.
Sail requires Docker. We work with Docker or Podman.
The point isn't that one approach is better than the other. The point is that by understanding what these tools are actually doing, you can make your own decisions. You can build tools that match your workflow, your team's preferences, your infrastructure constraints.
Putting it all together
Let's walk through what happens when a developer uses your tool:
composer require juststeveking/skiff --dev
php artisan skiff:install --with=mysql,redisThe install command runs. It creates a docker-compose.yml with only MySQL and Redis. It updates the .env file to point at these services. It copies over your opinionated Dockerfile and entrypoint script. It installs the skiff wrapper.
Then:
./skiff up -d
./skiff artisan migrate
./skiff composer install
./skiff testEverything works. The containers are running with either Docker or Podman. The file permissions are correct. The services are networked together. Your application is running in FrankenPHP with OPcache JIT enabled.
You've built something that's lighter than Sail, more flexible than Sail, and completely tailored to your needs.
What you've learned
You now understand the anatomy of a container orchestration tool for Laravel. You know how to build a CLI wrapper that works with multiple container runtimes. You understand service composition and how to dynamically build Docker Compose files. You've seen how entrypoint scripts handle user mapping and permissions.
More importantly, you understand that these tools aren't magic. They're just shell scripts, configuration files, and a bit of PHP glue. You can build your own. You can customize them. You can make them work exactly the way you want.
Sail is fantastic for most people. But if you want something different, you now know how to build it. And that's the real lesson here: understand the tools you use deeply enough that you can remake them when you need to.
The code is opinionated. The choices are deliberate. But they're my choices for my use case. Your choices might be different. And now you know how to implement them.
Build your own tools. Understand what's happening under the hood. Make deliberate choices about your development environment. That's what being a good developer is all about.