Merge remote-tracking branch 'origin/main' into resolve-compose-environment-conflict

This commit is contained in:
Andras Bacsai
2026-08-18 14:09:40 +02:00
535 changed files with 5310 additions and 8684 deletions
-1
View File
@@ -3,7 +3,6 @@ APP_ENV=local
APP_NAME=Coolify
APP_ID=development
APP_KEY=
COOLIFY_FLUX_LARAVEL_API_TOKEN=development-flux-token
APP_URL=http://localhost
APP_PORT=8000
APP_DEBUG=true
-1
View File
@@ -2,7 +2,6 @@ APP_ENV=production
APP_NAME="Coolify Staging"
APP_ID=development
APP_KEY=
COOLIFY_FLUX_LARAVEL_API_TOKEN=test-flux-token
APP_URL=http://localhost
APP_PORT=8000
SSH_MUX_ENABLED=true
-1
View File
@@ -1,6 +1,5 @@
APP_ENV=testing
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
COOLIFY_FLUX_LARAVEL_API_TOKEN=test-flux-token
APP_DEBUG=true
DB_CONNECTION=testing
+7 -4
View File
@@ -15,6 +15,8 @@ env:
jobs:
build-push:
outputs:
short_sha: ${{ steps.version.outputs.short_sha }}
strategy:
matrix:
include:
@@ -35,6 +37,7 @@ jobs:
run: |
BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
echo "version=${BASE_VERSION}-dev.${GITHUB_SHA::9}" >> "$GITHUB_OUTPUT"
echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -60,8 +63,8 @@ jobs:
build-args: |
COOLIFY_VERSION=${{ steps.version.outputs.version }}
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.short_sha }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.short_sha }}-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
@@ -86,7 +89,7 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
SHA: ${{ github.sha }}
SHA: ${{ needs.build-push.outputs.short_sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker buildx imagetools create \
@@ -97,7 +100,7 @@ jobs:
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
SHA: ${{ github.sha }}
SHA: ${{ needs.build-push.outputs.short_sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker buildx imagetools create \
+1
View File
@@ -179,6 +179,7 @@ Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentin
- Run `vendor/bin/pint --dirty --format agent` before finalizing changes
- Every change must have tests — write or update tests, then run them. For bug fixes, follow TDD: write a failing test first, then fix the bug (see Test Enforcement below)
- Check sibling files for conventions before creating new files
- When adding remote shell commands, account for servers using non-root SSH users: commands pass through `parseCommandsByLineForSudo()`, so test pipelines, redirects, substitutions, and `sh -c`/`bash -c` scripts with the non-root sudo parser.
## Git Workflow
@@ -54,6 +54,14 @@ class CleanupPreviewDeployment
$server
);
if ($result['cancelled_deployments'] > 0) {
try {
next_after_cancel($server);
} catch (\Throwable $e) {
\Log::warning("Failed to advance deployment queue after cleaning up preview for application {$application->id}: {$e->getMessage()}");
}
}
// Step 2: Stop and remove all running PR containers
$result['killed_containers'] = $this->stopRunningContainers(
$application,
@@ -98,13 +106,13 @@ class CleanupPreviewDeployment
$deployment->update([
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
$cancelled++;
// Add cancellation log entry
$deployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
// Try to kill helper container if it exists
$this->killHelperContainer($deployment->deployment_uuid, $server);
$cancelled++;
} catch (\Throwable $e) {
\Log::warning("Failed to cancel deployment {$deployment->id}: {$e->getMessage()}");
}
+4 -6
View File
@@ -28,7 +28,7 @@ class StopApplication
if ($server->isSwarm()) {
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
return;
continue;
}
$containers = $previewDeployments
@@ -57,17 +57,15 @@ class StopApplication
}
}
$status = ['status' => 'exited'];
if ($resetRestartCount) {
$application->update([
$status = array_merge($status, [
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
} else {
$application->update([
'status' => 'exited',
]);
}
$application->update($status);
ServiceStatusChanged::dispatch($application->environment->project->team->id);
}
+3 -3
View File
@@ -208,11 +208,11 @@ class StartMariadb
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null";
}
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt";
}
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
+3 -3
View File
@@ -257,11 +257,11 @@ class StartMongodb
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem < /dev/null";
}
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem";
}
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
+3 -3
View File
@@ -209,11 +209,11 @@ class StartMysql
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null";
}
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt";
}
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
+3 -3
View File
@@ -219,11 +219,11 @@ class StartPostgresql
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt < /dev/null";
}
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt";
}
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
+1
View File
@@ -30,6 +30,7 @@ class StopDatabase
// Reset restart tracking when database is manually stopped
$database->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
+9 -1
View File
@@ -13,6 +13,8 @@ class GetProxyConfiguration
{
use AsAction;
public const MAX_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;
public function handle(Server $server, bool $forceRegenerate = false): string
{
$proxyType = $server->proxyType();
@@ -98,11 +100,17 @@ class GetProxyConfiguration
private function backfillFromDisk(Server $server): ?string
{
$proxy_path = $server->proxyPath();
$configurationPath = escapeshellarg("$proxy_path/docker-compose.yml");
$readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1;
$result = instant_remote_process([
"mkdir -p $proxy_path",
"cat $proxy_path/docker-compose.yml 2>/dev/null",
"if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi",
], $server, false);
if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) {
throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.');
}
if (! empty(trim($result ?? ''))) {
$server->proxy->last_saved_proxy_configuration = $result;
$server->save();
+1 -1
View File
@@ -131,7 +131,7 @@ class CleanupDocker
$commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
$grepCommands.' | '.
"xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
"xargs -r -I {} sh -c 'docker inspect --format \"{{index .Config.Labels \\\"coolify.managed\\\"}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
return implode(' && ', $commands);
}
+3
View File
@@ -49,6 +49,9 @@ class StopService
$this->stopContainersInParallel($containersToStop, $server);
}
$applications->each->update(['status' => 'exited']);
$dbs->each->update(['status' => 'exited']);
if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks();
}
@@ -2,6 +2,7 @@
namespace App\Actions\Service;
use App\Events\ServiceStatusChanged;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -21,5 +22,8 @@ class StopServiceApplication
instant_remote_process([
"docker stop {$containerName}",
], $server);
$serviceApplication->update(['status' => 'exited']);
ServiceStatusChanged::dispatch($service->environment->project->team->id);
}
}
@@ -88,6 +88,10 @@ class UpdateServiceApplicationFromApi
$serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('is_force_https_enabled', $payload)) {
$serviceApplication->is_force_https_enabled = filter_var($payload['is_force_https_enabled'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('is_log_drain_enabled', $payload)) {
$enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN);
$server = $serviceApplication->service->destination->server;
+1 -1
View File
@@ -70,7 +70,7 @@ class DeleteUserResources
return [
'applications' => $applications->unique('id'),
'databases' => $databases->unique('id'),
'databases' => $databases->unique(fn ($database) => $database::class.':'.$database->id),
'services' => $services->unique('id'),
];
}
-8
View File
@@ -15,10 +15,7 @@ use App\Jobs\RegenerateSslCertJob;
use App\Jobs\ScheduledJobManager;
use App\Jobs\ServerManagerJob;
use App\Jobs\UpdateCoolifyJob;
use App\Jobs\V5ReconcileServersJob;
use App\Jobs\V5RotateAgentTokensJob;
use App\Models\InstanceSettings;
use App\Support\V5\V5Feature;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@@ -52,11 +49,6 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
if (V5Feature::enabled()) {
$this->scheduleInstance->job(new V5ReconcileServersJob)->everyFiveMinutes()->withoutOverlapping()->onOneServer();
$this->scheduleInstance->job(new V5RotateAgentTokensJob)->everyFifteenMinutes()->withoutOverlapping()->onOneServer();
}
if (isDev()) {
// Instance Jobs
$this->scheduleInstance->command('horizon:snapshot')->everyMinute();
+45 -23
View File
@@ -238,57 +238,71 @@ class DeployController extends Controller
ApplicationDeploymentStatus::IN_PROGRESS->value,
];
if (! in_array($deployment->status, $cancellableStatuses)) {
if (! in_array($deployment->status, $cancellableStatuses, true)) {
return response()->json([
'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}",
], 400);
}
// Perform the cancellation
$cancelled = false;
$deploymentServer = Server::whereTeamId($teamId)->find($deployment->server_id);
try {
$deployment_uuid = $deployment->deployment_uuid;
$kill_command = "docker rm -f {$deployment_uuid}";
$build_server_id = $deployment->build_server_id ?? $deployment->server_id;
// Mark deployment as cancelled
$deployment->update([
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
$updated = ApplicationDeploymentQueue::whereKey($deployment->getKey())
->whereIn('status', $cancellableStatuses)
->update(['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]);
if ($updated !== 1) {
$deployment->refresh();
return response()->json([
'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}",
], 400);
}
$deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value;
$cancelled = true;
// Get the server
$server = Server::whereTeamId($teamId)->find($build_server_id);
if ($server) {
// Add cancellation log entry
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr');
try {
if ($server) {
// Add cancellation log entry
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr');
// Check if container exists and kill it
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
$containerExists = instant_remote_process([$checkCommand], $server);
// Check if container exists and kill it
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
$containerExists = instant_remote_process([$checkCommand], $server);
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
instant_remote_process([$kill_command], $server);
$deployment->addLogEntry('Deployment container stopped.');
} else {
$deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.');
}
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
instant_remote_process([$kill_command], $server);
$deployment->addLogEntry('Deployment container stopped.');
} else {
$deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.');
}
// Kill running process if process ID exists
if ($deployment->current_process_id) {
try {
// Kill running process if process ID exists
if ($deployment->current_process_id) {
$processKillCommand = "kill -9 {$deployment->current_process_id}";
instant_remote_process([$processKillCommand], $server);
} catch (\Throwable $e) {
// Process might already be gone
}
}
} catch (\Throwable $e) {
\Log::warning("Failed to clean up cancelled deployment {$deployment->id}: {$e->getMessage()}");
}
auditLog('api.deployment.cancelled', [
'team_id' => $teamId,
'deployment_uuid' => $deployment->deployment_uuid,
'application_id' => $application?->id,
'application_uuid' => $application?->uuid,
'application_id' => $deployment->application_id,
'application_uuid' => $deployment->application?->uuid,
'server_id' => $deployment->server_id,
]);
@@ -301,6 +315,14 @@ class DeployController extends Controller
return response()->json([
'message' => 'Failed to cancel deployment: '.$e->getMessage(),
], 500);
} finally {
if ($cancelled) {
try {
next_after_cancel($deploymentServer);
} catch (\Throwable $e) {
\Log::warning("Failed to advance deployment queue after cancelling deployment {$deployment->id}: {$e->getMessage()}");
}
}
}
}
@@ -256,6 +256,7 @@ class ServiceApplicationsController extends Controller
'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true),
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true),
]
)
)
@@ -328,6 +329,7 @@ class ServiceApplicationsController extends Controller
'is_log_drain_enabled',
'is_gzip_enabled',
'is_stripprefix_enabled',
'is_force_https_enabled',
];
$validationRules = [
@@ -341,6 +343,7 @@ class ServiceApplicationsController extends Controller
'is_log_drain_enabled' => 'sometimes|boolean',
'is_gzip_enabled' => 'sometimes|boolean',
'is_stripprefix_enabled' => 'sometimes|boolean',
'is_force_https_enabled' => 'sometimes|boolean',
];
$validator = Validator::make($payload, $validationRules);
+1
View File
@@ -15,6 +15,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Gitlab extends Controller
{
-19
View File
@@ -20,8 +20,6 @@ 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;
@@ -82,23 +80,6 @@ class Kernel extends HttpKernel
],
'v5.web' => [
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
V5HandleInertiaRequests::class,
],
'v5.authenticated' => [
'auth',
'verified',
'throttle:v5',
V5EnsureCurrentTeam::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
ThrottleRequests::class.':api',
+35 -5
View File
@@ -52,6 +52,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
private const CONTAINER_REMOVE_TIMEOUT_MARKER = '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__';
private const DOCKER_CLIENT_ENV_KEYS = [
'BUILDKIT_HOST',
'BUILDX_BUILDER',
@@ -4017,15 +4019,45 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
);
} else {
$this->execute_remote_command(
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true]
);
$this->removeContainerWithTimeout($containerName);
}
} catch (Exception $error) {
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
}
}
private function removeContainerWithTimeout(string $containerName): void
{
$outputKey = 'container_remove_'.md5($containerName);
$this->execute_remote_command([
dockerRemoveCommandWithTimeout($containerName),
'hidden' => true,
'ignore_errors' => true,
'save' => $outputKey,
'append' => false,
]);
if (! isset($this->saved_outputs)) {
return;
}
$output = (string) $this->saved_outputs->get($outputKey, '');
if (! str_contains($output, self::CONTAINER_REMOVE_TIMEOUT_MARKER)) {
return;
}
$this->application_deployment_queue->addLogEntry(
"Warning: Removing container {$containerName} timed out after 60 seconds. The deployment will continue and cleanup will be retried in 5 minutes.",
'stderr'
);
RemoveContainerJob::dispatch($this->server->id, $containerName)
->delay(now()->addMinutes(5));
}
private function stop_running_container(bool $force = false)
{
try {
@@ -5056,9 +5088,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// do not remove already running container for PR deployments
} else {
$this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr');
$this->execute_remote_command(
["docker rm -f $this->container_name >/dev/null 2>&1", 'hidden' => true, 'ignore_errors' => true]
);
$this->removeContainerWithTimeout($this->container_name);
}
}
}
+23 -3
View File
@@ -21,6 +21,10 @@ class CheckAndStartSentinelJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
if (! $this->sentinelIsEnabled()) {
return;
}
$latestVersion = get_latest_sentinel_version();
// Check if sentinel is running
@@ -28,7 +32,7 @@ class CheckAndStartSentinelJob implements ShouldBeEncrypted, ShouldQueue
$sentinelFoundJson = json_decode($sentinelFound, true);
$sentinelStatus = data_get($sentinelFoundJson, '0.State.Status', 'exited');
if ($sentinelStatus !== 'running') {
StartSentinel::run(server: $this->server, restart: true, latestVersion: $latestVersion);
$this->startSentinel($latestVersion);
return;
}
@@ -38,15 +42,31 @@ class CheckAndStartSentinelJob implements ShouldBeEncrypted, ShouldQueue
$runningVersion = '0.0.0';
}
if ($latestVersion === '0.0.0' && $runningVersion === '0.0.0') {
StartSentinel::run(server: $this->server, restart: true, latestVersion: 'latest');
$this->startSentinel('latest');
return;
} else {
if (version_compare($runningVersion, $latestVersion, '<')) {
StartSentinel::run(server: $this->server, restart: true, latestVersion: $latestVersion);
$this->startSentinel($latestVersion);
return;
}
}
}
private function sentinelIsEnabled(): bool
{
$this->server->unsetRelation('settings');
return $this->server->isSentinelEnabled();
}
private function startSentinel(string $latestVersion): void
{
if (! $this->sentinelIsEnabled()) {
return;
}
StartSentinel::run(server: $this->server, restart: true, latestVersion: $latestVersion);
}
}
+7 -1
View File
@@ -33,10 +33,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
*/
public function handle(): void
{
$this->clearOutdatedInfo();
// Detect current version (makes SSH call)
$currentVersion = getTraefikVersionFromDockerCompose($this->server);
// Update detected version in database
$this->server->update(['detected_traefik_version' => $currentVersion]);
if (! $currentVersion) {
@@ -113,6 +114,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
ProxyStatusChangedUI::dispatch($this->server->team_id);
}
private function clearOutdatedInfo(): void
{
$this->server->update(['traefik_outdated_info' => null]);
}
/**
* Get information about newer branches if available.
*/
+44 -30
View File
@@ -18,6 +18,7 @@ use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Rules\SafeWebhookUrl;
use App\Support\BackupCompression;
use App\Support\ClickhouseBackupCommand;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
@@ -278,33 +279,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} else {
return;
}
} else {
if (str($databaseType)->contains('postgres')) {
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} elseif (str($databaseType)->contains('mongo')) {
// Format: db1:collection1,collection2|db2:collection3,collection4
// Only explode if it's a string, not if it's already an array
if (is_string($databasesToBackup)) {
$databasesToBackup = explode('|', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
}
} elseif (str($databaseType)->contains('mysql')) {
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} elseif (str($databaseType)->contains('mariadb')) {
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} elseif ($this->database instanceof StandaloneClickhouse) {
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} else {
return;
}
}
$databasesToBackup = $this->databasesToBackup($databaseType, $databasesToBackup);
if ($databasesToBackup === []) {
return;
}
$this->backup_dir = backup_dir().'/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name;
if ($this->database->name === 'coolify-db') {
@@ -599,6 +577,30 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
/** @return array<int, string> */
private function databasesToBackup(string $databaseType, string|array $databases): array
{
$type = str($databaseType);
if ($this->backup->dump_all && $type->contains(['postgres', 'mysql', 'mariadb'])) {
return ['all'];
}
if (is_array($databases)) {
return $databases;
}
if ($type->contains('mongo')) {
return array_map('trim', explode('|', $databases));
}
if ($type->contains(['postgres', 'mysql', 'mariadb', 'clickhouse'])) {
return array_map('trim', explode(',', $databases));
}
return [];
}
private function backup_standalone_postgresql(string $database): void
{
try {
@@ -609,7 +611,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
$escapedUsername = escapeshellarg($this->database->postgres_user);
if ($this->backup->dump_all) {
$backupCommand .= " $this->container_name pg_dumpall --username $escapedUsername | gzip > $this->backup_location";
$backupCommand .= " $this->container_name pg_dumpall --username $escapedUsername";
$backupCommand = $this->buildCompressedDumpCommand($backupCommand).' > '.escapeshellarg($this->backup_location);
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
@@ -635,7 +638,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$commands[] = 'mkdir -p '.$this->backup_dir;
$escapedPassword = escapeshellarg($this->database->mysql_root_password);
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mysqldump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
$dumpCommand = "docker exec $this->container_name mysqldump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false";
$commands[] = $this->buildCompressedDumpCommand($dumpCommand).' > '.escapeshellarg($this->backup_location);
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
@@ -659,7 +663,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$commands[] = 'mkdir -p '.$this->backup_dir;
$escapedPassword = escapeshellarg($this->database->mariadb_root_password);
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
$dumpCommand = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false";
$commands[] = $this->buildCompressedDumpCommand($dumpCommand).' > '.escapeshellarg($this->backup_location);
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
@@ -806,6 +811,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
return "{$helperImage}:{$latestVersion}";
}
private function buildCompressedDumpCommand(string $dumpCommand): string
{
$cpuPercentage = BackupCompression::cpuPercentage($this->server->settings->backup_compression_cpu_percentage);
$compressorCommand = BackupCompression::compressorCommand($cpuPercentage);
$script = "compressor=\$({$compressorCommand}); exec \$compressor";
return $dumpCommand.' | docker run --rm -i '.escapeshellarg($this->getFullImageName()).' sh -c '.escapeshellarg($script);
}
private function markStaleExecutionsAsFailed(): void
{
try {
+11
View File
@@ -158,12 +158,15 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
])
->get();
$cancelledDeployments = 0;
foreach ($activeDeployments as $activeDeployment) {
try {
// Mark deployment as cancelled
$activeDeployment->update([
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
$cancelledDeployments++;
// Add cancellation log entry
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
@@ -186,6 +189,14 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
}
}
if ($cancelledDeployments > 0) {
try {
next_after_cancel($server);
} catch (\Throwable $e) {
\Log::warning("Failed to advance deployment queue after deleting preview {$this->resource->id}: {$e->getMessage()}");
}
}
try {
if ($server->isSwarm()) {
$escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
+1 -19
View File
@@ -188,7 +188,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
Cache::forget($storageCacheKey);
}
if ($this->containers->isEmpty()) {
if ($this->containers->isEmpty() && ! $this->isCompleteSnapshot()) {
return;
}
@@ -625,12 +625,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
// Batch update: mark all not-found applications as exited (excluding already exited ones)
Application::whereIn('id', $notFoundApplicationIds)
->where('status', 'not like', 'exited%')
@@ -644,12 +638,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
// Collect IDs of previews that need to be marked as exited
$previewIdsToUpdate = collect();
foreach ($notFoundApplicationPreviewsIds as $previewKey) {
@@ -738,12 +726,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
$notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databasesByUuid->get($databaseUuid);
if ($database) {
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class RemoveContainerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 90;
public function __construct(public int $serverId, public string $containerName) {}
public function handle(): void
{
$server = Server::findOrFail($this->serverId);
instant_remote_process(
[dockerRemoveCommandWithTimeout($this->containerName)],
$server,
timeout: 75,
disableMultiplexing: true,
);
}
public function backoff(): array
{
return [300, 900];
}
public function failed(?\Throwable $exception): void
{
Log::warning('Deferred container removal failed', [
'server_id' => $this->serverId,
'container' => $this->containerName,
'error' => $exception?->getMessage(),
]);
}
}
+14 -2
View File
@@ -25,6 +25,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024;
/**
* The number of times the job may be attempted.
*/
@@ -148,10 +150,12 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
foreach ($this->containers as $containerName) {
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
$exec = "docker exec {$containerName} {$cmd}";
$dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker';
$execCommand = "{$dockerCommand} exec {$containerName} {$cmd}";
$exec = $this->boundedTaskCommand($execCommand);
// Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently
// See: https://github.com/coollabsio/coolify/issues/6736
$this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true);
$this->task_log->update([
'status' => 'success',
'message' => $this->task_output,
@@ -204,6 +208,14 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function boundedTaskCommand(string $command): string
{
$maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES;
$readLimit = $maxOutputBytes + 1;
return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code";
}
/**
* Calculate the number of seconds to wait before retrying the job.
*/
+5 -12
View File
@@ -8,6 +8,7 @@ use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Rules\SafeWebhookUrl;
use App\Support\BackupCompression;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -77,15 +78,14 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$source = $this->backup->sourcePath();
$containerName = 'volume-backup-'.$this->execution->uuid;
$image = coolifyHelperImage().':'.getHelperVersion();
$compressionCpuPercentage = $this->compressionCpuPercentage($server);
$compressionCpuPercentage = BackupCompression::cpuPercentage($server->settings->backup_compression_cpu_percentage);
$this->logCompressorInDevelopment($image, $server, $compressionCpuPercentage);
$verifySourceCommand = $target instanceof LocalPersistentVolume && blank($target->host_path)
? 'docker volume inspect '.escapeshellarg($source).' >/dev/null'
: 'test -d '.escapeshellarg($source);
$archiveScript = "compressor='gzip -3'; "
."if command -v pigz >/dev/null 2>&1; then compressor=\"pigz -3 -p \$(( (\$(nproc) * {$compressionCpuPercentage} + 99) / 100 ))\"; fi; "
.'tar -I "$compressor" -cf - -C /volume .';
$compressorCommand = BackupCompression::compressorCommand($compressionCpuPercentage);
$archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume .";
$archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName)
.' -v '.escapeshellarg($source.':/volume:ro')
.' '.escapeshellarg($image)
@@ -344,7 +344,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
return;
}
$script = "if command -v pigz >/dev/null 2>&1; then printf 'pigz -3 -p %s' \"\$(( (\$(nproc) * {$compressionCpuPercentage} + 99) / 100 ))\"; else printf 'gzip -3'; fi";
$script = BackupCompression::compressorCommand($compressionCpuPercentage);
$compressor = instant_remote_process(
['docker run --rm '.escapeshellarg($image).' sh -c '.escapeshellarg($script)],
$server,
@@ -361,13 +361,6 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
]);
}
private function compressionCpuPercentage(Server $server): int
{
$percentage = (int) ($server->settings->backup_compression_cpu_percentage ?? 25);
return in_array($percentage, [25, 50, 75, 100], true) ? $percentage : 25;
}
private function removeExpiredBackups(Server $server): void
{
if ($this->hasRetentionLimits(
+14 -1
View File
@@ -29,7 +29,10 @@ class ActivityMonitor extends Component
public static $eventDispatched = false;
protected $listeners = ['activityMonitor' => 'newMonitorActivity'];
protected $listeners = [
'activityMonitor' => 'newMonitorActivity',
'processDialogClosed' => 'clearActivity',
];
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null)
{
@@ -50,6 +53,16 @@ class ActivityMonitor extends Component
$this->isPollingActive = true;
}
public function clearActivity(): void
{
$this->activityId = null;
$this->activity = null;
$this->isPollingActive = false;
$this->eventToDispatch = 'activityFinished';
$this->eventData = null;
self::$eventDispatched = false;
}
public function hydrateActivity()
{
if ($this->activityId === null) {
-6
View File
@@ -54,12 +54,6 @@ class DeploymentsIndicator extends Component
return $this->deployments->count();
}
#[Computed]
public function shouldReduceOpacity(): bool
{
return request()->routeIs('project.application.deployment.*');
}
public function toggleExpanded()
{
$this->expanded = ! $this->expanded;
@@ -82,7 +82,7 @@ class Create extends Component
'type' => 'Directory',
'name' => $directory->fs_path,
]);
$this->targets = $volumes->concat($directories)->values();
$this->targets = collect($volumes->concat($directories)->all())->values();
$this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key');
$this->loadSelectedBackup();
}
@@ -22,6 +22,14 @@ class Index extends Component
public int $defaultTake = 10;
public function updatedDefaultTake(): void
{
$this->defaultTake = max(1, min(100, $this->defaultTake));
$this->skip = 0;
$this->loadDeployments();
}
public bool $showNext = false;
public bool $showPrev = false;
@@ -6,6 +6,7 @@ use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application;
use App\Models\Server;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
@@ -22,6 +23,8 @@ class Domains extends Component
public string $redirect = 'both';
public bool $isForceHttpsEnabled = true;
/**
* Per compose-service www/non-www redirect direction.
* Keys are wire-safe (dots encoded) use serviceRedirectWireKey().
@@ -35,12 +38,20 @@ class Domains extends Component
public string $newDomain = '';
public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $newDomainPartsChanged = false;
public ?string $newDomainService = null;
public ?int $editingIndex = null;
public string $editingDomain = '';
public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $editingDomainPartsChanged = false;
public ?string $editingService = null;
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool}> */
@@ -100,6 +111,7 @@ class Domains extends Component
'newDomain' => ValidationPatterns::applicationDomainRules(),
'editingDomain' => ValidationPatterns::applicationDomainRules(),
'redirect' => 'string|required|in:both,www,non-www',
'isForceHttpsEnabled' => 'boolean',
'serviceRedirects' => 'array',
'serviceRedirects.*' => 'string|in:both,www,non-www',
];
@@ -151,6 +163,18 @@ class Domains extends Component
$this->setRedirect();
}
public function updateForceHttps(): void
{
$this->authorize('update', $this->application);
$this->validateOnly('isForceHttpsEnabled');
$this->application->settings->is_force_https_enabled = $this->isForceHttpsEnabled;
$this->application->settings->save();
$this->resetDefaultLabels();
$this->dispatch('configurationChanged')->to(ConfigurationChecker::class);
$this->dispatch('success', 'HTTP to HTTPS redirect updated.');
}
public function loadDomainState(): void
{
$this->application->refresh();
@@ -159,6 +183,7 @@ class Domains extends Component
$this->isCompose = $this->application->build_pack === 'dockercompose';
$this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false;
$this->redirect = $this->application->redirect ?? 'both';
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
$settings = instanceSettings();
$this->dnsValidationEnabled = (bool) data_get($settings, 'is_dns_validation_enabled', true);
@@ -662,6 +687,12 @@ class Domains extends Component
$this->resetAddDomainDnsGate();
}
public function updatedNewDomainParts(): void
{
$this->newDomainPartsChanged = true;
$this->resetAddDomainDnsGate();
}
public function updatedNewDomainService(): void
{
$this->resetAddDomainDnsGate();
@@ -677,6 +708,8 @@ class Domains extends Component
public function resetAddDomainForm(): void
{
$this->newDomain = '';
$this->newDomainParts = DomainUrlParts::empty();
$this->newDomainPartsChanged = false;
$this->resetAddDomainDnsGate();
$this->resetErrorBag('newDomain');
}
@@ -743,6 +776,9 @@ class Domains extends Component
return;
}
if ($this->newDomainPartsChanged) {
$this->newDomain = DomainUrlParts::compose(...$this->newDomainParts);
}
$this->validateOnly('newDomain');
$normalized = ValidationPatterns::normalizeApplicationDomains($this->newDomain);
@@ -893,6 +929,12 @@ class Domains extends Component
$this->resetEditDomainDnsGate();
}
public function updatedEditingDomainParts(): void
{
$this->editingDomainPartsChanged = true;
$this->resetEditDomainDnsGate();
}
public function resetEditDomainDnsGate(): void
{
$this->editDomainDnsFailed = false;
@@ -908,10 +950,13 @@ class Domains extends Component
$this->editingIndex = $index;
$this->editingDomain = $this->domainRows[$index]['url'];
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
$this->editingDomainPartsChanged = false;
$this->editingService = $this->domainRows[$index]['service'];
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
$this->showEditDomainModal = true;
$this->dispatch('open-edit-domain');
}
public function addSuggestedDomain(int $index): void
@@ -990,6 +1035,8 @@ class Domains extends Component
$this->showEditDomainModal = false;
$this->editingIndex = null;
$this->editingDomain = '';
$this->editingDomainParts = DomainUrlParts::empty();
$this->editingDomainPartsChanged = false;
$this->editingService = null;
$this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain');
@@ -1021,6 +1068,9 @@ class Domains extends Component
return;
}
if ($this->editingDomainPartsChanged) {
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
}
$this->validateOnly('editingDomain');
$normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain);
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application;
use App\Actions\Docker\GetContainersStatus;
use App\Events\ServiceStatusChanged;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
@@ -373,6 +374,11 @@ class Previews extends Component
$this->stopContainers($containers, $server);
}
ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->update(['status' => 'exited']);
ServiceStatusChanged::dispatch($this->application->environment->project->team->id);
GetContainersStatus::run($server);
$this->application->refresh();
$this->dispatch('containerStatusUpdated');
@@ -98,26 +98,34 @@ class BackupExecutions extends Component
return;
}
$server = $execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class
? $execution->scheduledDatabaseBackup->database->service->destination->server
: $execution->scheduledDatabaseBackup->database->destination->server;
try {
if ($execution->filename) {
deleteBackupsLocally($execution->filename, $server);
$deleteFromS3 = in_array('delete_backup_s3', $selectedActions, true);
if ($this->delete_backup_s3 && $execution->scheduledDatabaseBackup->s3) {
deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3);
if ($execution->filename && ! $execution->local_storage_deleted) {
$server = $this->backup->server();
if (! $server) {
throw new \RuntimeException('The backup server is unavailable.');
}
deleteBackupsLocally($execution->filename, $server, throwError: true);
}
if ($deleteFromS3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) {
if (! $execution->scheduledDatabaseBackup->s3) {
throw new \RuntimeException('The S3 storage is unavailable.');
}
deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3);
}
$execution->delete();
$this->delete_backup_s3 = false;
$this->dispatch('success', 'Backup deleted.');
$this->refreshBackupExecutions();
} catch (\Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
return true;
return false;
}
return true;
+6 -7
View File
@@ -812,14 +812,13 @@ EOD;
// /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
$sep = '([[:space:]]|/\\*[^*]*\\*/)';
$pattern = implode('|', [
"copy{$sep}+[^;]*(from|to){$sep}+program",
'(^|[[:space:]])\\\\!',
"(^|[[:space:]])\\\\(o|g){$sep}*\\|",
]);
$escapedPattern = escapeshellarg($pattern);
$sqlPattern = "(^|;){$sep}*copy{$sep}+[^;]*(from|to){$sep}+program";
$psqlPattern = "^{$sep}*\\\\(!|copy{$sep}+[^[:space:]]+.*{$sep}+program|(o|g){$sep}*\\|)";
$escapedSqlPattern = escapeshellarg($sqlPattern);
$escapedPsqlPattern = escapeshellarg($psqlPattern);
$contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }";
return "if (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
return "header=\$({$contents} | head -c 5); if [ \"\$header\" = 'PGDMP' ]; then exit 0; fi; if {$contents} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$contents} | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedSqlPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
}
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
@@ -209,11 +209,15 @@ class General extends Component
}
}
public function instantSave()
public function instantSave(?bool $isPublic = null)
{
try {
$this->authorize('update', $this->database);
if ($isPublic !== null) {
$this->isPublic = $isPublic;
}
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->isPublic = false;
@@ -134,8 +134,9 @@ class GithubPrivateRepository extends Component
public function loadBranches()
{
$this->selected_repository_owner = $this->repositories->where('id', $this->selected_repository_id)->first()['owner']['login'];
$this->selected_repository_repo = $this->repositories->where('id', $this->selected_repository_id)->first()['name'];
$repository = $this->repositories->firstWhere('id', $this->selected_repository_id);
$this->selected_repository_owner = data_get($repository, 'owner.login');
$this->selected_repository_repo = data_get($repository, 'name');
$this->branches = collect();
$this->page = 1;
$this->loadBranchByPage();
@@ -146,7 +147,10 @@ class GithubPrivateRepository extends Component
}
}
$this->branches = sortBranchesByPriority($this->branches);
$this->selected_branch_name = data_get($this->branches, '0.name', 'main');
$defaultBranch = data_get($repository, 'default_branch', 'main');
$this->selected_branch_name = $this->branches->contains('name', $defaultBranch)
? $defaultBranch
: data_get($this->branches, '0.name', 'main');
}
protected function loadBranchByPage()
+4 -31
View File
@@ -4,8 +4,6 @@ namespace App\Livewire\Project\Resource;
use App\Models\Environment;
use App\Models\Project;
use App\Models\V5\Application as V5Application;
use App\Support\V5\V5Feature;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -72,10 +70,6 @@ class Index extends Component
'clickhouses:id,uuid,name,environment_id',
];
if (V5Feature::enabled()) {
$environmentRelations[] = 'v5Applications:id,uuid,name,environment_id,status';
}
$this->allEnvironments = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->with($environmentRelations)
@@ -111,24 +105,6 @@ class Index extends Component
return $application;
});
if (V5Feature::enabled()) {
$this->applications = $this->applications->merge(V5Application::query()
->where('team_id', currentTeam()->id)
->where('project_id', $this->project->id)
->where('environment_id', $this->environment->id)
->with('server:id,name')
->get()
->map(function (V5Application $application) use ($projectUuid, $environmentUuid) {
$application->hrefLink = route('v5.dashboard', [
'project' => $projectUuid,
'environment' => $environmentUuid,
'application' => $application->uuid,
]);
return $application;
}));
}
$this->applications = $this->applications->sortBy('name');
// Load all database resources in a single query per type
@@ -207,21 +183,18 @@ class Index extends Component
'uuid' => $item->uuid,
'name' => $item->name,
'type' => $type,
'typeLabel' => $item instanceof V5Application ? 'Application (V5)' : $typeLabel,
'typeLabel' => $typeLabel,
'fqdn' => $item->fqdn ?? null,
'description' => $item instanceof V5Application ? 'Managed by Coolify V5' : ($item->description ?? null),
'description' => $item->description ?? null,
'status' => $item->status ?? '',
'version' => $item instanceof V5Application ? 'v5' : 'v4',
'server_status' => $item->server_status ?? null,
'hrefLink' => $item->hrefLink ?? '',
'destination' => [
'server' => [
'name' => $item instanceof V5Application
? ($item->server?->name ?? 'Unknown')
: ($item->destination?->server?->name ?? 'Unknown'),
'name' => $item->destination?->server?->name ?? 'Unknown',
],
],
'tags' => ($item instanceof V5Application ? collect() : $item->tags)->map(fn ($tag) => [
'tags' => $item->tags->map(fn ($tag) => [
'id' => $tag->id,
'name' => $tag->name,
])->values()->toArray(),
+65
View File
@@ -33,6 +33,9 @@ class Domains extends Component
*/
public array $serviceRedirects = [];
/** @var array<int|string, bool> */
public array $forceHttpsRedirects = [];
/** Service application id when a pending domain conflict belongs to setServiceRedirect. */
public ?int $pendingRedirectServiceApplicationId = null;
@@ -43,10 +46,18 @@ class Domains extends Component
public string $newDomain = '';
public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $newDomainPartsChanged = false;
public ?int $editingIndex = null;
public string $editingDomain = '';
public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $editingDomainPartsChanged = false;
public ?int $editingServiceApplicationId = null;
public bool $showEditDomainModal = false;
@@ -102,6 +113,8 @@ class Domains extends Component
'newServiceApplicationId' => 'nullable|integer',
'serviceRedirects' => 'array',
'serviceRedirects.*' => 'string|in:both,www,non-www',
'forceHttpsRedirects' => 'array',
'forceHttpsRedirects.*' => 'boolean',
];
}
@@ -135,6 +148,22 @@ class Domains extends Component
$this->dispatch('success', 'Search engine indexing updated.');
}
public function updateForceHttps(int $serviceApplicationId, bool $enabled): void
{
$application = $this->service->applications()->findOrFail($serviceApplicationId);
$this->authorize('update', $application);
$this->forceHttpsRedirects[$serviceApplicationId] = $enabled;
$this->validateOnly("forceHttpsRedirects.{$serviceApplicationId}");
$application->is_force_https_enabled = $enabled;
$application->save();
$this->service->parse();
$this->refreshDomains();
$this->dispatch('configurationChanged')->to(ConfigurationChecker::class);
$this->dispatch('success', 'HTTP to HTTPS redirect updated.');
}
public function loadDomainState(): void
{
$this->service->loadMissing(['applications', 'server']);
@@ -159,6 +188,10 @@ class Domains extends Component
$this->serverIpConfigured = null;
}
$this->forceHttpsRedirects = $this->service->applications
->mapWithKeys(fn (ServiceApplication $app) => [$app->id => $app->isForceHttpsEnabled()])
->all();
$this->serviceApps = $this->service->applications
->sortBy(fn (ServiceApplication $app) => strtolower($app->human_name ?: $app->name))
->values()
@@ -509,6 +542,17 @@ class Domains extends Component
}
public function updatedNewDomain(): void
{
$this->resetAddDomainDnsGate();
}
public function updatedNewDomainParts(): void
{
$this->newDomainPartsChanged = true;
$this->resetAddDomainDnsGate();
}
public function resetAddDomainDnsGate(): void
{
$this->addDomainDnsFailed = false;
$this->addDomainDnsMessage = '';
@@ -522,6 +566,12 @@ class Domains extends Component
$this->forceSaveEditDns = false;
}
public function updatedEditingDomainParts(): void
{
$this->editingDomainPartsChanged = true;
$this->updatedEditingDomain();
}
public function confirmAddDomainDespiteDns(): void
{
$this->forceSaveDns = true;
@@ -842,6 +892,9 @@ class Domains extends Component
{
try {
$this->authorize('update', $this->service);
if ($this->newDomainPartsChanged) {
$this->newDomain = DomainUrlParts::compose(...$this->newDomainParts);
}
$this->validateOnly('newDomain');
$app = $this->findServiceApp($this->newServiceApplicationId);
@@ -893,6 +946,8 @@ class Domains extends Component
}
$this->newDomain = '';
$this->newDomainParts = DomainUrlParts::empty();
$this->newDomainPartsChanged = false;
$this->addDomainDnsFailed = false;
$this->addDomainDnsMessage = '';
$this->forceSaveDns = false;
@@ -916,12 +971,15 @@ class Domains extends Component
$this->editingIndex = $index;
$this->editingDomain = $this->domainRows[$index]['url'];
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
$this->editingDomainPartsChanged = false;
$this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id'];
$this->editDomainDnsFailed = false;
$this->editDomainDnsMessage = '';
$this->forceSaveEditDns = false;
$this->resetErrorBag('editingDomain');
$this->showEditDomainModal = true;
$this->dispatch('open-edit-domain');
}
public function cancelEdit(): void
@@ -929,6 +987,8 @@ class Domains extends Component
$this->showEditDomainModal = false;
$this->editingIndex = null;
$this->editingDomain = '';
$this->editingDomainParts = DomainUrlParts::empty();
$this->editingDomainPartsChanged = false;
$this->editingServiceApplicationId = null;
$this->editDomainDnsFailed = false;
$this->editDomainDnsMessage = '';
@@ -945,6 +1005,9 @@ class Domains extends Component
return;
}
if ($this->editingDomainPartsChanged) {
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
}
$this->validateOnly('editingDomain');
$app = $this->findServiceApp($this->editingServiceApplicationId);
@@ -1130,6 +1193,8 @@ class Domains extends Component
}
$this->newDomain = $domain;
$this->newDomainParts = DomainUrlParts::split($domain);
$this->newDomainPartsChanged = true;
$this->updatedNewDomain();
} catch (\Throwable $e) {
handleError($e, $this);
@@ -44,6 +44,14 @@ class All extends Component
public int $perPage = 10;
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public bool $is_env_sorting_enabled = false;
public bool $use_build_secrets = false;
+34 -8
View File
@@ -25,6 +25,8 @@ class GetLogs extends Component
{
public const MAX_LOG_LINES = 50000;
public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024;
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
public string $outputs = '';
@@ -154,14 +156,12 @@ class GetLogs extends Component
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} else {
$command = "docker logs -n {$this->numberOfLines} -t {$this->container}";
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
}
} else {
if ($this->server->isSwarm()) {
@@ -170,22 +170,39 @@ class GetLogs extends Component
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} else {
$command = "docker logs -n {$this->numberOfLines} {$this->container}";
if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0];
}
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
}
}
$command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES);
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Collect new logs into temporary variable first to prevent flickering
// (avoids clearing output before new data is ready)
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
$logChunks = [];
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) {
$accumulatedBytes = 0;
$truncated = false;
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
if ($truncated) {
return;
}
$remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes;
$outputBytes = strlen($output);
if ($outputBytes > $remainingBytes) {
$logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes)));
$truncated = true;
return;
}
$logChunks[] = removeAnsiColors($output);
$accumulatedBytes += $outputBytes;
});
$newOutputs = implode('', $logChunks);
@@ -198,6 +215,10 @@ class GetLogs extends Component
})->join("\n");
}
if ($truncated) {
$newOutputs .= "\n\n[... Output truncated at 5MB limit ...]";
}
// Only update outputs after new data is ready (atomic update prevents flicker)
$this->outputs = $newOutputs;
}
@@ -239,6 +260,7 @@ class GetLogs extends Component
$command = $command[0];
}
$command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES);
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
@@ -252,20 +274,19 @@ class GetLogs extends Component
return;
}
$output = removeAnsiColors($output);
$outputBytes = strlen($output);
if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
if ($remaining > 0) {
$logChunks[] = substr($output, 0, $remaining);
$logChunks[] = removeAnsiColors(substr($output, 0, $remaining));
}
$truncated = true;
return;
}
$logChunks[] = $output;
$logChunks[] = removeAnsiColors($output);
$accumulatedBytes += $outputBytes;
});
@@ -287,6 +308,11 @@ class GetLogs extends Component
return sanitizeLogsForExport($allLogs);
}
private function boundedLogCommand(string $command, int $maxBytes): string
{
return "({$command}) 2>&1 | head -c ".($maxBytes + 1);
}
public function render()
{
return view('livewire.project.shared.get-logs');
@@ -58,6 +58,15 @@ class VolumeBackups extends Component
public int $timeout = 3600;
public int $perPage = 10;
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->resetPage();
}
public bool $delete_backup_s3 = false;
public Collection $availableS3Storages;
@@ -316,7 +325,7 @@ class VolumeBackups extends Component
public function render()
{
$executions = $this->backup?->executions()->paginate(10);
$executions = $this->backup?->executions()->paginate($this->perPage);
return view('livewire.project.shared.storages.volume-backups', [
'executions' => $executions ?? collect(),
+16 -3
View File
@@ -163,6 +163,7 @@ class Navbar extends Component
$previousStatus = $this->proxyStatus;
$this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$this->dispatchProxyConfigurationState();
// If event contains activityId, open activity monitor
if ($event && isset($event['activityId'])) {
@@ -227,6 +228,16 @@ class Navbar extends Component
{
$this->server->refresh();
$this->server->load('settings');
$this->dispatchProxyConfigurationState();
}
private function dispatchProxyConfigurationState(): void
{
$this->dispatch(
'proxy-configuration-state-changed',
pending: $this->server->hasPendingProxyConfiguration(),
traefikOutdated: $this->server->hasCurrentTraefikOutdatedInfo(),
);
}
public function refreshSentinelStatus($event = null): void
@@ -248,10 +259,12 @@ class Navbar extends Component
return false;
}
// Check if server has outdated info stored
$outdatedInfo = $this->server->traefik_outdated_info;
return $this->server->hasCurrentTraefikOutdatedInfo();
}
return ! empty($outdatedInfo) && isset($outdatedInfo['type']);
public function getHasPendingProxyConfigurationProperty(): bool
{
return $this->server->hasPendingProxyConfiguration();
}
public function render()
+5 -1
View File
@@ -161,6 +161,7 @@ class Proxy extends Component
$this->server->proxy->redirect_url = $this->redirectUrl;
$this->server->save();
$this->server->setupDefaultRedirect();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -175,6 +176,7 @@ class Proxy extends Component
$this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true);
SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->save();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration reset to default.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -276,7 +278,9 @@ class Proxy extends Component
// Check if we have outdated info stored for this server (faster than computing)
$outdatedInfo = $this->server->traefik_outdated_info;
if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') {
$storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v');
$detectedCurrentVersion = ltrim($currentVersion, 'v');
if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') {
// Use the upgrade_target field if available (e.g., "v3.6")
if (isset($outdatedInfo['upgrade_target'])) {
return str_starts_with($outdatedInfo['upgrade_target'], 'v')
@@ -11,6 +11,12 @@ class DynamicConfigurations extends Component
{
use AuthorizesRequests;
public const MAX_CONFIGURATION_FILE_SIZE_BYTES = 1024 * 1024;
public const MAX_TOTAL_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;
public const MAX_CONFIGURATION_FILES = 100;
public ?Server $server = null;
public $parameters = [];
@@ -44,15 +50,36 @@ class DynamicConfigurations extends Component
return handleError($e, $this);
}
$proxy_path = $this->server->proxyPath();
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server);
$fileLimit = self::MAX_CONFIGURATION_FILES + 1;
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic | head -n {$fileLimit}"], $this->server);
$files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file));
$files = $files->map(fn ($file) => trim($file));
$files = $files->sort();
$contents = collect([]);
foreach ($files as $file) {
$skippedFiles = collect([]);
$totalBytes = 0;
if ($files->count() > self::MAX_CONFIGURATION_FILES) {
$skippedFiles->push('additional files');
}
foreach ($files->take(self::MAX_CONFIGURATION_FILES) as $file) {
$without_extension = str_replace('.', '|', $file);
$content = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server);
$contents[$without_extension] = $content ?? '';
$filePath = escapeshellarg("{$proxy_path}/dynamic/{$file}");
$readLimit = self::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1;
$content = instant_remote_process(["head -c {$readLimit} {$filePath}"], $this->server);
$content = $content ?? '';
$contentBytes = strlen($content);
if ($contentBytes > self::MAX_CONFIGURATION_FILE_SIZE_BYTES || $totalBytes + $contentBytes > self::MAX_TOTAL_CONFIGURATION_SIZE_BYTES) {
$skippedFiles->push($file);
continue;
}
$contents[$without_extension] = $content;
$totalBytes += $contentBytes;
}
if ($skippedFiles->isNotEmpty()) {
$this->dispatch('warning', 'Some dynamic configurations were not loaded because they exceed the safe display limits: '.$skippedFiles->implode(', '));
}
$this->contents = $contents;
$this->dispatch('$refresh');
+5
View File
@@ -20,6 +20,9 @@ class Index extends Component
#[Validate('nullable|string|max:255|url')]
public ?string $fqdn = null;
#[Validate('boolean')]
public bool $is_dashboard_force_https_enabled = true;
#[Validate('required|integer|min:1025|max:65535')]
public int $public_port_min;
@@ -68,6 +71,7 @@ class Index extends Component
$this->server = Server::findOrFail(0);
}
$this->fqdn = $this->settings->fqdn;
$this->is_dashboard_force_https_enabled = $this->settings->is_dashboard_force_https_enabled;
$this->public_port_min = $this->settings->public_port_min;
$this->public_port_max = $this->settings->public_port_max;
$this->instance_name = $this->settings->instance_name;
@@ -91,6 +95,7 @@ class Index extends Component
$this->authorize('update', $this->settings);
$this->validate();
$this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn;
$this->settings->is_dashboard_force_https_enabled = $this->is_dashboard_force_https_enabled;
$this->settings->public_port_min = $this->public_port_min;
$this->settings->public_port_max = $this->public_port_max;
$this->settings->instance_name = $this->instance_name;
+14 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Uri;
@@ -26,7 +27,11 @@ class Create extends Component
public string $bucket;
public string $endpoint;
public string $endpoint = '';
public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $endpointPartsChanged = false;
public S3Storage $storage;
@@ -76,6 +81,9 @@ class Create extends Component
try {
$this->authorize('create', S3Storage::class);
if ($this->endpointPartsChanged) {
$this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
}
$this->endpoint = $this->normalizeEndpoint($this->endpoint);
$this->validate();
$this->storage = new S3Storage;
@@ -101,6 +109,11 @@ class Create extends Component
}
}
public function updatedEndpointParts(): void
{
$this->endpointPartsChanged = true;
}
private function connectionErrorDescription(\Throwable $exception): string
{
$settingsUrl = route('settings.advanced').'#endpoint-section';
+42 -5
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
@@ -24,6 +25,10 @@ class Form extends Component
public string $endpoint;
public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $endpointPartsChanged = false;
public string $bucket;
public string $region;
@@ -101,6 +106,8 @@ class Form extends Component
$this->name = $this->storage->name;
$this->description = $this->storage->description;
$this->endpoint = $this->storage->endpoint;
$this->endpointParts = DomainUrlParts::split($this->endpoint);
$this->endpointPartsChanged = false;
$this->bucket = $this->storage->bucket;
$this->region = $this->storage->region;
$this->key = $this->storage->key;
@@ -122,20 +129,42 @@ class Form extends Component
public function testConnection()
{
$testedStorage = null;
try {
$this->authorize('validateConnection', $this->storage);
if ($this->endpointPartsChanged) {
$this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
}
$testedStorage = new S3Storage;
$testedStorage->uuid = $this->storage->uuid;
$testedStorage->team_id = $this->storage->team_id;
$testedStorage->unusable_email_sent = $this->storage->unusable_email_sent;
$testedStorage->name = $this->name;
$testedStorage->description = $this->description;
$testedStorage->endpoint = $this->endpoint;
$testedStorage->bucket = $this->bucket;
$testedStorage->region = $this->region;
$testedStorage->key = $this->key;
$testedStorage->secret = $this->secret;
$this->storage->testConnection(shouldSave: true);
$testedStorage->testConnection();
// Update component property to reflect the new validation status
$this->isUsable = $this->storage->is_usable;
$this->isUsable = $testedStorage->is_usable;
$this->storage->is_usable = $testedStorage->is_usable;
$this->storage->unusable_email_sent = $testedStorage->unusable_email_sent;
$this->storage->save();
$this->dispatch('storage-status-changed', isUsable: $this->isUsable);
return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.');
} catch (\Throwable $e) {
// Refresh model and sync to get the latest state
$this->storage->refresh();
$this->isUsable = $this->storage->is_usable;
if ($testedStorage) {
$this->isUsable = $testedStorage->is_usable;
$this->storage->is_usable = $testedStorage->is_usable;
$this->storage->unusable_email_sent = $testedStorage->unusable_email_sent;
$this->storage->save();
}
$this->dispatch('storage-status-changed', isUsable: $this->isUsable);
$this->dispatch('error', 'Failed to test connection.', $e->getMessage());
@@ -147,6 +176,9 @@ class Form extends Component
{
try {
$this->authorize('update', $this->storage);
if ($this->endpointPartsChanged) {
$this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
}
DB::transaction(function () {
$this->validate();
@@ -176,4 +208,9 @@ class Form extends Component
return handleError($e, $this);
}
}
public function updatedEndpointParts(): void
{
$this->endpointPartsChanged = true;
}
}
+10 -1
View File
@@ -16,6 +16,8 @@ class AdminView extends Component
public string $sort = 'name_asc';
public int $perPage = 10;
public function mount()
{
if (! isInstanceAdmin()) {
@@ -42,6 +44,13 @@ class AdminView extends Component
$this->resetPage();
}
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->resetPage();
}
public function submitSearch(): void
{
if (! isInstanceAdmin()) {
@@ -103,7 +112,7 @@ class AdminView extends Component
->when($this->sort === 'email_desc', fn ($query) => $query->orderByDesc('email'))
->when($this->sort === 'name_asc', fn ($query) => $query->orderBy('name'))
->orderBy('id')
->paginate(10);
->paginate($this->perPage);
return view('livewire.team.admin-view', [
'users' => $users,
+13
View File
@@ -47,6 +47,18 @@ trait BuildsResponse
// app/env secrets
'value', 'real_value', 'http_basic_auth_password',
// free-form commands / configurations can embed credentials
'git_full_url',
'install_command', 'build_command', 'start_command',
'health_check_command', 'health_check_response_text',
'custom_docker_run_options', 'pre_deployment_command', 'post_deployment_command',
'docker_compose_custom_start_command', 'docker_compose_custom_build_command',
'custom_nginx_configuration',
// raw database configuration blobs
'postgres_conf', 'mysql_conf', 'mariadb_conf', 'mongo_conf',
'redis_conf', 'keydb_conf',
// database connection strings embed credentials
'internal_db_url', 'external_db_url', 'init_scripts',
@@ -58,6 +70,7 @@ trait BuildsResponse
// bulky / unsafe blobs
'dockerfile', 'docker_compose', 'docker_compose_raw',
'last_saved_proxy_configuration',
'custom_labels', 'environment_variables',
'environment_variables_preview', 'validation_logs',
'server_metadata', 'logs', 'configuration_snapshot',
+7
View File
@@ -104,6 +104,13 @@ class CancelDeployment extends Tool
'server_id' => $deployment->server_id,
]);
try {
$deploymentServer = Server::whereTeamId($teamId)->find($deployment->server_id);
next_after_cancel($deploymentServer);
} catch (\Throwable $e) {
\Log::warning("Failed to advance deployment queue after cancelling deployment {$deployment->id}: {$e->getMessage()}");
}
return $this->mcpSuccess($request, $this->respond([
'ok' => true,
'message' => 'Deployment cancelled successfully.',
+13 -2
View File
@@ -122,6 +122,8 @@ class Application extends BaseModel
{
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
private static $parserVersion = '5';
protected $fillable = [
@@ -2109,6 +2111,9 @@ class Application extends BaseModel
$workdir = rtrim($this->base_directory, '/');
$composeFile = $this->docker_compose_location;
$fileList = collect([".$workdir$composeFile"]);
$composeFilePath = escapeshellarg(".$workdir$composeFile");
$composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1;
$readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi";
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
if (! $gitRemoteStatus['is_accessible']) {
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.');
@@ -2139,7 +2144,7 @@ class Application extends BaseModel
'git sparse-checkout init',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
"cat .$workdir$composeFile",
$readComposeFile,
]);
} else {
$commands = collect([
@@ -2151,11 +2156,14 @@ class Application extends BaseModel
'git sparse-checkout init --cone',
"git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD',
"cat .$workdir$composeFile",
$readComposeFile,
]);
}
try {
$composeFileContent = instant_remote_process($commands, $this->destination->server);
if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) {
throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.');
}
} catch (\Exception $e) {
// Restore original values on failure only
$this->docker_compose_location = $initialDockerComposeLocation;
@@ -2171,6 +2179,9 @@ class Application extends BaseModel
}
throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.');
}
if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) {
throw $e;
}
throw new RuntimeException('Failed to read the Docker Compose file from the repository.');
} finally {
// Cleanup only - restoration happens in catch block
+1 -13
View File
@@ -2,9 +2,6 @@
namespace App\Models;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection as V5ResourceConnection;
use App\Support\V5\V5Feature;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -57,11 +54,7 @@ class Environment extends BaseModel
public function isEmpty()
{
return (! V5Feature::enabled() || (
! V5Application::query()->where('environment_id', $this->id)->exists() &&
! V5ResourceConnection::query()->where('environment_id', $this->id)->exists()
)) &&
$this->applications()->count() == 0 &&
return $this->applications()->count() == 0 &&
$this->redis()->count() == 0 &&
$this->postgresqls()->count() == 0 &&
$this->mysqls()->count() == 0 &&
@@ -83,11 +76,6 @@ class Environment extends BaseModel
return $this->hasMany(Application::class);
}
public function v5Applications()
{
return $this->hasMany(V5Application::class);
}
public function postgresqls()
{
return $this->hasMany(StandalonePostgresql::class);
+6
View File
@@ -9,6 +9,10 @@ use Spatie\Url\Url;
class InstanceSettings extends Model
{
protected $attributes = [
'is_dashboard_force_https_enabled' => true,
];
protected $fillable = [
'public_ipv4',
'public_ipv6',
@@ -51,6 +55,7 @@ class InstanceSettings extends Model
'webhook_allow_localhost',
'avatar_storage_type',
'avatar_s3_storage_id',
'is_dashboard_force_https_enabled',
];
protected $hidden = [
@@ -89,6 +94,7 @@ class InstanceSettings extends Model
'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'array',
'webhook_allow_localhost' => 'boolean',
'is_dashboard_force_https_enabled' => 'boolean',
];
protected static function booted(): void
+24 -3
View File
@@ -138,9 +138,9 @@ class LocalFileVolume extends BaseModel
return;
}
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
$content = $this->readRemoteFileContent($escapedPath, $server);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) {
$content = self::BINARY_PLACEHOLDER;
}
$this->content = $content;
@@ -161,6 +161,27 @@ class LocalFileVolume extends BaseModel
return $size > self::MAX_CONTENT_SIZE;
}
/**
* Cap the remote read itself so a file that grows after the size check
* cannot be fully slurped into PHP memory.
*/
protected function readRemoteFileContent(string $escapedPath, $server): string
{
$readLimit = self::MAX_CONTENT_SIZE + 1;
$content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false);
return self::contentFromBoundedRead($content);
}
public static function contentFromBoundedRead(?string $content): string
{
if (strlen((string) $content) > self::MAX_CONTENT_SIZE) {
return self::TOO_LARGE_PLACEHOLDER;
}
return (string) $content;
}
public function deleteStorageOnServer()
{
if ($this->is_host_file) {
@@ -253,7 +274,7 @@ class LocalFileVolume extends BaseModel
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
} else {
$this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
$this->content = $this->readRemoteFileContent($escapedPath, $server);
}
$this->is_directory = false;
$this->save();
+8 -15
View File
@@ -2,9 +2,6 @@
namespace App\Models;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection as V5ResourceConnection;
use App\Support\V5\V5Feature;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -147,11 +144,7 @@ class Project extends BaseModel
public function isEmpty()
{
return (! V5Feature::enabled() || (
! V5Application::query()->where('project_id', $this->id)->exists() &&
! V5ResourceConnection::query()->where('project_id', $this->id)->exists()
)) &&
$this->applications()->count() == 0 &&
return $this->applications()->count() == 0 &&
$this->redis()->count() == 0 &&
$this->postgresqls()->count() == 0 &&
$this->mysqls()->count() == 0 &&
@@ -166,13 +159,13 @@ class Project extends BaseModel
public function databases(array $with = []): Collection
{
return $this->postgresqls()->with($with)->get()
->merge($this->redis()->with($with)->get())
->merge($this->mongodbs()->with($with)->get())
->merge($this->mysqls()->with($with)->get())
->merge($this->mariadbs()->with($with)->get())
->merge($this->keydbs()->with($with)->get())
->merge($this->dragonflies()->with($with)->get())
->merge($this->clickhouses()->with($with)->get());
->concat($this->redis()->with($with)->get())
->concat($this->mongodbs()->with($with)->get())
->concat($this->mysqls()->with($with)->get())
->concat($this->mariadbs()->with($with)->get())
->concat($this->keydbs()->with($with)->get())
->concat($this->dragonflies()->with($with)->get())
->concat($this->clickhouses()->with($with)->get());
}
public function navigateTo()
+1 -1
View File
@@ -198,7 +198,7 @@ class S3Storage extends BaseModel
try {
$mail = new MailMessage;
$mail->subject('Coolify: S3 Storage Connection Error');
$mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]);
$mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => base_url().'/storages/'.$this->uuid]);
// Load the team with its members and their roles explicitly
$team = $this->team()->with(['members' => function ($query) {
+1
View File
@@ -11,6 +11,7 @@ class ScheduledDatabaseBackup extends BaseModel
protected function casts(): array
{
return [
'dump_all' => 'boolean',
'database_backup_retention_max_storage_locally' => 'float',
'database_backup_retention_max_storage_s3' => 'float',
];
+52 -4
View File
@@ -731,11 +731,12 @@ class Server extends BaseModel
];
if ($schema === 'https') {
$traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = [
0 => 'redirect-to-https',
];
$traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings);
$traefik_dynamic_conf['http']['routers']['coolify-https'] = [
'middlewares' => [
0 => 'gzip',
],
'entryPoints' => [
0 => 'https',
],
@@ -789,8 +790,10 @@ class Server extends BaseModel
$url = Url::fromString($settings->fqdn);
$host = $url->getHost();
$schema = $url->getScheme();
$siteAddress = $this->dashboardCaddySiteAddress($settings, $schema, $host);
$caddy_file = "
$schema://$host {
$siteAddress {
encode zstd gzip
handle /app/* {
reverse_proxy coolify-realtime:6001
}
@@ -815,6 +818,24 @@ $schema://$host {
], $this);
}
public function dashboardHttpMiddlewares(InstanceSettings $settings): array
{
if ($settings->is_dashboard_force_https_enabled) {
return ['redirect-to-https'];
}
return ['gzip'];
}
public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string
{
if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) {
return "http://{$host}, https://{$host}";
}
return "{$schema}://{$host}";
}
public function proxyPath()
{
$base_path = config('constants.coolify.base_config_path');
@@ -837,6 +858,33 @@ $schema://$host {
return data_get($this->proxy, 'type');
}
public function hasPendingProxyConfiguration(): bool
{
if ($this->proxy->get('status') !== 'running') {
return false;
}
$savedSettings = $this->proxy->get('last_saved_settings');
$appliedSettings = $this->proxy->get('last_applied_settings');
return filled($savedSettings) && filled($appliedSettings) && $savedSettings !== $appliedSettings;
}
public function hasCurrentTraefikOutdatedInfo(): bool
{
if ($this->proxyType() !== ProxyTypes::TRAEFIK->value) {
return false;
}
$detectedVersion = ltrim((string) $this->detected_traefik_version, 'v');
$storedVersion = ltrim((string) data_get($this->traefik_outdated_info, 'current'), 'v');
$type = data_get($this->traefik_outdated_info, 'type');
return filled($detectedVersion)
&& $storedVersion === $detectedVersion
&& in_array($type, ['patch_update', 'minor_upgrade'], true);
}
public function scopeWithProxy(): Builder
{
return $this->proxy->modelScope();
+11
View File
@@ -31,6 +31,7 @@ class ServiceApplication extends BaseModel
'is_include_timestamps',
'is_gzip_enabled',
'is_stripprefix_enabled',
'is_force_https_enabled',
'last_online_at',
'is_migrated',
];
@@ -44,11 +45,16 @@ class ServiceApplication extends BaseModel
'domain_dns_statuses',
];
protected $attributes = [
'is_force_https_enabled' => true,
];
protected function casts(): array
{
return [
'domain_dns_statuses' => 'array',
'noindex_domains' => 'array',
'is_force_https_enabled' => 'boolean',
];
}
@@ -124,6 +130,11 @@ class ServiceApplication extends BaseModel
return data_get($this, 'is_gzip_enabled', true);
}
public function isForceHttpsEnabled(): bool
{
return $this->is_force_https_enabled;
}
public function type()
{
return 'service';
+1 -17
View File
@@ -4,12 +4,10 @@ namespace App\Models;
use App\Actions\User\RevokeUserTeamTokens;
use App\Events\ServerReachabilityChanged;
use App\Jobs\V5TeardownTeamJob;
use App\Notifications\Channels\SendsDiscord;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\Channels\SendsPushover;
use App\Notifications\Channels\SendsSlack;
use App\Support\V5\V5Feature;
use App\Traits\HasNotificationSettings;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -81,20 +79,6 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
});
static::deleting(function (Team $team) {
// Best-effort on-host teardown of this team's v5 resources BEFORE the
// DB cascade removes the servers/applications/private keys. Captured
// synchronously into a queued job so an unreachable host cannot block
// or fail the team deletion (see V5TeardownTeamJob). Guarded so a v5
// teardown problem never breaks v4 team deletion. This is disabled
// with the rest of v5 outside development environments.
if (V5Feature::enabled()) {
try {
V5TeardownTeamJob::dispatchForTeam($team);
} catch (\Throwable $exception) {
report($exception);
}
}
RevokeUserTeamTokens::forTeam($team->id);
foreach ($team->privateKeys as $key) {
@@ -107,7 +91,7 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
// Delete non-instance-wide sources owned by this team
$teamSources = GithubApp::where('team_id', $team->id)->get()
->merge(GitlabApp::where('team_id', $team->id)->get());
->concat(GitlabApp::where('team_id', $team->id)->get());
foreach ($teamSources as $source) {
$source->delete();
}
-20
View File
@@ -3,10 +3,7 @@
namespace App\Providers;
use App\Models\PersonalAccessToken;
use App\Models\V5\Application;
use App\Support\V5\V5Feature;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
@@ -26,11 +23,6 @@ class AppServiceProvider extends ServiceProvider
{
$this->configureCommands();
if (V5Feature::enabled()) {
$this->loadMigrationsFrom(database_path('migrations-v5'));
$this->configureMorphMap();
}
$this->configureModels();
$this->configurePasswords();
$this->configureSanctumModel();
@@ -45,18 +37,6 @@ class AppServiceProvider extends ServiceProvider
}
}
/**
* Map v5 models to stable morph aliases so polymorphic rows survive class
* renames. Deliberately NOT enforced: v4 polymorphic relations store FQCNs
* and must keep resolving them.
*/
private function configureMorphMap(): void
{
Relation::morphMap([
'v5.application' => Application::class,
]);
}
private function configureModels(): void
{
// Disabled because it's causing issues with the application
-14
View File
@@ -38,10 +38,6 @@ use App\Models\SwarmDocker;
use App\Models\Tag;
use App\Models\Team;
use App\Models\TelegramNotificationSettings;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Cluster as V5Cluster;
use App\Models\V5\ResourceConnection as V5ResourceConnection;
use App\Models\V5\Server as V5Server;
use App\Models\WebhookNotificationSettings;
use App\Policies\ApiTokenPolicy;
use App\Policies\ApplicationPolicy;
@@ -69,10 +65,6 @@ use App\Policies\StandaloneDockerPolicy;
use App\Policies\SwarmDockerPolicy;
use App\Policies\TagPolicy;
use App\Policies\TeamPolicy;
use App\Policies\V5\ApplicationPolicy as V5ApplicationPolicy;
use App\Policies\V5\ClusterPolicy as V5ClusterPolicy;
use App\Policies\V5\ResourceConnectionPolicy as V5ResourceConnectionPolicy;
use App\Policies\V5\ServerPolicy as V5ServerPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Laravel\Sanctum\PersonalAccessToken;
@@ -138,12 +130,6 @@ class AuthServiceProvider extends ServiceProvider
CloudInitScript::class => CloudInitScriptPolicy::class,
Tag::class => TagPolicy::class,
// V5 policies - scoped to the current team resolved from the request
V5Application::class => V5ApplicationPolicy::class,
V5Cluster::class => V5ClusterPolicy::class,
V5ResourceConnection::class => V5ResourceConnectionPolicy::class,
V5Server::class => V5ServerPolicy::class,
];
/**
+9 -5
View File
@@ -75,12 +75,16 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider
protected function gate(): void
{
Gate::define('viewHorizon', function ($user) {
$root_user = User::find(0);
Gate::define('viewHorizon', function (User $user) {
if ($user->id === 0) {
return true;
}
return in_array($user->email, [
$root_user->email,
]);
return str(config()->string('horizon.allowed_emails'))
->lower()
->explode(',')
->map(fn (string $email) => trim($email))
->contains($user->email);
});
}
}
-14
View File
@@ -2,7 +2,6 @@
namespace App\Providers;
use App\Support\V5\V5Feature;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
@@ -35,13 +34,6 @@ class RouteServiceProvider extends ServiceProvider
Route::prefix('webhooks')
->group(base_path('routes/webhooks.php'));
if (V5Feature::enabled()) {
Route::middleware('v5.web')
->prefix('v5')
->as('v5.')
->group(base_path('routes/v5.php'));
}
Route::middleware('web')
->group(base_path('routes/web.php'));
});
@@ -63,12 +55,6 @@ class RouteServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
});
if (V5Feature::enabled()) {
RateLimiter::for('v5', function (Request $request) {
return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip());
});
}
RateLimiter::for('feedback', function (Request $request) {
return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip());
});
+1 -1
View File
@@ -71,7 +71,7 @@ class AvatarStorageService
protected function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter
{
if ($storageType !== 's3') {
return Storage::disk('local');
return Storage::disk('images');
}
$storage = S3Storage::query()->whereKey($s3StorageId)->where('is_usable', true)->first();
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Support;
final class BackupCompression
{
public static function cpuPercentage(int|string|null $configuredPercentage): int
{
$percentage = (int) $configuredPercentage;
return in_array($percentage, [25, 50, 75, 100], true) ? $percentage : 25;
}
public static function compressorCommand(int $cpuPercentage): string
{
$cpuPercentage = self::cpuPercentage($cpuPercentage);
return "if command -v pigz >/dev/null 2>&1; then printf 'pigz -3 -p %s' \"\$(( (\$(nproc) * {$cpuPercentage} + 99) / 100 ))\"; else printf 'gzip -3'; fi";
}
}
+6 -2
View File
@@ -90,13 +90,17 @@ class DatabaseBackupFileValidator
public static function containsPostgresqlProgramExecution(string $sql): bool
{
if (str_starts_with($sql, 'PGDMP')) {
return false;
}
$withoutComments = self::stripSqlComments($sql);
if (preg_match('/^\s*\\\\(?:!|copy\b.*\bprogram\b)/mi', $withoutComments) === 1) {
if (preg_match('/^\s*\\\\(?:!|copy\b[^\r\n]*\bprogram\b|(?:o|g)\s*\|)/mi', $withoutComments) === 1) {
return true;
}
return preg_match('/\bcopy\b[\s\S]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
}
private static function extensionFor(string $name): ?string
+1 -1
View File
@@ -12,7 +12,7 @@ class Links extends Component
{
public Collection $links;
public function __construct(public Service $service, public bool $fullWidth = false)
public function __construct(public Service $service, public bool $fullWidth = false, public bool $compact = false)
{
$this->links = collect([]);
$service->applications()->get()->map(function ($application) {
+36 -17
View File
@@ -263,6 +263,15 @@ function dockerStopCommand(int $timeout, string $containers, Server|string|null
return $command;
}
function dockerRemoveCommandWithTimeout(string $container, int $timeout = 60, int $killAfter = 10): string
{
$container = escapeShellValue($container);
$script = "if command -v timeout >/dev/null 2>&1; then timeout -k {$killAfter}s {$timeout}s docker rm -f {$container}; exit_code=\$?; else exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; fi; exit \$exit_code";
return 'bash -c '.escapeShellValue($script);
}
function escapeShellValue(string $value): string
{
return "'".str_replace("'", "'\\''", $value)."'";
@@ -518,6 +527,10 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
$path = $url->getPath();
$host_without_www = str($host)->replace('www.', '');
$schema = $url->getScheme();
$siteAddress = "{$schema}://{$host}";
if ($schema === 'https' && ! $is_force_https_enabled) {
$siteAddress = "http://{$host}, https://{$host}";
}
$port = $url->getPort();
$handle = 'handle_path';
if (! $is_stripprefix_enabled) {
@@ -529,7 +542,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
if (is_null($port) && $predefinedPort) {
$port = $predefinedPort;
}
$labels->push("caddy_{$loop}={$schema}://{$host}");
$labels->push("caddy_{$loop}={$siteAddress}");
if (isNoindexDomain($domain, $noindex_domains)) {
// Caddy's header directive takes either inline arguments or a block,
// never both, so -Server has to move into the block alongside it.
@@ -549,11 +562,12 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
if ($is_gzip_enabled) {
$labels->push("caddy_{$loop}.encode=zstd gzip");
}
$redirect_schema = $is_force_https_enabled ? $schema : '{scheme}';
if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) {
$labels->push("caddy_{$loop}.redir={$schema}://www.{$host}{uri}");
$labels->push("caddy_{$loop}.redir={$redirect_schema}://www.{$host}{uri}");
}
if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) {
$labels->push("caddy_{$loop}.redir={$schema}://{$host_without_www}{uri}");
$labels->push("caddy_{$loop}.redir={$redirect_schema}://{$host_without_www}{uri}");
}
if ($is_http_basic_auth_enabled) {
$labels->push("caddy_{$loop}.basicauth.{$http_basic_auth_username}=\"{$hashedPassword}\"");
@@ -563,7 +577,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
return $labels->sort();
}
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null)
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true)
{
$labels = collect([]);
$labels->push('traefik.enable=true');
@@ -646,14 +660,15 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$to_www_name = "{$loop}-{$uuid}-to-www";
$to_non_www_name = "{$loop}-{$uuid}-to-non-www";
$redirect_capture_prefix = $escape_redirect_replacement_for_compose ? '$$' : '$';
$redirect_to_non_www = [
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.regex=^(http|https)://www\.(.+)",
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement=\$\${1}://\$\${2}",
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement={$redirect_capture_prefix}{1}://{$redirect_capture_prefix}{2}",
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.permanent=false",
];
$redirect_to_www = [
"traefik.http.middlewares.{$to_www_name}.redirectregex.regex=^(http|https)://(?:www\.)?(.+)",
"traefik.http.middlewares.{$to_www_name}.redirectregex.replacement=\$\${1}://www.\$\${2}",
"traefik.http.middlewares.{$to_www_name}.redirectregex.replacement={$redirect_capture_prefix}{1}://www.{$redirect_capture_prefix}{2}",
"traefik.http.middlewares.{$to_www_name}.redirectregex.permanent=false",
];
if ($schema === 'https') {
@@ -695,8 +710,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$middlewares->push($middleware_name);
});
if ($middlewares->isNotEmpty()) {
$middlewares = $middlewares->join(',');
$labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares}");
$labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares->join(',')}");
}
} else {
$middlewares = collect([]);
@@ -724,8 +738,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$middlewares->push($middleware_name);
});
if ($middlewares->isNotEmpty()) {
$middlewares = $middlewares->join(',');
$labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares}");
$labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares->join(',')}");
}
}
$labels->push("traefik.http.routers.{$https_label}.tls=true");
@@ -738,15 +751,17 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port");
$labels->push("traefik.http.routers.{$http_label}.service={$http_label}");
}
$middlewares = collect([]);
if ($is_noindex) {
$middlewares->push($noindex_name);
}
if ($is_force_https_enabled) {
$middlewares->push('redirect-to-https');
$httpMiddlewares = collect([]);
if ($is_noindex) {
$httpMiddlewares->push($noindex_name);
}
$httpMiddlewares->push('redirect-to-https');
} else {
$httpMiddlewares = $middlewares;
}
if ($middlewares->isNotEmpty()) {
$labels->push("traefik.http.routers.{$http_label}.middlewares={$middlewares->join(',')}");
if ($httpMiddlewares->isNotEmpty()) {
$labels->push("traefik.http.routers.{$http_label}.middlewares={$httpMiddlewares->join(',')}");
}
} else {
// Set labels for http
@@ -876,6 +891,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
));
break;
}
@@ -892,6 +908,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
));
$labels = $labels->merge(fqdnLabelsForCaddy(
network: $application->destination->network,
@@ -932,6 +949,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
));
break;
case ProxyTypes::CADDY->value:
@@ -962,6 +980,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
));
$labels = $labels->merge(fqdnLabelsForCaddy(
network: $application->destination->network,
+23 -4
View File
@@ -358,6 +358,19 @@ function parseDockerVolumeString(string $volumeString): array
];
}
function addTraefikDockerNetworkLabel(Collection $labels, string $network): Collection
{
$hasUserDefinedNetwork = $labels->contains(
fn ($label): bool => is_string($label) && str($label)->before('=')->is('traefik.docker.network')
);
if (! $hasUserDefinedNetwork) {
$labels->push("traefik.docker.network={$network}");
}
return $labels;
}
function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null): Collection
{
$uuid = data_get($resource, 'uuid');
@@ -1373,6 +1386,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
? $composeRedirect
: 'both';
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
}
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@@ -2675,13 +2691,16 @@ function serviceParser(Service $resource): Collection
$redirectDirection = in_array(data_get($originalResource, 'redirect'), ['www', 'non-www', 'both'], true)
? data_get($originalResource, 'redirect')
: 'both';
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
}
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@@ -2696,7 +2715,7 @@ function serviceParser(Service $resource): Collection
network: $network,
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@@ -2712,7 +2731,7 @@ function serviceParser(Service $resource): Collection
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@@ -2725,7 +2744,7 @@ function serviceParser(Service $resource): Collection
network: $network,
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
+12 -11
View File
@@ -107,8 +107,8 @@ function collectDockerNetworksByServer(Server $server)
}
function connectProxyToNetworks(Server $server)
{
['networks' => $networks] = collectDockerNetworksByServer($server);
if ($server->isSwarm()) {
['networks' => $networks] = collectDockerNetworksByServer($server);
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
@@ -118,19 +118,20 @@ function connectProxyToNetworks(Server $server)
"echo 'Successfully connected coolify-proxy to {$safe} network.'",
];
});
} else {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
return [
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null",
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
"echo 'Successfully connected coolify-proxy to {$safe} network.'",
];
});
return $commands->flatten();
}
return $commands->flatten();
return collect([
'for network in $(docker inspect $(docker ps --filter label=coolify.managed=true --format "{{.ID}}") --format=\'{{range $network, $_ := .NetworkSettings.Networks}}{{println $network}}{{end}}\' 2>/dev/null | sort -u); do',
' if [ -z "$network" ] || [ "$network" = "bridge" ] || [ "$network" = "host" ] || [ "$network" = "none" ] || [ "$network" = "default" ]; then',
' continue',
' fi',
' if docker network inspect "$network" >/dev/null 2>&1; then',
' docker network connect "$network" coolify-proxy >/dev/null 2>&1 || true',
' fi',
'done',
]);
}
/**
+3 -5
View File
@@ -167,13 +167,11 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
$isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
// If its a file & exists
$filesystemContent = instant_remote_process(["cat $fileLocation"], $server);
if ($fileVolume->is_based_on_git) {
$fileVolume->content = $filesystemContent;
}
$fileVolume->is_directory = false;
$fileVolume->save();
if ($fileVolume->is_based_on_git) {
$fileVolume->loadStorageOnServer();
}
} elseif ($isDir === 'OK') {
// If its a directory & exists
$fileVolume->content = null;
+4 -4
View File
@@ -3050,7 +3050,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $resource->uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $savedService->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
@@ -3065,7 +3065,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
network: $resource->destination->network,
uuid: $resource->uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $savedService->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
@@ -3080,7 +3080,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $resource->uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $savedService->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
@@ -3093,7 +3093,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
network: $resource->destination->network,
uuid: $resource->uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $savedService->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
+1
View File
@@ -95,6 +95,7 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array
$isComplexPipeCommand = (
$line->contains(' | sh') ||
$line->contains(' | bash') ||
$line->contains(' sh -c ') ||
($line->contains(' | ') && ($line->contains('||') || $line->contains('&&')))
);
+86 -932
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -15,7 +15,6 @@
"danharrin/livewire-rate-limiting": "^2.2.1",
"doctrine/dbal": "^4.4.4",
"guzzlehttp/guzzle": "^7.15.3",
"inertiajs/inertia-laravel": "^3.3",
"laravel/fortify": "^1.37.3",
"laravel/framework": "^12.65.0",
"laravel/horizon": "^5.48.2",
Generated
+1 -73
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": "9cfa94749243cbf2879e01939d7f86cb",
"content-hash": "971daeb1b3078a36428c0fb56bb895b7",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -1649,78 +1649,6 @@
],
"time": "2026-07-17T13:53:03+00:00"
},
{
"name": "inertiajs/inertia-laravel",
"version": "v3.3.0",
"source": {
"type": "git",
"url": "https://github.com/inertiajs/inertia-laravel.git",
"reference": "1e134f607ac6af9a77c35c110714a82cead80c40"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/1e134f607ac6af9a77c35c110714a82cead80c40",
"reference": "1e134f607ac6af9a77c35c110714a82cead80c40",
"shasum": ""
},
"require": {
"ext-json": "*",
"laravel/framework": "^11.35|^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.15.2|^8.0",
"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"
},
"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.3.0"
},
"time": "2026-08-04T09:15:41+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
+1 -1
View File
@@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => env('COOLIFY_VERSION') ?: '4.3.3',
'version' => env('COOLIFY_VERSION') ?: '4.3.9',
'helper_version' => '1.0.15',
'realtime_version' => '1.0.17',
'railpack_version' => '0.23.0',
+7
View File
@@ -35,6 +35,13 @@ return [
'throw' => false,
],
'images' => [
'driver' => 'local',
'root' => storage_path('app/images'),
'visibility' => 'private',
'throw' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
+12 -28
View File
@@ -1,17 +1,7 @@
<?php
use App\Support\V5\V5Feature;
use Illuminate\Support\Str;
$v5Enabled = V5Feature::enabledForEnvironment((string) env('APP_ENV', 'production'));
$v5ReconcileSupervisor = $v5Enabled ? [
'v5reconcile' => [
'autoScalingStrategy' => 'size',
'minProcesses' => env('HORIZON_V5_RECONCILE_MIN_PROCESSES', 1),
'maxProcesses' => env('HORIZON_V5_RECONCILE_MAX_PROCESSES', 1),
],
] : [];
return [
/*
@@ -40,6 +30,18 @@ return [
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Allowed Emails
|--------------------------------------------------------------------------
|
| A comma-separated list of email addresses that may access the Horizon
| dashboard in addition to the root user.
|
*/
'allowed_emails' => env('HORIZON_ALLOWED_EMAILS', ''),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
@@ -203,20 +205,6 @@ return [
'timeout' => env('HORIZON_TIMEOUT', 36000),
],
...($v5Enabled ? [
'v5reconcile' => [
'connection' => 'redis',
'balance' => env('HORIZON_V5_RECONCILE_BALANCE', 'false'),
'queue' => 'v5-reconcile',
'maxTime' => env('HORIZON_V5_RECONCILE_MAX_TIME', 0),
'maxJobs' => 200,
'memory' => 128,
'tries' => 1,
'nice' => 10,
'sleep' => 3,
'timeout' => env('HORIZON_V5_RECONCILE_TIMEOUT', 300),
],
] : []),
],
'environments' => [
@@ -237,10 +225,6 @@ return [
'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1),
'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1),
],
...$v5ReconcileSupervisor,
],
'development' => $v5ReconcileSupervisor,
'dev' => $v5ReconcileSupervisor,
'testing' => $v5ReconcileSupervisor,
],
];
@@ -0,0 +1,28 @@
<?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::table('service_applications', function (Blueprint $table) {
$table->boolean('is_force_https_enabled')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('service_applications', function (Blueprint $table) {
$table->dropColumn('is_force_https_enabled');
});
}
};
@@ -0,0 +1,28 @@
<?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::table('instance_settings', function (Blueprint $table) {
$table->boolean('is_dashboard_force_https_enabled')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('is_dashboard_force_https_enabled');
});
}
};
-149
View File
@@ -1311,146 +1311,6 @@ CREATE TABLE IF NOT EXISTS "users" (
"email_change_code_expires_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_clusters" (
"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,
"wireguard_interface" TEXT DEFAULT 'wg0' NOT NULL,
"wireguard_management_pool" TEXT DEFAULT '100.64.0.0/16' NOT NULL,
"wireguard_listen_port" INTEGER DEFAULT '51820' NOT NULL,
"container_network_pool" TEXT DEFAULT '10.210.0.0/16' NOT NULL,
"container_network_prefix" INTEGER DEFAULT '24' NOT NULL,
"namespaces" JSON,
"default_deny_containers" INTEGER DEFAULT true NOT NULL,
"coold_version" TEXT DEFAULT 'nightly' NOT NULL,
"corrosion_version" TEXT DEFAULT 'v1.0.0' NOT NULL,
"corrosion_gossip_port" INTEGER DEFAULT '8787' NOT NULL,
"corrosion_api_port" INTEGER DEFAULT '8080' NOT NULL,
"builder_enabled" INTEGER DEFAULT true NOT NULL,
"builder_capacity" INTEGER DEFAULT '2' NOT NULL,
"builder_cpu_quota" TEXT DEFAULT '200%' NOT NULL,
"builder_memory_max" TEXT DEFAULT '2G' NOT NULL,
"builder_timeout_secs" INTEGER NOT NULL DEFAULT '1800',
"last_cli_action" TEXT,
"last_cli_status" TEXT,
"last_cli_summary" TEXT,
"last_cli_ran_at" TEXT,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_servers" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"uuid" TEXT,
"team_id" INTEGER NOT NULL,
"cluster_id" INTEGER,
"created_by_user_id" INTEGER NOT NULL,
"private_key_id" INTEGER,
"name" TEXT NOT NULL,
"host" TEXT NOT NULL,
"ssh_user" TEXT NOT NULL,
"ssh_port" INTEGER DEFAULT '22' NOT NULL,
"status" TEXT DEFAULT 'installed' NOT NULL,
"ingress_type" TEXT,
"ingress_status" TEXT,
"capabilities" TEXT,
"builder_enabled" INTEGER DEFAULT false NOT NULL,
"builder_capacity" INTEGER DEFAULT '0' NOT NULL,
"builder_cpu_quota" TEXT DEFAULT '200%' NOT NULL,
"node_address" TEXT,
"wireguard_listen_port_override" INTEGER,
"wireguard_endpoint_override" TEXT,
"wireguard_management_ip" TEXT,
"wireguard_public_key" TEXT,
"container_subnets" JSON,
"canvas_x" INTEGER,
"canvas_y" INTEGER,
"last_bootstrapped_at" TEXT,
"last_bootstrap_action" TEXT,
"last_bootstrap_status" TEXT,
"last_bootstrap_output" TEXT,
"last_bootstrap_ran_at" TEXT,
"last_status_check" TEXT,
"last_status_output" TEXT,
"last_status_checked_at" TEXT,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_container_statuses" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
"server_id" INTEGER NOT NULL,
"container_id" TEXT NOT NULL,
"container_name" TEXT,
"image" TEXT,
"status" TEXT DEFAULT 'unknown' NOT NULL,
"status_message" TEXT,
"last_seen_at" TEXT,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_applications" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
"project_id" INTEGER NOT NULL,
"environment_id" INTEGER NOT NULL,
"server_id" INTEGER,
"created_by_user_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"image" TEXT NOT NULL,
"container_name" TEXT NOT NULL,
"status" TEXT DEFAULT 'creating' NOT NULL,
"status_message" TEXT,
"runtime_container_id" TEXT,
"mesh_namespace" TEXT DEFAULT 'default' NOT NULL,
"ingress_enabled" INTEGER DEFAULT false NOT NULL,
"internal_port" INTEGER,
"canvas_x" INTEGER DEFAULT '0' NOT NULL,
"canvas_y" INTEGER DEFAULT '0' NOT NULL,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_application_domains" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"application_id" INTEGER NOT NULL,
"domain" TEXT NOT NULL,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_resource_connections" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"team_id" INTEGER NOT NULL,
"project_id" INTEGER NOT NULL,
"environment_id" INTEGER NOT NULL,
"resource_one_type" TEXT NOT NULL,
"resource_one_id" INTEGER NOT NULL,
"resource_two_type" TEXT NOT NULL,
"resource_two_id" INTEGER NOT NULL,
"resource_pair_key" TEXT NOT NULL,
"created_by_user_id" INTEGER NOT NULL,
"created_at" TEXT,
"updated_at" TEXT
);
CREATE TABLE IF NOT EXISTS "v5_resource_connection_rules" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
"connection_id" INTEGER NOT NULL,
"source_resource_type" TEXT NOT NULL,
"source_resource_id" INTEGER NOT NULL,
"target_resource_type" TEXT NOT NULL,
"target_resource_id" INTEGER NOT NULL,
"protocol" TEXT DEFAULT 'tcp' NOT NULL,
"port" INTEGER NOT NULL,
"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,
@@ -1559,11 +1419,6 @@ CREATE INDEX IF NOT EXISTS "user_changelog_reads_release_tag_index" ON "user_cha
CREATE INDEX IF NOT EXISTS "user_changelog_reads_user_id_index" ON "user_changelog_reads" (user_id);
CREATE UNIQUE INDEX IF NOT EXISTS "user_changelog_reads_user_id_release_tag_unique" ON "user_changelog_reads" (user_id, release_tag);
CREATE UNIQUE INDEX IF NOT EXISTS "users_email_unique" ON "users" (email);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_applications_container_name_unique" ON "v5_applications" (container_name);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_application_domains_application_id_domain_unique" ON "v5_application_domains" (application_id, domain);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_resource_connections_team_id_resource_pair_key_unique" ON "v5_resource_connections" (team_id, resource_pair_key);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_resource_connection_rules_unique_direction_port" ON "v5_resource_connection_rules" (connection_id, source_resource_type, source_resource_id, target_resource_type, target_resource_id, protocol, port);
CREATE UNIQUE INDEX IF NOT EXISTS "v5_servers_uuid_unique" ON "v5_servers" (uuid);
CREATE UNIQUE INDEX IF NOT EXISTS "webhook_notification_settings_team_id_unique" ON "webhook_notification_settings" (team_id);
-- Migration records
@@ -1881,8 +1736,4 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_1
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_03_000000_add_oauth_fields_to_gitlab_apps_table', 315);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130649_v5_create_clusters_table', 316);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130650_v5_create_servers_table', 317);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_19_140000_v5_create_applications_table', 318);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (319, '2026_06_19_142000_v5_create_resource_connections_table', 319);
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (320, '2026_06_19_182231_create_container_statuses_table', 320);
-4
View File
@@ -8,16 +8,12 @@ services:
args:
- USER_ID=${USERID:-1000}
- GROUP_ID=${GROUPID:-1000}
- COOLIFY_FLUX_VERSION=${COOLIFY_FLUX_VERSION:-nightly}
- COOLIFY_CLI_VERSION=${COOLIFY_CLI_VERSION:-nightly}
ports:
- "${APP_PORT:-8000}:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
AUTORUN_ENABLED: false
COOLIFY_FLUX_VERSION: "${COOLIFY_FLUX_VERSION:-nightly}"
COOLIFY_CLI_VERSION: "${COOLIFY_CLI_VERSION:-nightly}"
PUSHER_HOST: "${PUSHER_HOST:-}"
PUSHER_PORT: "${PUSHER_PORT:-}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
-5
View File
@@ -22,13 +22,8 @@ services:
args:
- USER_ID=${USERID:-1000}
- GROUP_ID=${GROUPID:-1000}
- COOLIFY_FLUX_VERSION=${COOLIFY_FLUX_VERSION:-nightly}
- COOLIFY_FLUX_CHECKSUM=${COOLIFY_FLUX_CHECKSUM:-unknown}
- COOLIFY_CLI_VERSION=${COOLIFY_CLI_VERSION:-nightly}
- COOLIFY_CLI_CHECKSUM=${COOLIFY_CLI_CHECKSUM:-unknown}
ports:
- "${APP_PORT:-8000}:8080"
- "${FORWARD_FLUX_PORT:-6443}:6443"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
-11
View File
@@ -8,24 +8,13 @@ services:
args:
- USER_ID=${USERID:-1000}
- GROUP_ID=${GROUPID:-1000}
- COOLIFY_FLUX_VERSION=${COOLIFY_FLUX_VERSION:-nightly}
- COOLIFY_FLUX_CHECKSUM=${COOLIFY_FLUX_CHECKSUM:-unknown}
- COOLIFY_CLI_VERSION=${COOLIFY_CLI_VERSION:-nightly}
- COOLIFY_CLI_CHECKSUM=${COOLIFY_CLI_CHECKSUM:-unknown}
ports:
- "${DEV_BIND_ADDRESS:-0.0.0.0}:${APP_PORT:-8000}:8080"
- "${FORWARD_FLUX_PORT:-6443}:6443"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
AUTORUN_ENABLED: false
COOLIFY_CONTAINER_ROLE: "${COOLIFY_CONTAINER_ROLE:-all}"
COOLIFY_COOLD_VERSION: "${COOLIFY_COOLD_VERSION:-nightly}"
COOLIFY_FLUX_VERSION: "${COOLIFY_FLUX_VERSION:-nightly}"
COOLIFY_FLUX_REQUIRE_HOST_BINDING: "${COOLIFY_FLUX_REQUIRE_HOST_BINDING:-0}"
COOLIFY_CLI_VERSION: "${COOLIFY_CLI_VERSION:-nightly}"
COOLIFY_CLI_SSH_USER: "${COOLIFY_CLI_SSH_USER:-}"
COOLIFY_CORROSION_VERSION: "${COOLIFY_CORROSION_VERSION:-v1.0.0}"
PUSHER_HOST: "${PUSHER_HOST:-}"
PUSHER_PORT: "${PUSHER_PORT:-}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
+3 -1
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/images:/var/www/html/storage/app/images
environment:
- APP_ENV=${APP_ENV:-production}
- PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M}
@@ -27,7 +28,8 @@ services:
healthcheck:
test: curl --fail http://127.0.0.1:8080/api/health || exit 1
interval: 5s
retries: 10
retries: 24
start_period: 1m
timeout: 2s
depends_on:
postgres:
+1
View File
@@ -25,6 +25,7 @@ services:
- ./databases:/var/www/html/storage/app/databases
- ./services:/var/www/html/storage/app/services
- ./backups:/var/www/html/storage/app/backups
- ./images:/var/www/html/storage/app/images
env_file:
- .env
environment:
+1 -53
View File
@@ -2,12 +2,9 @@
# https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine
ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-05-21T01-59-54Z
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
# 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
ARG COOLIFY_CLI_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
@@ -30,10 +27,6 @@ ARG TARGETPLATFORM
ARG TARGETARCH
ARG POSTGRES_VERSION
ARG CLOUDFLARED_VERSION
ARG COOLIFY_FLUX_VERSION
ARG COOLIFY_FLUX_CHECKSUM
ARG COOLIFY_CLI_VERSION
ARG COOLIFY_CLI_CHECKSUM
ARG NGINX_VERSION
WORKDIR /var/www/html
@@ -90,51 +83,6 @@ 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; \
echo "Flux checksum: ${COOLIFY_FLUX_CHECKSUM}"; \
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
# Install coolify from coold nightly release based on architecture
RUN set -eux; \
echo "Coolify CLI checksum: ${COOLIFY_CLI_CHECKSUM}"; \
mkdir -p /usr/local/bin; \
case "${TARGETARCH:-}" in \
amd64|arm64) COOLIFY_CLI_ARCH="${TARGETARCH}" ;; \
"") \
case "$(uname -m)" in \
x86_64) COOLIFY_CLI_ARCH="amd64" ;; \
aarch64) COOLIFY_CLI_ARCH="arm64" ;; \
*) echo "unsupported coolify arch: $(uname -m)" >&2; exit 1 ;; \
esac ;; \
*) echo "unsupported coolify TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
curl -fsSL --retry 3 --max-time 120 \
-o /tmp/coolify.tar.gz \
"https://github.com/coollabsio/coold/releases/download/${COOLIFY_CLI_VERSION}/coolify-linux-musl-${COOLIFY_CLI_ARCH}.tar.gz"; \
tar -xzf /tmp/coolify.tar.gz -C /tmp; \
test -f /tmp/coolify; \
install -m 0755 /tmp/coolify /usr/local/bin/coolify; \
rm -f /tmp/coolify /tmp/coolify.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
+1 -1
View File
@@ -2,7 +2,7 @@
# https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine
ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-05-21T01-59-54Z
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
# https://github.com/cloudflare/cloudflared/releases
ARG CLOUDFLARED_VERSION=2026.7.3
# https://www.postgresql.org/support/versioning/
@@ -0,0 +1,90 @@
# External TLS HTTP Redirect Design
## Problem
The Cloudflare Tunnel all-resource setup sends public HTTPS requests to Coolify's proxy through `http://localhost:80`. When a resource domain is stored as `https://` and Coolify redirects HTTP traffic to HTTPS, the tunneled request repeatedly returns to the HTTP entrypoint and causes `TOO_MANY_REDIRECTS`.
The current documentation avoids the loop by telling users to store the public domain as `http://`. That misrepresents the public URL and can produce incorrect secure cookies, OAuth callback URLs, and canonical links. Applications can already disable forced HTTPS in advanced settings, but the control is not near domain configuration. Service applications always enable the redirect in generated proxy configuration.
## Goals
- Store the externally visible URL accurately as `https://`.
- Let an upstream proxy such as Cloudflare handle the HTTP-to-HTTPS redirect.
- Apply the behavior consistently to applications and service applications.
- Keep existing resources secure and behaviorally unchanged by default.
- Keep the feature generic rather than coupling it to Cloudflare or a server-wide tunnel mode.
## Non-goals
- Detect Cloudflare automatically.
- Add a server-wide all-resource tunnel mode.
- Configure trusted forwarded-header networks.
- Replace the end-to-end origin TLS workflow.
- Change the default redirect behavior of existing or new resources.
## User Experience
The Domains page shows a boolean control named **Redirect HTTP to HTTPS** when a resource has at least one `https://` domain.
The control defaults to enabled. Its help text explains:
> Disable this when HTTPS and redirects are handled by Cloudflare Tunnel or another reverse proxy that connects to Coolify over HTTP.
A Cloudflare Tunnel user configures `https://app.example.com` and disables the control. A directly exposed resource leaves it enabled.
For regular and Docker Compose applications, the control edits the existing `ApplicationSetting::is_force_https_enabled` value. The existing Advanced-page control must not become an independent source of truth; it should either be removed from that page or remain bound to the same setting with the clearer label.
For service applications, the Domains page provides the same control for each application service. Database-only service entries do not expose it.
## Data Model
Add `is_force_https_enabled` to service applications as a non-null boolean with a default of `true`. Existing service applications therefore keep their current behavior after migration.
Regular applications continue using the existing application setting. No Cloudflare-specific state is stored.
## Proxy Configuration
Domain scheme and redirect policy remain independent:
- An `https://` domain continues generating the HTTPS router/listener.
- Its HTTP router/listener is also generated.
- When redirect is enabled, the HTTP router applies the HTTPS redirect middleware.
- When redirect is disabled, the HTTP router forwards the request to the resource without that middleware.
The stored service-application setting replaces the currently hardcoded `true` passed into Traefik and Caddy label generation. Existing path stripping, gzip, authentication, noindex, and www/non-www middleware behavior remains unchanged.
Preview deployments inherit the parent application's existing redirect setting, matching current application behavior.
## Validation and Authorization
The new service-application value is validated as a boolean. Updating it uses the same authorization checks as other service domain settings. Changing the value marks proxy configuration as changed and follows the existing save/redeploy flow used by domain configuration.
The control is relevant only when an HTTPS domain exists. Hiding it for HTTP-only resources does not reset the stored value.
## Documentation
Update the Cloudflare all-resource guide to instruct users to:
1. Store the public resource domain using `https://`.
2. Disable **Redirect HTTP to HTTPS** for that resource.
3. Let Cloudflare perform the public redirect and TLS termination.
The guide should retain the full TLS guide as the alternative for users who want TLS between cloudflared and Coolify's HTTPS entrypoint.
## Testing
Automated tests must cover:
- Application HTTPS domains with redirects enabled and disabled.
- Service-application HTTPS domains with redirects enabled and disabled.
- The service-application default remains enabled.
- Traefik and Caddy omit only the redirect behavior when disabled.
- Other middleware remains present when the redirect is disabled.
- HTTP-only resources do not show an irrelevant control.
- The Domains UI persists changes with existing authorization rules.
A manual smoke test should route a Cloudflare Tunnel hostname to `http://localhost:80`, save the Coolify resource as `https://`, disable the redirect, and verify the public HTTPS URL loads without a redirect loop.
## Compatibility
The database default of `true` preserves service behavior. Existing application values are unchanged. No automatic migration attempts to infer which resources are behind Cloudflare.
+10
View File
@@ -0,0 +1,10 @@
# Archived V5 Implementation
This directory preserves every file removed from the previous executable V5
implementation, using its original repository path beneath this directory.
Files have a `.txt` suffix so framework, build, test, and runtime discovery
cannot load them.
The previous migrations and UI source have dedicated archives in
`docs/v5/migrations/` and `docs/v5/ui/`. This directory contains the remaining
backend, configuration, development tooling, and test sources.

Some files were not shown because too many files have changed in this diff Show More