feat(v5): add Inertia app shell with Flux health

Adds v5 routing, middleware, home page rendering, team context sharing,
project model/table support, and Flux health reporting. Installs Flux in
container builds with role-aware s6 services and documents runtime roles.
This commit is contained in:
Andras Bacsai
2026-06-15 23:09:49 +02:00
parent 02ab4b39ef
commit dcd325dc44
41 changed files with 1705 additions and 108 deletions
+25
View File
@@ -29,6 +29,31 @@ You can find the installation script source [here](./scripts/install.sh).
> [!NOTE]
> Please refer to the [docs](https://coolify.io/docs/installation) for more information about the installation.
## Container roles and Flux
The Coolify image can run different process roles with `COOLIFY_CONTAINER_ROLE`:
- `all` (default): self-hosted mode; runs the web process, worker services, and Flux when configured.
- `web`: web/API process only; s6 worker services and Flux sleep.
- `worker`: Horizon, Laravel scheduler worker, and optional Nightwatch agent.
- `flux`: Flux only; used by Cloud/HA deployments that scale coold connection routers separately.
Flux is installed from the coold nightly release into `/usr/local/bin/flux`. Containers running the `all` or `flux` role expose Flux on port `6443` and use these runtime variables:
```env
COOLIFY_FLUX_GRPC_BIND=0.0.0.0:6443
COOLIFY_FLUX_UNIX_SOCKET_PATH=/run/coolify/flux.sock
COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH=/var/www/html/storage/app/flux/jwt.priv
COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH=/var/www/html/storage/app/flux/jwt.pub
COOLIFY_FLUX_ALLOW_PUBLIC_BIND=1
COOLIFY_FLUX_ID=flux-eu-1
COOLIFY_FLUX_PUBLIC_URL=grpcs://flux-eu-1.example.com:6443
COOLIFY_FLUX_INTERNAL_URL=http://coolify-flux-eu-1.internal:6443
```
If `COOLIFY_FLUX_ENABLED=false` is set, the Flux s6 service sleeps even for `all` and `flux` roles. The Flux s6 service creates a persistent JWT keypair on first boot when the key files are missing. The current Coolify image only installs the nightly Flux artifact during the Docker build; the s6 service verifies the binary at runtime and sleeps with a clear error if the published artifact is not compatible with the Alpine base image.
## Support
Contact us at [coolify.io/docs/contact](https://coolify.io/docs/contact).
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\V5;
use App\Http\Controllers\Controller;
use App\Models\Team;
use App\Models\User;
use App\Services\Flux\FluxHealth;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class HomeController extends Controller
{
public function __invoke(Request $request, FluxHealth $fluxHealth): Response
{
/** @var User $user */
$user = $request->user();
$currentTeam = $request->attributes->get('v5.currentTeam');
return Inertia::render('Home', [
'status' => 'v5-ready',
'flux' => $fluxHealth->check(),
'currentTeam' => $currentTeam instanceof Team ? [
'id' => $currentTeam->id,
'name' => $currentTeam->name,
'description' => $currentTeam->description,
'role' => $currentTeam->pivot?->role ?? $user->roleInTeam($currentTeam->id),
'personal' => $currentTeam->personal_team,
] : null,
'teams' => $user->teams()
->select('teams.id', 'teams.name', 'teams.description', 'teams.personal_team')
->orderBy('teams.name')
->get()
->map(fn (Team $team) => [
'id' => $team->id,
'name' => $team->name,
'description' => $team->description,
'role' => $team->pivot->role,
'personal' => $team->personal_team,
]),
]);
}
}
+18
View File
@@ -18,6 +18,8 @@ use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\V5\EnsureCurrentTeam as V5EnsureCurrentTeam;
use App\Http\Middleware\V5\HandleInertiaRequests as V5HandleInertiaRequests;
use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
@@ -76,6 +78,22 @@ class Kernel extends HttpKernel
],
'v5.web' => [
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
V5HandleInertiaRequests::class,
],
'v5.authenticated' => [
'auth',
'verified',
V5EnsureCurrentTeam::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
ThrottleRequests::class.':api',
@@ -0,0 +1,54 @@
<?php
namespace App\Http\Middleware\V5;
use App\Models\Team;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureCurrentTeam
{
public function handle(Request $request, Closure $next): Response
{
/** @var User|null $user */
$user = $request->user();
if (! $user) {
return $next($request);
}
$currentTeam = $this->resolveCurrentTeam($user);
if (! $currentTeam) {
abort(403, 'No team available for this user.');
}
session(['currentTeam' => $currentTeam]);
$request->attributes->set('v5.currentTeam', $currentTeam);
return $next($request);
}
private function resolveCurrentTeam(User $user): ?Team
{
$sessionTeamId = data_get(session('currentTeam'), 'id');
if ($sessionTeamId) {
$sessionTeam = $user->teams()
->select('teams.id', 'teams.name', 'teams.description', 'teams.personal_team')
->whereKey($sessionTeamId)
->first();
if ($sessionTeam) {
return $sessionTeam;
}
}
return $user->teams()
->select('teams.id', 'teams.name', 'teams.description', 'teams.personal_team')
->orderBy('teams.id')
->first();
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware\V5;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'v5.app';
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
] : null,
],
];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models\V5;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Project extends V5Model
{
protected $table = 'v5_projects';
protected $fillable = [
'team_id',
'created_by_user_id',
'name',
'description',
];
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models\V5;
use Illuminate\Database\Eloquent\Model;
abstract class V5Model extends Model
{
//
}
+5
View File
@@ -34,6 +34,11 @@ class RouteServiceProvider extends ServiceProvider
Route::prefix('webhooks')
->group(base_path('routes/webhooks.php'));
Route::middleware('v5.web')
->prefix('v5')
->as('v5.')
->group(base_path('routes/v5.php'));
Route::middleware('web')
->group(base_path('routes/web.php'));
});
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Services\Flux;
class FluxHealth
{
/**
* @return array{available: bool, label: string, message: string, socket: string|null}
*/
public function check(): array
{
$socketPath = config('flux.unix_socket_path');
if (! is_string($socketPath) || $socketPath === '') {
return $this->unavailable(null, 'Flux socket is not configured.');
}
if (! file_exists($socketPath)) {
return $this->unavailable($socketPath, 'Flux socket was not found.');
}
$timeout = (float) config('flux.health_timeout_seconds', 1.0);
$stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $timeout);
if ($stream === false) {
return $this->unavailable($socketPath, $errorMessage ?: "Could not connect to Flux socket ({$errorCode}).");
}
stream_set_timeout($stream, (int) ceil($timeout));
fwrite($stream, "GET /v1/health HTTP/1.1\r\nHost: flux\r\nAccept: application/json\r\nConnection: close\r\n\r\n");
$response = stream_get_contents($stream) ?: '';
fclose($stream);
if (! str_starts_with($response, 'HTTP/1.1 200') && ! str_starts_with($response, 'HTTP/1.0 200')) {
return $this->unavailable($socketPath, 'Flux health endpoint did not return HTTP 200.');
}
$body = str_contains($response, "\r\n\r\n") ? substr($response, strpos($response, "\r\n\r\n") + 4) : '';
$payload = json_decode($body, true);
if (! is_array($payload) || ($payload['ok'] ?? false) !== true) {
return $this->unavailable($socketPath, 'Flux health endpoint returned an invalid response.');
}
return [
'available' => true,
'label' => 'Running',
'message' => 'Flux is running.',
'socket' => $socketPath,
];
}
/**
* @return array{available: false, label: string, message: string, socket: string|null}
*/
private function unavailable(?string $socketPath, string $message): array
{
return [
'available' => false,
'label' => 'Unavailable',
'message' => $message,
'socket' => $socketPath,
];
}
}
+1
View File
@@ -15,6 +15,7 @@
"danharrin/livewire-rate-limiting": "^2.1.0",
"doctrine/dbal": "^4.4.1",
"guzzlehttp/guzzle": "^7.10.0",
"inertiajs/inertia-laravel": "^3.1",
"laravel/fortify": "^1.34.0",
"laravel/framework": "^12.49.0",
"laravel/horizon": "^5.43.0",
Generated
+92 -19
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "64b77285a7140ce68e83db2659e9a21d",
"content-hash": "d7e1f3eef5ae01d358f9482007814245",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -1643,6 +1643,79 @@
],
"time": "2025-08-22T14:27:06+00:00"
},
{
"name": "inertiajs/inertia-laravel",
"version": "v3.1.0",
"source": {
"type": "git",
"url": "https://github.com/inertiajs/inertia-laravel.git",
"reference": "f588ce4a5beb25d166f4e372bac0c7f46a4f52ac"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/f588ce4a5beb25d166f4e372bac0c7f46a4f52ac",
"reference": "f588ce4a5beb25d166f4e372bac0c7f46a4f52ac",
"shasum": ""
},
"require": {
"ext-json": "*",
"laravel/framework": "^11.0|^12.0|^13.0",
"php": "^8.2.0",
"symfony/console": "^7.0|^8.0"
},
"conflict": {
"laravel/boost": "<2.2.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.2",
"larastan/larastan": "^3.0",
"laravel/pint": "^1.16",
"mockery/mockery": "^1.3.3",
"orchestra/testbench": "^9.2|^10.0|^11.0",
"phpunit/phpunit": "^11.5|^12.0",
"roave/security-advisories": "dev-master"
},
"suggest": {
"ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Inertia\\ServiceProvider"
]
}
},
"autoload": {
"files": [
"./helpers.php"
],
"psr-4": {
"Inertia\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonathan Reinink",
"email": "jonathan@reinink.ca",
"homepage": "https://reinink.ca"
}
],
"description": "The Laravel adapter for Inertia.js.",
"keywords": [
"inertia",
"laravel"
],
"support": {
"issues": "https://github.com/inertiajs/inertia-laravel/issues",
"source": "https://github.com/inertiajs/inertia-laravel/tree/v3.1.0"
},
"time": "2026-04-30T15:30:29+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
@@ -8973,16 +9046,16 @@
},
{
"name": "symfony/http-foundation",
"version": "v7.4.8",
"version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "9381209597ec66c25be154cbf2289076e64d1eab"
"reference": "bc354f47c62301e990b7874fa662326368508e2c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab",
"reference": "9381209597ec66c25be154cbf2289076e64d1eab",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c",
"reference": "bc354f47c62301e990b7874fa662326368508e2c",
"shasum": ""
},
"require": {
@@ -9031,7 +9104,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.4.8"
"source": "https://github.com/symfony/http-foundation/tree/v7.4.13"
},
"funding": [
{
@@ -9051,7 +9124,7 @@
"type": "tidelift"
}
],
"time": "2026-03-24T13:12:05+00:00"
"time": "2026-05-24T11:20:33+00:00"
},
{
"name": "symfony/http-kernel",
@@ -10008,16 +10081,16 @@
},
{
"name": "symfony/polyfill-php83",
"version": "v1.37.0",
"version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
"reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149"
"reference": "8339098cae28673c15cce00d80734af0453054e2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149",
"reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2",
"reference": "8339098cae28673c15cce00d80734af0453054e2",
"shasum": ""
},
"require": {
@@ -10064,7 +10137,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0"
"source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1"
},
"funding": [
{
@@ -10084,7 +10157,7 @@
"type": "tidelift"
}
],
"time": "2026-04-10T17:25:58+00:00"
"time": "2026-05-26T12:51:13+00:00"
},
{
"name": "symfony/polyfill-php84",
@@ -10650,16 +10723,16 @@
},
{
"name": "symfony/routing",
"version": "v7.4.12",
"version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
"reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204"
"reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204",
"reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204",
"url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d",
"reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d",
"shasum": ""
},
"require": {
@@ -10711,7 +10784,7 @@
"url"
],
"support": {
"source": "https://github.com/symfony/routing/tree/v7.4.12"
"source": "https://github.com/symfony/routing/tree/v7.4.13"
},
"funding": [
{
@@ -10731,7 +10804,7 @@
"type": "tidelift"
}
],
"time": "2026-05-20T07:20:23+00:00"
"time": "2026-05-24T11:20:33+00:00"
},
{
"name": "symfony/serializer",
+6
View File
@@ -0,0 +1,6 @@
<?php
return [
'unix_socket_path' => env('COOLIFY_FLUX_UNIX_SOCKET_PATH', '/run/coolify/flux.sock'),
'health_timeout_seconds' => (float) env('COOLIFY_FLUX_HEALTH_TIMEOUT_SECONDS', 1.0),
];
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('v5_projects', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained('teams')->cascadeOnDelete();
$table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
$table->index(['team_id', 'name']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('v5_projects');
}
};
+11
View File
@@ -1321,6 +1321,16 @@ CREATE TABLE IF NOT EXISTS "users" (
"email_change_code_expires_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_projects" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
"created_by_user_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "webhook_notification_settings" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
@@ -1752,3 +1762,4 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_1
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (315, '2026_06_04_050157_create_v5_projects_table', 315);
+2
View File
@@ -10,8 +10,10 @@ services:
- GROUP_ID=${GROUPID:-1000}
ports:
- "${APP_PORT:-8000}:8080"
- "${FORWARD_FLUX_PORT:-6443}:6443"
environment:
AUTORUN_ENABLED: false
COOLIFY_CONTAINER_ROLE: "${COOLIFY_CONTAINER_ROLE:-all}"
PUSHER_HOST: "${PUSHER_HOST}"
PUSHER_PORT: "${PUSHER_PORT}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
+4
View File
@@ -11,6 +11,7 @@ services:
- /data/coolify/databases:/var/www/html/storage/app/databases
- /data/coolify/services:/var/www/html/storage/app/services
- /data/coolify/backups:/var/www/html/storage/app/backups
- /data/coolify/flux:/var/www/html/storage/app/flux
environment:
- APP_ENV=${APP_ENV:-production}
- PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M}
@@ -18,12 +19,15 @@ services:
- PHP_FPM_PM_START_SERVERS=${PHP_FPM_PM_START_SERVERS:-1}
- PHP_FPM_PM_MIN_SPARE_SERVERS=${PHP_FPM_PM_MIN_SPARE_SERVERS:-1}
- PHP_FPM_PM_MAX_SPARE_SERVERS=${PHP_FPM_PM_MAX_SPARE_SERVERS:-10}
- COOLIFY_CONTAINER_ROLE=${COOLIFY_CONTAINER_ROLE:-all}
env_file:
- /data/coolify/source/.env
ports:
- "${APP_PORT:-8000}:8080"
- "${COOLIFY_FLUX_PORT:-6443}:6443"
expose:
- "${APP_PORT:-8000}"
- "${COOLIFY_FLUX_PORT:-6443}"
healthcheck:
test: curl --fail http://127.0.0.1:8080/api/health || exit 1
interval: 5s
+27
View File
@@ -5,6 +5,8 @@ ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine
ARG MINIO_VERSION=RELEASE.2025-05-21T01-59-54Z
# https://github.com/cloudflare/cloudflared/releases
ARG CLOUDFLARED_VERSION=2025.7.0
# https://github.com/coollabsio/coold/releases/tag/nightly
ARG COOLIFY_FLUX_VERSION=nightly
# https://www.postgresql.org/support/versioning/
# Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer=
ARG POSTGRES_VERSION=18
@@ -24,8 +26,10 @@ FROM serversideup/php:${SERVERSIDEUP_PHP_VERSION}
ARG USER_ID
ARG GROUP_ID
ARG TARGETPLATFORM
ARG TARGETARCH
ARG POSTGRES_VERSION
ARG CLOUDFLARED_VERSION
ARG COOLIFY_FLUX_VERSION
ARG NGINX_VERSION
WORKDIR /var/www/html
@@ -58,6 +62,7 @@ RUN apk upgrade --no-cache && \
RUN apk add --no-cache \
postgresql${POSTGRES_VERSION}-client \
openssh-client \
openssl \
git \
git-lfs \
jq \
@@ -81,6 +86,28 @@ RUN mkdir -p /usr/local/bin && \
fi && \
chmod +x /usr/local/bin/cloudflared
# Install Flux from coold nightly release based on architecture
RUN set -eux; \
mkdir -p /usr/local/bin /run/coolify /etc/coolify; \
chown -R www-data:www-data /run/coolify /etc/coolify; \
case "${TARGETARCH:-}" in \
amd64|arm64) FLUX_ARCH="${TARGETARCH}" ;; \
"") \
case "$(uname -m)" in \
x86_64) FLUX_ARCH="amd64" ;; \
aarch64) FLUX_ARCH="arm64" ;; \
*) echo "unsupported Flux arch: $(uname -m)" >&2; exit 1 ;; \
esac ;; \
*) echo "unsupported Flux TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
curl -fsSL --retry 3 --max-time 120 \
-o /tmp/flux.tar.gz \
"https://github.com/coollabsio/coold/releases/download/${COOLIFY_FLUX_VERSION}/flux-linux-musl-${FLUX_ARCH}.tar.gz"; \
tar -xzf /tmp/flux.tar.gz -C /tmp; \
test -f /tmp/flux; \
install -m 0755 /tmp/flux /usr/local/bin/flux; \
rm -f /tmp/flux /tmp/flux.tar.gz
# Configure PHP
COPY docker/development/etc/php/conf.d/zzz-custom-php.ini /usr/local/etc/php/conf.d/zzz-custom-php.ini
ENV PHP_OPCACHE_ENABLE=0
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
cd /var/www/html
role="${COOLIFY_CONTAINER_ROLE:-}"
if [ -z "$role" ]; then
role="$(grep -E '^COOLIFY_CONTAINER_ROLE=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '"' | tr -d "'")"
fi
role="${role:-all}"
case "$role" in
all|flux) ;;
*)
echo " INFO Flux is disabled for role '$role', sleeping."
exec sleep infinity
;;
esac
if grep -qE '^COOLIFY_FLUX_ENABLED=false' .env 2>/dev/null || [ "${COOLIFY_FLUX_ENABLED:-}" = "false" ]; then
echo " INFO Flux is disabled, sleeping."
exec sleep infinity
fi
export COOLIFY_FLUX_GRPC_BIND="${COOLIFY_FLUX_GRPC_BIND:-0.0.0.0:6443}"
export COOLIFY_FLUX_UNIX_SOCKET_PATH="${COOLIFY_FLUX_UNIX_SOCKET_PATH:-/run/coolify/flux.sock}"
export COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH="${COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH:-/var/www/html/storage/app/flux/jwt.priv}"
export COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH="${COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH:-/var/www/html/storage/app/flux/jwt.pub}"
export COOLIFY_FLUX_ALLOW_PUBLIC_BIND="${COOLIFY_FLUX_ALLOW_PUBLIC_BIND:-1}"
if [ ! -r "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH" ]; then
echo " INFO Flux JWT public key not found at $COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH, generating keypair..."
mkdir -p "$(dirname "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH")" "$(dirname "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH")"
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp"
chmod 0600 "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp"
mv "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp" "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH"
openssl pkey -in "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH" -pubout -out "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp"
chmod 0644 "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp"
mv "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp" "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH"
fi
if ! /usr/local/bin/flux --version >/dev/null 2>&1; then
echo " ERROR Flux binary cannot run in this container. Check that the installed coold nightly Flux artifact is compatible with Alpine."
exec sleep infinity
fi
mkdir -p "$(dirname "$COOLIFY_FLUX_UNIX_SOCKET_PATH")"
echo " INFO Flux is enabled for role '$role', starting..."
exec /usr/local/bin/flux
@@ -0,0 +1 @@
longrun
@@ -2,6 +2,20 @@
cd /var/www/html
role="${COOLIFY_CONTAINER_ROLE:-}"
if [ -z "$role" ]; then
role="$(grep -E '^COOLIFY_CONTAINER_ROLE=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '"' | tr -d "'")"
fi
role="${role:-all}"
case "$role" in
all|worker) ;;
*)
echo " INFO Horizon is disabled for role '$role', sleeping."
exec sleep infinity
;;
esac
if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then
echo " INFO Horizon is disabled, sleeping."
exec sleep infinity
@@ -2,6 +2,20 @@
cd /var/www/html
role="${COOLIFY_CONTAINER_ROLE:-}"
if [ -z "$role" ]; then
role="$(grep -E '^COOLIFY_CONTAINER_ROLE=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '"' | tr -d "'")"
fi
role="${role:-all}"
case "$role" in
all|worker) ;;
*)
echo " INFO Nightwatch is disabled for role '$role', sleeping."
exec sleep infinity
;;
esac
if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then
echo " INFO Nightwatch is enabled, starting..."
exec php artisan nightwatch:agent
@@ -2,6 +2,20 @@
cd /var/www/html
role="${COOLIFY_CONTAINER_ROLE:-}"
if [ -z "$role" ]; then
role="$(grep -E '^COOLIFY_CONTAINER_ROLE=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '"' | tr -d "'")"
fi
role="${role:-all}"
case "$role" in
all|worker) ;;
*)
echo " INFO Scheduler worker is disabled for role '$role', sleeping."
exec sleep infinity
;;
esac
if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then
echo " INFO Scheduler is disabled, sleeping."
exec sleep infinity
+27
View File
@@ -5,6 +5,8 @@ ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine
ARG MINIO_VERSION=RELEASE.2025-05-21T01-59-54Z
# https://github.com/cloudflare/cloudflared/releases
ARG CLOUDFLARED_VERSION=2025.7.0
# https://github.com/coollabsio/coold/releases/tag/nightly
ARG COOLIFY_FLUX_VERSION=nightly
# https://www.postgresql.org/support/versioning/
# Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer=
ARG POSTGRES_VERSION=18
@@ -73,8 +75,10 @@ FROM serversideup/php:${SERVERSIDEUP_PHP_VERSION}
ARG USER_ID
ARG GROUP_ID
ARG TARGETPLATFORM
ARG TARGETARCH
ARG POSTGRES_VERSION
ARG CLOUDFLARED_VERSION
ARG COOLIFY_FLUX_VERSION
ARG NGINX_VERSION
ARG CI=true
@@ -108,6 +112,7 @@ RUN --mount=type=cache,target=/var/cache/apk \
apk add --no-cache \
postgresql${POSTGRES_VERSION}-client \
openssh-client \
openssl \
git \
git-lfs \
jq \
@@ -128,6 +133,28 @@ RUN mkdir -p /usr/local/bin && \
fi && \
chmod +x /usr/local/bin/cloudflared
# Install Flux from coold nightly release based on architecture
RUN set -eux; \
mkdir -p /usr/local/bin /run/coolify /etc/coolify; \
chown -R www-data:www-data /run/coolify /etc/coolify; \
case "${TARGETARCH:-}" in \
amd64|arm64) FLUX_ARCH="${TARGETARCH}" ;; \
"") \
case "$(uname -m)" in \
x86_64) FLUX_ARCH="amd64" ;; \
aarch64) FLUX_ARCH="arm64" ;; \
*) echo "unsupported Flux arch: $(uname -m)" >&2; exit 1 ;; \
esac ;; \
*) echo "unsupported Flux TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
curl -fsSL --retry 3 --max-time 120 \
-o /tmp/flux.tar.gz \
"https://github.com/coollabsio/coold/releases/download/${COOLIFY_FLUX_VERSION}/flux-linux-musl-${FLUX_ARCH}.tar.gz"; \
tar -xzf /tmp/flux.tar.gz -C /tmp; \
test -f /tmp/flux; \
install -m 0755 /tmp/flux /usr/local/bin/flux; \
rm -f /tmp/flux /tmp/flux.tar.gz
# Configure PHP
COPY docker/production/etc/php/conf.d/zzz-custom-php.ini /usr/local/etc/php/conf.d/zzz-custom-php.ini
ENV PHP_OPCACHE_ENABLE=1
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
cd /var/www/html
role="${COOLIFY_CONTAINER_ROLE:-}"
if [ -z "$role" ]; then
role="$(grep -E '^COOLIFY_CONTAINER_ROLE=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '"' | tr -d "'")"
fi
role="${role:-all}"
case "$role" in
all|flux) ;;
*)
echo " INFO Flux is disabled for role '$role', sleeping."
exec sleep infinity
;;
esac
if grep -qE '^COOLIFY_FLUX_ENABLED=false' .env 2>/dev/null || [ "${COOLIFY_FLUX_ENABLED:-}" = "false" ]; then
echo " INFO Flux is disabled, sleeping."
exec sleep infinity
fi
export COOLIFY_FLUX_GRPC_BIND="${COOLIFY_FLUX_GRPC_BIND:-0.0.0.0:6443}"
export COOLIFY_FLUX_UNIX_SOCKET_PATH="${COOLIFY_FLUX_UNIX_SOCKET_PATH:-/run/coolify/flux.sock}"
export COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH="${COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH:-/var/www/html/storage/app/flux/jwt.priv}"
export COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH="${COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH:-/var/www/html/storage/app/flux/jwt.pub}"
export COOLIFY_FLUX_ALLOW_PUBLIC_BIND="${COOLIFY_FLUX_ALLOW_PUBLIC_BIND:-1}"
if [ ! -r "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH" ]; then
echo " INFO Flux JWT public key not found at $COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH, generating keypair..."
mkdir -p "$(dirname "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH")" "$(dirname "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH")"
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp"
chmod 0600 "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp"
mv "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH.tmp" "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH"
openssl pkey -in "$COOLIFY_FLUX_JWT_PRIVATE_KEY_PATH" -pubout -out "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp"
chmod 0644 "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp"
mv "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH.tmp" "$COOLIFY_FLUX_JWT_PUBLIC_KEY_PATH"
fi
if ! /usr/local/bin/flux --version >/dev/null 2>&1; then
echo " ERROR Flux binary cannot run in this container. Check that the installed coold nightly Flux artifact is compatible with Alpine."
exec sleep infinity
fi
mkdir -p "$(dirname "$COOLIFY_FLUX_UNIX_SOCKET_PATH")"
echo " INFO Flux is enabled for role '$role', starting..."
exec /usr/local/bin/flux
@@ -0,0 +1 @@
longrun
@@ -2,6 +2,20 @@
cd /var/www/html
role="${COOLIFY_CONTAINER_ROLE:-}"
if [ -z "$role" ]; then
role="$(grep -E '^COOLIFY_CONTAINER_ROLE=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '"' | tr -d "'")"
fi
role="${role:-all}"
case "$role" in
all|worker) ;;
*)
echo " INFO Horizon is disabled for role '$role', sleeping."
exec sleep infinity
;;
esac
if grep -qE '^HORIZON_ENABLED=false' .env 2>/dev/null; then
echo " INFO Horizon is disabled, sleeping."
exec sleep infinity
@@ -2,6 +2,20 @@
cd /var/www/html
role="${COOLIFY_CONTAINER_ROLE:-}"
if [ -z "$role" ]; then
role="$(grep -E '^COOLIFY_CONTAINER_ROLE=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '"' | tr -d "'")"
fi
role="${role:-all}"
case "$role" in
all|worker) ;;
*)
echo " INFO Nightwatch is disabled for role '$role', sleeping."
exec sleep infinity
;;
esac
if grep -qE '^NIGHTWATCH_ENABLED=true' .env 2>/dev/null; then
echo " INFO Nightwatch is enabled, starting..."
exec php artisan nightwatch:agent
@@ -2,6 +2,20 @@
cd /var/www/html
role="${COOLIFY_CONTAINER_ROLE:-}"
if [ -z "$role" ]; then
role="$(grep -E '^COOLIFY_CONTAINER_ROLE=' .env 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '"' | tr -d "'")"
fi
role="${role:-all}"
case "$role" in
all|worker) ;;
*)
echo " INFO Scheduler worker is disabled for role '$role', sleeping."
exec sleep infinity
;;
esac
if grep -qE '^SCHEDULER_ENABLED=false' .env 2>/dev/null; then
echo " INFO Scheduler is disabled, sleeping."
exec sleep infinity
+706 -87
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -8,6 +8,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
"@vitejs/plugin-react": "^5.2.0",
"laravel-vite-plugin": "2.0.1",
"postcss": "8.5.15",
"tailwind-scrollbar": "4.0.2",
@@ -15,10 +16,14 @@
"vite": "7.3.2"
},
"dependencies": {
"@inertiajs/react": "^3.3.0",
"@inertiajs/vite": "^3.3.0",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"playwright": "^1.58.2"
"playwright": "^1.58.2",
"react": "^19.2.7",
"react-dom": "^19.2.7"
}
}
+11
View File
@@ -0,0 +1,11 @@
@font-face {
font-display: swap;
font-family: 'Geist Sans';
font-style: normal;
font-weight: 100 900;
src: url('../../fonts/geist-sans-variable.woff2') format('woff2');
}
body {
font-family: 'Geist Sans', sans-serif;
}
+59
View File
@@ -0,0 +1,59 @@
import { Head } from '@inertiajs/react';
export default function Home({ status, currentTeam, teams, flux }) {
return (
<>
<Head title="V5" />
<main>
<p>{status}</p>
<h1>Coolify v5</h1>
<p>
This page is served from Laravel through Inertia and React, with routes,
assets, and future v5 tables isolated from the current v4 Livewire app.
</p>
<section aria-labelledby="flux-status-heading">
<h2 id="flux-status-heading">Flux status</h2>
<p>
<strong>{flux.label}</strong>
</p>
<p>{flux.message}</p>
{flux.socket ? <p>Socket: {flux.socket}</p> : null}
</section>
<h2>Current team</h2>
{currentTeam ? (
<dl>
<dt>Name</dt>
<dd>{currentTeam.name}</dd>
<dt>Description</dt>
<dd>{currentTeam.description || 'No description'}</dd>
<dt>Your role</dt>
<dd>{currentTeam.role}</dd>
</dl>
) : (
<p>No team selected.</p>
)}
<h2>Your teams</h2>
<ul>
{teams.map((team) => (
<li key={team.id}>
{team.name} ({team.role})
</li>
))}
</ul>
</main>
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { createInertiaApp } from '@inertiajs/react';
import '../../css/v5/app.css';
createInertiaApp({
id: 'v5-app',
pages: {
path: './Pages',
extension: '.jsx',
},
strictMode: true,
});
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title inertia>{{ config('app.name') }} v5</title>
@viteReactRefresh
@vite('resources/js/v5/app.jsx')
<x-inertia::head />
</head>
<body>
<x-inertia::app id="v5-app" />
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
<?php
use App\Http\Controllers\V5\HomeController;
use Illuminate\Support\Facades\Route;
Route::middleware('v5.authenticated')->group(function () {
Route::get('/', HomeController::class)->name('home');
});
+214
View File
@@ -0,0 +1,214 @@
<?php
use App\Http\Kernel;
use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\V5\EnsureCurrentTeam;
use App\Http\Middleware\V5\HandleInertiaRequests;
use App\Models\Team;
use App\Models\User;
use App\Services\Flux\FluxHealth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Mockery\MockInterface;
beforeEach(function () {
Config::set('app.maintenance.store', 'array');
Config::set('cache.default', 'array');
Schema::dropIfExists('v5_projects');
Schema::dropIfExists('team_user');
Schema::dropIfExists('teams');
Schema::dropIfExists('users');
});
it('registers the v5 home route', function () {
expect(Route::has('v5.home'))->toBeTrue();
});
it('uses separated v5 middleware groups', function () {
$kernel = app(Kernel::class);
$reflection = new ReflectionClass($kernel);
$property = $reflection->getProperty('middlewareGroups');
$property->setAccessible(true);
$groups = $property->getValue($kernel);
expect($groups)->toHaveKey('v5.web')
->and($groups)->toHaveKey('v5.authenticated')
->and($groups['v5.web'])->toContain(HandleInertiaRequests::class)
->and($groups['v5.web'])->not->toContain(CheckForcePasswordReset::class)
->and($groups['v5.web'])->not->toContain(DecideWhatToDoWithUser::class)
->and($groups['v5.authenticated'])->toContain('auth')
->and($groups['v5.authenticated'])->toContain('verified')
->and($groups['v5.authenticated'])->toContain(EnsureCurrentTeam::class);
});
it('creates v5 project tables in the shared database', function () {
createSharedUserAndTeamTables();
$migration = include database_path('migrations/2026_06_04_050157_create_v5_projects_table.php');
$migration->up();
expect(Schema::hasTable('v5_projects'))->toBeTrue()
->and(Schema::hasColumns('v5_projects', [
'id',
'team_id',
'created_by_user_id',
'name',
'description',
'created_at',
'updated_at',
]))->toBeTrue();
});
it('includes v5 project tables in the dev testing schema', function () {
$schema = file_get_contents(database_path('schema/testing-schema.sql'));
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS "v5_projects"')
->and($schema)->toContain('"team_id" INTEGER NOT NULL')
->and($schema)->toContain('"created_by_user_id" INTEGER NOT NULL')
->and($schema)->toContain('2026_06_04_050157_create_v5_projects_table');
});
it('redirects guests to the shared login', function () {
$this->get('/v5')
->assertRedirect('/login');
});
it('serves the v5 inertia shell', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Ada Lovelace',
'email' => 'ada@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'V5 Shared Team',
'description' => 'Shared team details',
'personal_team' => true,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('v5-app', false)
->assertSee('Home', false)
->assertSee('v5-ready', false)
->assertSee('Running')
->assertSee('Flux is running.')
->assertSee('V5 Shared Team')
->assertSee('Shared team details')
->assertSee('owner')
->assertSee($user->email);
});
it('selects a shared team when the session has no current team', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Grace Hopper',
'email' => 'grace@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Auto Selected Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'admin']);
$this
->actingAs($user)
->get('/v5')
->assertSuccessful()
->assertSessionHas('currentTeam')
->assertSee('Auto Selected Team')
->assertSee('admin');
});
it('shows when flux is unavailable', function () {
$this->withoutVite();
fakeFluxHealth(false, 'Flux socket was not found.');
createSharedUserAndTeamTables();
$user = User::withoutEvents(fn () => User::query()->create([
'name' => 'Katherine Johnson',
'email' => 'katherine@example.com',
'email_verified_at' => now(),
'password' => 'password',
]));
$team = Team::withoutEvents(fn () => Team::query()->create([
'name' => 'Flux Test Team',
'description' => null,
'personal_team' => false,
'show_boarding' => false,
]));
$user->teams()->attach($team, ['role' => 'owner']);
$this
->actingAs($user)
->withSession(['currentTeam' => $team])
->get('/v5')
->assertSuccessful()
->assertSee('Unavailable')
->assertSee('Flux socket was not found.');
});
function fakeFluxHealth(bool $available = true, string $message = 'Flux is running.'): void
{
app()->instance(FluxHealth::class, Mockery::mock(FluxHealth::class, function (MockInterface $mock) use ($available, $message) {
$mock->shouldReceive('check')
->once()
->andReturn([
'available' => $available,
'label' => $available ? 'Running' : 'Unavailable',
'message' => $message,
'socket' => '/run/coolify/flux.sock',
]);
}));
}
function createSharedUserAndTeamTables(): void
{
Schema::create('users', function ($table) {
$table->id();
$table->string('name')->default('Anonymous');
$table->string('email');
$table->timestamp('email_verified_at')->nullable();
$table->string('password')->nullable();
$table->rememberToken();
$table->timestamps();
});
Schema::create('teams', function ($table) {
$table->id();
$table->string('name');
$table->string('description')->nullable();
$table->boolean('personal_team')->default(false);
$table->boolean('show_boarding')->default(false);
$table->timestamps();
});
Schema::create('team_user', function ($table) {
$table->id();
$table->foreignId('team_id');
$table->foreignId('user_id');
$table->string('role')->default('member');
$table->timestamps();
$table->unique(['team_id', 'user_id']);
});
}
+9 -1
View File
@@ -1,5 +1,7 @@
import { defineConfig, loadEnv } from "vite";
import laravel from "laravel-vite-plugin";
import react from "@vitejs/plugin-react";
import inertia from "@inertiajs/vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
@@ -32,9 +34,15 @@ export default defineConfig(({ mode }) => {
},
plugins: [
laravel({
input: ["resources/css/app.css", "resources/js/app.js"],
input: [
"resources/css/app.css",
"resources/js/app.js",
"resources/js/v5/app.jsx",
],
refresh: true,
}),
inertia({ ssr: false }),
react(),
],
}
});