feat(buildpack): add railpack as a build pack option (#9117)

This commit is contained in:
Andras Bacsai 2026-05-11 17:34:42 +02:00 committed by GitHub
commit 88a86287cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 3787 additions and 110 deletions

View file

@ -51,6 +51,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true',
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",

View file

@ -8,4 +8,5 @@ enum BuildPackTypes: string
case STATIC = 'static';
case DOCKERFILE = 'dockerfile';
case DOCKERCOMPOSE = 'dockercompose';
case RAILPACK = 'railpack';
}

View file

@ -153,7 +153,7 @@ public function applications(Request $request)
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'],
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@ -322,7 +322,7 @@ public function create_public_application(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@ -488,7 +488,7 @@ public function create_private_gh_app_application(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@ -650,7 +650,7 @@ public function create_private_deploy_key_application(Request $request)
'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'build_pack' => ['type' => 'string', 'enum' => ['dockerfile'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@ -2206,7 +2206,7 @@ public function delete_by_uuid(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],

View file

@ -33,6 +33,7 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
use JsonException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
@ -48,6 +49,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
private const RAILPACK_REPOSITORY_CONFIG_PATH = 'railpack.json';
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
public $tries = 1;
public $timeout = 3600;
@ -124,6 +129,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_nixpacks_args;
private $env_railpack_args;
private $docker_compose;
private $docker_compose_base64;
@ -174,6 +181,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $dockerBuildkitSupported = false;
private bool $dockerBuildxAvailable = false;
private bool $dockerSecretsSupported = false;
private bool $skip_build = false;
@ -414,6 +423,7 @@ private function detectBuildKitCapabilities(): void
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
$this->dockerBuildkitSupported = false;
$this->dockerBuildxAvailable = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
return;
@ -427,8 +437,11 @@ private function detectBuildKitCapabilities(): void
if (trim($buildxAvailable) === 'available') {
$this->dockerBuildkitSupported = true;
$this->dockerBuildxAvailable = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
} else {
$this->dockerBuildxAvailable = false;
// Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
$buildkitTest = instant_remote_process(
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
@ -461,6 +474,7 @@ private function detectBuildKitCapabilities(): void
}
} catch (Exception $e) {
$this->dockerBuildkitSupported = false;
$this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
}
@ -484,8 +498,12 @@ private function decide_what_to_do()
$this->deploy_dockerfile_buildpack();
} elseif ($this->application->build_pack === 'static') {
$this->deploy_static_buildpack();
} else {
} elseif ($this->application->build_pack === 'nixpacks') {
$this->deploy_nixpacks_buildpack();
} elseif ($this->application->build_pack === 'railpack') {
$this->deploy_railpack_buildpack();
} else {
throw new DeploymentException("Unsupported build pack: {$this->application->build_pack}");
}
$this->post_deployment();
}
@ -938,6 +956,37 @@ private function deploy_nixpacks_buildpack()
$this->rolling_update();
}
private function deploy_railpack_buildpack(): void
{
if ($this->use_build_server) {
$this->server = $this->build_server;
}
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
$this->prepare_builder_image();
$this->check_git_if_build_needed();
$this->generate_image_names();
if (! $this->force_rebuild) {
$this->check_image_locally_or_remotely();
if ($this->should_skip_build()) {
return;
}
}
$this->clone_repository();
$this->cleanup_git();
$this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables();
$this->build_railpack_image();
// Save runtime environment variables AFTER the build
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
private function deploy_static_buildpack()
{
if ($this->use_build_server) {
@ -1217,11 +1266,11 @@ private function generate_runtime_environment_variables()
$envs = collect([]);
$sort = $this->application->settings->is_env_sorting_enabled;
if ($sort) {
$sorted_environment_variables = $this->application->environment_variables->sortBy('key');
$sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('key');
$sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key');
$sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('key');
} else {
$sorted_environment_variables = $this->application->environment_variables->sortBy('id');
$sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id');
$sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id');
$sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
}
if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
@ -1592,6 +1641,7 @@ private function generate_buildtime_environment_variables()
// 4. Add user-defined build-time variables LAST (highest priority - can override everything)
if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables()
->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
@ -1644,6 +1694,7 @@ private function generate_buildtime_environment_variables()
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
@ -1983,7 +2034,11 @@ private function deploy_pull_request()
if ($this->application->build_pack === 'dockerfile') {
$this->add_build_env_variables_to_dockerfile();
}
$this->build_image();
if ($this->application->build_pack === 'railpack') {
$this->build_railpack_image();
} else {
$this->build_image();
}
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
@ -2422,7 +2477,409 @@ private function generate_nixpacks_env_variables()
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
}
private function generate_coolify_env_variables(bool $forBuildTime = false): Collection
private function generate_railpack_env_variables(): Collection
{
$variables = $this->railpack_build_variables();
$this->env_railpack_args = $variables
->map(function ($value, $key) {
return '--env '.escapeShellValue("{$key}={$value}");
})
->implode(' ');
return $variables;
}
private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
{
$resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
if (is_null($resolvedValue) || $resolvedValue === '') {
return null;
}
if ($environmentVariable->is_literal || $environmentVariable->is_multiline) {
return trim($resolvedValue, "'");
}
return $resolvedValue;
}
/**
* All buildtime variables that must reach the Railpack build.
*
* Railpack's BuildKit frontend treats every `--env` passed to `railpack prepare`
* as a build secret entry in the generated plan, then pairs it with `--secret id=,env=`
* on `docker buildx build`. Because Railpack's schema disallows top-level `variables`
* (unlike Nixpacks, which bakes variables into the plan), this `--env` `--secret`
* channel is the only way user-defined buildtime variables become available to
* commands declared with `useSecrets: true`.
*/
private function railpack_build_variables(): Collection
{
$genericBuildVariables = $this->pull_request_id === 0
? $this->application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get()
: $this->application->environment_variables_preview()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get();
$railpackVariables = $this->pull_request_id === 0
? $this->application->railpack_environment_variables()->get()
: $this->application->railpack_environment_variables_preview()->get();
$variables = $genericBuildVariables
->merge($railpackVariables)
->mapWithKeys(function (EnvironmentVariable $environmentVariable) {
$value = $this->normalize_resolved_build_variable_value($environmentVariable);
if (is_null($value) || $value === '') {
return [];
}
return [$environmentVariable->key => $value];
});
if ($this->application->install_command) {
$variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command);
}
$variables = $this->merge_railpack_deploy_apt_packages($variables);
// Mirror Nixpacks behavior: expose COOLIFY_* and SOURCE_COMMIT to the build so apps
// (e.g. SPAs baking the public URL) can read them via /run/secrets/<KEY>.
foreach ($this->generate_coolify_env_variables(forBuildTime: true) as $key => $value) {
if (! is_null($value) && $value !== '') {
$variables->put($key, $value);
}
}
return $variables;
}
private function merge_railpack_deploy_apt_packages(Collection $variables): Collection
{
$packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: [])
->filter()
->values();
foreach (['curl', 'wget'] as $package) {
if (! $packages->contains($package)) {
$packages->push($package);
}
}
$variables->put('RAILPACK_DEPLOY_APT_PACKAGES', $packages->implode(' '));
return $variables;
}
private function railpack_build_environment_prefix(Collection $variables): string
{
if ($variables->isEmpty()) {
return '';
}
return 'env '.$variables
->map(function ($value, $key) {
return escapeShellValue("{$key}={$value}");
})
->implode(' ').' ';
}
private function railpack_build_secret_flags(Collection $variables): string
{
if ($variables->isEmpty()) {
return '';
}
return ' '.$variables
->map(function ($value, $key) {
return '--secret '.escapeShellValue("id={$key},env={$key}");
})
->implode(' ');
}
private function railpack_build_command(string $imageName, Collection $variables): string
{
$cacheArgs = '';
if ($this->force_rebuild) {
$cacheArgs = '--no-cache';
} else {
$cacheArgs = "--build-arg cache-key='{$this->application->uuid}'";
}
if ($variables->isNotEmpty()) {
$cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables);
}
$environmentPrefix = $this->railpack_build_environment_prefix($variables);
$secretFlags = $this->railpack_build_secret_flags($variables);
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
." && {$environmentPrefix}docker buildx build --builder coolify-railpack"
." {$this->addHosts} --network host"
." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
." {$cacheArgs}"
."{$secretFlags}"
.' -f /artifacts/railpack-plan.json'
.' --progress plain'
.' --load'
." -t {$imageName}"
." {$this->workdir}";
}
private function decode_railpack_config(string $config, string $source): array
{
try {
$decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception);
}
if (! is_array($decoded)) {
throw new DeploymentException("Invalid {$source}: expected a JSON object.");
}
return $decoded;
}
private function is_assoc_array(array $value): bool
{
if ($value === []) {
return false;
}
return array_keys($value) !== range(0, count($value) - 1);
}
private function merge_railpack_config(array $base, array $overrides): array
{
foreach ($overrides as $key => $value) {
if (
array_key_exists($key, $base)
&& is_array($base[$key])
&& is_array($value)
&& $this->is_assoc_array($base[$key])
&& $this->is_assoc_array($value)
) {
$base[$key] = $this->merge_railpack_config($base[$key], $value);
} else {
$base[$key] = $value;
}
}
return $base;
}
private function railpack_config_overrides(): array
{
return [];
}
private function generated_railpack_config_relative_path(): string
{
return self::RAILPACK_GENERATED_CONFIG_PATH;
}
private function generated_railpack_config_absolute_path(): string
{
return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH;
}
private function generate_railpack_config_file(): ?string
{
$repositoryConfig = [];
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"),
'hidden' => true,
'save' => 'railpack_config_exists',
]);
if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') {
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH),
'hidden' => true,
'save' => 'railpack_repository_config',
]);
$repositoryConfig = $this->decode_railpack_config(
$this->saved_outputs->get('railpack_repository_config', ''),
'repository railpack.json'
);
}
$overrides = $this->railpack_config_overrides();
if ($repositoryConfig === [] && $overrides === []) {
return null;
}
$mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides);
if (! array_key_exists('$schema', $mergedConfig)) {
$mergedConfig['$schema'] = 'https://schema.railpack.com';
}
try {
$encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception);
}
$configPath = $this->generated_railpack_config_absolute_path();
$encodedConfig = base64_encode($encodedConfig);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"),
'hidden' => true,
]
);
if (isDev()) {
$this->application_deployment_queue->addLogEntry('Generated Railpack config: '.json_encode($mergedConfig, JSON_PRETTY_PRINT), hidden: true);
}
return $this->generated_railpack_config_relative_path();
}
private function railpack_prepare_command(?string $configFilePath = null): string
{
$prepare_command = 'railpack prepare';
if ($this->application->build_command) {
$prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
}
if ($this->application->start_command) {
$prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
}
if ($this->env_railpack_args) {
$prepare_command .= " {$this->env_railpack_args}";
}
if ($configFilePath) {
$prepare_command .= ' --config-file '.escapeShellValue($configFilePath);
}
$prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}";
return $prepare_command;
}
private function ensure_docker_buildx_available_for_railpack(): void
{
if ($this->dockerBuildxAvailable) {
return;
}
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
}
private function build_railpack_image(): void
{
$this->ensure_docker_buildx_available_for_railpack();
$railpackVariables = $this->generate_railpack_env_variables();
$railpackConfigPath = $this->generate_railpack_config_file();
// Step 1: Generate build plan with railpack prepare
$prepare_command = $this->railpack_prepare_command($railpackConfigPath);
$this->application_deployment_queue->addLogEntry('Generating Railpack build plan.');
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true],
[
executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'),
'hidden' => true,
'save' => 'railpack_plan',
],
);
$railpackPlanRaw = $this->saved_outputs->get('railpack_plan');
if (! empty($railpackPlanRaw)) {
if (isDev()) {
$this->application_deployment_queue->addLogEntry("Final Railpack plan: {$railpackPlanRaw}", hidden: true);
} else {
$parsedPlan = json_decode($railpackPlanRaw, true);
if (is_array($parsedPlan)) {
// Strip secrets array to avoid logging variable names in production.
unset($parsedPlan['secrets']);
$this->application_deployment_queue->addLogEntry('Final Railpack plan: '.json_encode($parsedPlan, JSON_PRETTY_PRINT), hidden: true);
}
}
}
// Step 2: Build image using docker buildx with railpack frontend.
// Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder.
$this->application_deployment_queue->addLogEntry('Building docker image with Railpack.');
$this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
$image_name = $this->application->settings->is_static
? $this->build_image_name
: $this->production_image_name;
if ($this->application->settings->is_static && $this->application->static_image) {
$this->pull_latest_image($this->application->static_image);
}
$build_command = $this->railpack_build_command($image_name, $railpackVariables);
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
'hidden' => true,
]
);
// Step 3: If static, copy built assets into nginx image
if ($this->application->settings->is_static) {
$this->build_railpack_static_image();
}
}
private function build_railpack_static_image(): void
{
$publishDir = trim($this->application->publish_directory, '/');
$publishDir = $publishDir ? "/{$publishDir}" : '';
$dockerfile = base64_encode("FROM {$this->application->static_image}
WORKDIR /usr/share/nginx/html/
LABEL coolify.deploymentId={$this->deployment_uuid}
COPY --from={$this->build_image_name} /app{$publishDir} .
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
} else {
$nginx_config = $this->application->settings->is_spa
? base64_encode(defaultNginxConfiguration('spa'))
: base64_encode(defaultNginxConfiguration());
}
$static_build = $this->dockerBuildkitSupported
? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"
: "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
$base64_static_build = base64_encode($static_build);
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")],
[executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")],
[executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true],
[executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
[executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
);
}
protected function generate_coolify_env_variables(bool $forBuildTime = false): Collection
{
$coolify_envs = collect([]);
$local_branch = $this->branch;
@ -2538,7 +2995,7 @@ private function generate_env_variables()
// For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) {
$envs = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
@ -2550,7 +3007,7 @@ private function generate_env_variables()
}
} else {
$envs = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
@ -3630,7 +4087,7 @@ private function add_build_env_variables_to_dockerfile()
if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build
$envs = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
@ -3652,7 +4109,7 @@ private function add_build_env_variables_to_dockerfile()
} else {
// Only add preview environment variables that are available during build
$envs = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {

View file

@ -606,7 +606,7 @@ public function updatedBuildPack()
// Sync property to model before checking/modifying
$this->syncData(toModel: true);
if ($this->buildPack !== 'nixpacks') {
if ($this->buildPack !== 'nixpacks' && $this->buildPack !== 'railpack') {
$this->isStatic = false;
$this->application->settings->is_static = false;
$this->application->settings->save();

View file

@ -81,9 +81,11 @@ public function updatedSelectedRepositoryId(): void
public function updatedBuildPack()
{
if ($this->build_pack === 'nixpacks') {
if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
$this->port = 3000;
if (! $this->is_static) {
$this->port = 3000;
}
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->is_static = false;

View file

@ -94,9 +94,11 @@ public function mount()
public function updatedBuildPack()
{
if ($this->build_pack === 'nixpacks') {
if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
$this->port = 3000;
if (! $this->is_static) {
$this->port = 3000;
}
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->is_static = false;

View file

@ -96,9 +96,11 @@ public function mount()
public function updatedBuildPack()
{
if ($this->build_pack === 'nixpacks') {
if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
$this->port = 3000;
if (! $this->isStatic) {
$this->port = 3000;
}
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->isStatic = false;

View file

@ -2,9 +2,14 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@ -37,15 +42,23 @@ class Add extends Component
protected $listeners = ['clearAddEnv' => 'clear'];
protected $rules = [
'key' => 'required|string',
'value' => 'nullable',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
'comment' => 'nullable|string|max:256',
];
protected function rules(): array
{
return [
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'nullable',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
'comment' => 'nullable|string|max:256',
];
}
protected function messages(): array
{
return ValidationPatterns::environmentVariableKeyMessages('key');
}
protected $validationAttributes = [
'key' => 'key',
@ -85,7 +98,7 @@ public function availableSharedVariables(): array
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@ -116,12 +129,12 @@ public function availableSharedVariables(): array
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
@ -131,7 +144,7 @@ public function availableSharedVariables(): array
$serverUuid = data_get($this->parameters, 'server_uuid');
if ($serverUuid) {
// If we have a specific server_uuid, show variables for that server
$server = \App\Models\Server::where('team_id', $team->id)
$server = Server::where('team_id', $team->id)
->where('uuid', $serverUuid)
->first();
@ -141,7 +154,7 @@ public function availableSharedVariables(): array
$result['server'] = $server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@ -149,7 +162,7 @@ public function availableSharedVariables(): array
// For application environment variables, try to use the application's destination server
$applicationUuid = data_get($this->parameters, 'application_uuid');
if ($applicationUuid) {
$application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
$application = Application::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $applicationUuid)
->with('destination.server')
->first();
@ -160,7 +173,7 @@ public function availableSharedVariables(): array
$result['server'] = $application->destination->server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@ -168,7 +181,7 @@ public function availableSharedVariables(): array
// For service environment variables, try to use the service's server
$serviceUuid = data_get($this->parameters, 'service_uuid');
if ($serviceUuid) {
$service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
$service = Service::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $serviceUuid)
->with('server')
->first();
@ -179,7 +192,7 @@ public function availableSharedVariables(): array
$result['server'] = $service->server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@ -192,6 +205,7 @@ public function availableSharedVariables(): array
public function submit()
{
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
$this->validate();
$this->dispatch('saveKey', [
'key' => $this->key,

View file

@ -2,7 +2,9 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -38,7 +40,7 @@ public function mount()
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
$this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
$this->resourceClass = get_class($this->resource);
$resourceWithPreviews = [\App\Models\Application::class];
$resourceWithPreviews = [Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$this->showPreview = true;
@ -194,7 +196,7 @@ public function submit($data = null)
private function updateOrder()
{
$variables = parseEnvFormatToArray($this->variables);
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$order = 1;
foreach ($variables as $key => $value) {
$env = $this->resource->environment_variables()->where('key', $key)->first();
@ -206,7 +208,7 @@ private function updateOrder()
}
if ($this->showPreview) {
$previewVariables = parseEnvFormatToArray($this->variablesPreview);
$previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
$order = 1;
foreach ($previewVariables as $key => $value) {
$env = $this->resource->environment_variables_preview()->where('key', $key)->first();
@ -221,7 +223,7 @@ private function updateOrder()
private function handleBulkSubmit()
{
$variables = parseEnvFormatToArray($this->variables);
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$changesMade = false;
$errorOccurred = false;
@ -241,7 +243,7 @@ private function handleBulkSubmit()
}
if ($this->showPreview) {
$previewVariables = parseEnvFormatToArray($this->variablesPreview);
$previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
// Try to delete removed preview variables
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
@ -267,6 +269,7 @@ private function handleBulkSubmit()
private function handleSingleSubmit($data)
{
$data['key'] = ValidationPatterns::validatedEnvironmentVariableKey($data['key']);
$found = $this->resource->environment_variables()->where('key', $data['key'])->first();
if ($found) {
$this->dispatch('error', 'Environment variable already exists.');
@ -334,6 +337,23 @@ private function deleteRemovedVariables($isPreview, $variables)
return $variablesToDelete->count();
}
private function normalizeEnvironmentVariables(array $variables): array
{
$normalizedVariables = [];
foreach ($variables as $key => $data) {
$normalizedKey = ValidationPatterns::validatedEnvironmentVariableKey((string) $key);
if (array_key_exists($normalizedKey, $normalizedVariables)) {
throw new \InvalidArgumentException("Duplicate environment variable key after normalization: {$normalizedKey}.");
}
$normalizedVariables[$normalizedKey] = $data;
}
return $normalizedVariables;
}
private function updateOrCreateVariables($isPreview, $variables)
{
$count = 0;

View file

@ -2,12 +2,17 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@ -64,23 +69,31 @@ class Show extends Component
'compose_loaded' => '$refresh',
];
protected $rules = [
'key' => 'required|string',
'value' => 'nullable',
'comment' => 'nullable|string|max:256',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_shown_once' => 'required|boolean',
'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
'real_value' => 'nullable',
'is_required' => 'required|boolean',
];
protected function rules(): array
{
return [
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'nullable',
'comment' => 'nullable|string|max:256',
'is_multiline' => 'required|boolean',
'is_literal' => 'required|boolean',
'is_shown_once' => 'required|boolean',
'is_runtime' => 'required|boolean',
'is_buildtime' => 'required|boolean',
'real_value' => 'nullable',
'is_required' => 'required|boolean',
];
}
protected function messages(): array
{
return ValidationPatterns::environmentVariableKeyMessages('key');
}
public function mount()
{
$this->syncData();
if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) {
if ($this->env->getMorphClass() === SharedEnvironmentVariable::class) {
$this->isSharedVariable = true;
}
$this->parameters = get_route_parameters();
@ -108,9 +121,11 @@ public function refresh()
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
if ($this->isSharedVariable) {
$this->validate([
'key' => 'required|string',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'nullable',
'comment' => 'nullable|string|max:256',
'is_multiline' => 'required|boolean',
@ -233,7 +248,7 @@ public function availableSharedVariables(): array
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@ -264,12 +279,12 @@ public function availableSharedVariables(): array
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
@ -279,7 +294,7 @@ public function availableSharedVariables(): array
$serverUuid = data_get($this->parameters, 'server_uuid');
if ($serverUuid) {
// If we have a specific server_uuid, show variables for that server
$server = \App\Models\Server::where('team_id', $team->id)
$server = Server::where('team_id', $team->id)
->where('uuid', $serverUuid)
->first();
@ -289,7 +304,7 @@ public function availableSharedVariables(): array
$result['server'] = $server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@ -297,7 +312,7 @@ public function availableSharedVariables(): array
// For application environment variables, try to use the application's destination server
$applicationUuid = data_get($this->parameters, 'application_uuid');
if ($applicationUuid) {
$application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
$application = Application::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $applicationUuid)
->with('destination.server')
->first();
@ -308,7 +323,7 @@ public function availableSharedVariables(): array
$result['server'] = $application->destination->server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@ -316,7 +331,7 @@ public function availableSharedVariables(): array
// For service environment variables, try to use the service's server
$serviceUuid = data_get($this->parameters, 'service_uuid');
if ($serviceUuid) {
$service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
$service = Service::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $serviceUuid)
->with('server')
->first();
@ -327,7 +342,7 @@ public function availableSharedVariables(): array
$result['server'] = $service->server->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}

View file

@ -39,7 +39,7 @@
'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'],
'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'],
'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose']],
'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']],
'static_image' => ['type' => 'string', 'description' => 'Static image used when static site is deployed.'],
'install_command' => ['type' => 'string', 'description' => 'Install command.'],
'build_command' => ['type' => 'string', 'description' => 'Build command.'],
@ -960,7 +960,7 @@ public function runtime_environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
->where('key', 'not like', 'NIXPACKS_%');
->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables()
@ -970,6 +970,13 @@ public function nixpacks_environment_variables()
->where('key', 'like', 'NIXPACKS_%');
}
public function railpack_environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
->where('key', 'like', 'RAILPACK_%');
}
public function environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
@ -988,7 +995,7 @@ public function runtime_environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->where('key', 'not like', 'NIXPACKS_%');
->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables_preview()
@ -998,6 +1005,13 @@ public function nixpacks_environment_variables_preview()
->where('key', 'like', 'NIXPACKS_%');
}
public function railpack_environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->where('key', 'like', 'RAILPACK_%');
}
public function scheduled_tasks(): HasMany
{
return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc');
@ -1117,7 +1131,7 @@ public function deploymentType()
public function could_set_build_commands(): bool
{
if ($this->build_pack === 'nixpacks') {
if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
return true;
}

View file

@ -3,6 +3,8 @@
namespace App\Models;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use OpenApi\Attributes as OA;
@ -32,6 +34,8 @@
)]
class EnvironmentVariable extends BaseModel
{
public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_'];
protected $attributes = [
'is_runtime' => true,
'is_buildtime' => true,
@ -74,11 +78,11 @@ class EnvironmentVariable extends BaseModel
'resourceable_id' => 'integer',
];
protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify'];
protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify'];
protected static function booted()
{
static::created(function (EnvironmentVariable $environment_variable) {
static::created(function (ModelsEnvironmentVariable $environment_variable) {
if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) {
$found = ModelsEnvironmentVariable::where('key', $environment_variable->key)
->where('resourceable_type', Application::class)
@ -109,7 +113,7 @@ protected static function booted()
]);
});
static::saving(function (EnvironmentVariable $environmentVariable) {
static::saving(function (ModelsEnvironmentVariable $environmentVariable) {
$environmentVariable->updateIsShared();
});
}
@ -119,6 +123,30 @@ public function service()
return $this->belongsTo(Service::class);
}
public function scopeWithoutBuildpackControlVariables(Builder $query): Builder
{
foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
$query->where('key', 'not like', "{$prefix}%");
}
return $query;
}
public static function isBuildpackControlKey(?string $key): bool
{
if (blank($key)) {
return false;
}
foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
if (str($key)->startsWith($prefix)) {
return true;
}
}
return false;
}
protected function value(): Attribute
{
return Attribute::make(
@ -188,16 +216,10 @@ protected function isReallyRequired(): Attribute
);
}
protected function isNixpacks(): Attribute
protected function isBuildpackControl(): Attribute
{
return Attribute::make(
get: function () {
if (str($this->key)->startsWith('NIXPACKS_')) {
return true;
}
return false;
}
get: fn () => self::isBuildpackControlKey($this->key),
);
}
@ -349,7 +371,9 @@ private function set_environment_variables(?string $environment_variable = null)
protected function key(): Attribute
{
return Attribute::make(
set: fn (string $value) => str($value)->trim()->replace(' ', '_')->value,
set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey(
ValidationPatterns::normalizeEnvironmentVariableKey($value)
),
);
}

View file

@ -2,6 +2,8 @@
namespace App\Models;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class SharedEnvironmentVariable extends Model
@ -33,6 +35,13 @@ class SharedEnvironmentVariable extends Model
'value' => 'encrypted',
];
protected function key(): Attribute
{
return Attribute::make(
set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value),
);
}
public function team()
{
return $this->belongsTo(Team::class);

View file

@ -82,6 +82,12 @@ class ValidationPatterns
*/
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for Docker-compatible environment variable keys.
* Docker environment entries are KEY=value strings, so keys must be non-empty and cannot contain '=' or NUL.
*/
public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[^=\x00]+\z/u';
/**
* Pattern for SQL-safe unquoted database identifiers (usernames, database names).
* Allows letters, digits, underscore; first char must be letter or underscore.
@ -96,6 +102,67 @@ class ValidationPatterns
*/
public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/';
/**
* Normalize environment variable keys before validation and storage.
*/
public static function normalizeEnvironmentVariableKey(string $value): string
{
return str($value)->trim()->value;
}
/**
* Get validation rules for environment variable keys.
*/
public static function environmentVariableKeyRules(bool $required = true, int $maxLength = 255): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "max:$maxLength";
$rules[] = 'regex:'.self::ENVIRONMENT_VARIABLE_KEY_PATTERN;
return $rules;
}
/**
* Get validation messages for environment variable key fields.
*/
public static function environmentVariableKeyMessages(string $field = 'key', string $label = 'key'): array
{
return [
"{$field}.regex" => "The {$label} must be a non-empty Docker-compatible environment variable key and cannot contain '=' or NUL characters.",
"{$field}.max" => "The {$label} may not be greater than :max characters.",
];
}
/**
* Check if a string is a valid environment variable key.
*/
public static function isValidEnvironmentVariableKey(string $value): bool
{
return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1;
}
/**
* Normalize and validate an environment variable key.
*/
public static function validatedEnvironmentVariableKey(string $value, string $label = 'key'): string
{
$key = self::normalizeEnvironmentVariableKey($value);
if (! self::isValidEnvironmentVariableKey($key)) {
throw new \InvalidArgumentException(self::environmentVariableKeyMessages(label: $label)['key.regex']);
}
return $key;
}
/**
* Get validation rules for database identifier fields (username, database name).
*

View file

@ -5,6 +5,7 @@
'version' => '4.1.0',
'helper_version' => '1.0.13',
'realtime_version' => '1.0.14',
'railpack_version' => '0.22.0',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),

View file

@ -47,6 +47,22 @@ public function run(): void
'source_id' => 1,
'source_type' => GithubApp::class,
]);
Application::create([
'uuid' => 'railpack-nodejs',
'name' => 'Railpack NodeJS Fastify Example',
'fqdn' => 'http://railpack-nodejs.127.0.0.1.sslip.io',
'repository_project_id' => 603035348,
'git_repository' => 'coollabsio/coolify-examples',
'git_branch' => 'v4.x',
'base_directory' => '/nodejs',
'build_pack' => 'railpack',
'ports_exposes' => '3000',
'environment_id' => 1,
'destination_id' => 0,
'destination_type' => StandaloneDocker::class,
'source_id' => 1,
'source_type' => GithubApp::class,
]);
Application::create([
'uuid' => 'dockerfile',
'name' => 'Dockerfile Example',
@ -145,5 +161,21 @@ public function run(): void
'source_id' => 1,
'source_type' => GitlabApp::class,
]);
Application::create([
'uuid' => 'railpack-static',
'name' => 'Railpack Static Example',
'fqdn' => 'http://railpack-static.127.0.0.1.sslip.io',
'repository_project_id' => 603035348,
'git_repository' => 'coollabsio/coolify-examples',
'git_branch' => 'v4.x',
'base_directory' => '/static',
'build_pack' => 'railpack',
'ports_exposes' => '80',
'environment_id' => 1,
'destination_id' => 0,
'destination_type' => StandaloneDocker::class,
'source_id' => 1,
'source_type' => GithubApp::class,
]);
}
}

View file

@ -22,5 +22,12 @@ public function run(): void
$gitlabPublic->settings->is_static = true;
$gitlabPublic->settings->save();
}
$railpackStatic = Application::where('uuid', 'railpack-static')->first();
if ($railpackStatic) {
$railpackStatic->load(['settings']);
$railpackStatic->settings->is_static = true;
$railpackStatic->settings->save();
}
}
}

View file

@ -31,5 +31,11 @@ public function run(): void
CaSslCertSeeder::class,
PersonalAccessTokenSeeder::class,
]);
if (in_array(config('app.env'), ['local', 'development', 'dev'], true)) {
$this->call([
DevelopmentRailpackExamplesSeeder::class,
]);
}
}
}

View file

@ -0,0 +1,513 @@
<?php
namespace Database\Seeders;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Database\Seeder;
use RuntimeException;
class DevelopmentRailpackExamplesSeeder extends Seeder
{
public const PROJECT_UUID = 'railpack-examples';
public const ENVIRONMENT_UUID = 'railpack-examples-production';
public const GIT_REPOSITORY = 'coollabsio/coolify-examples';
public const GIT_BRANCH = 'next';
public const REPOSITORY_PROJECT_ID = 603035348;
public function run(): void
{
if (! $this->isDevelopmentEnvironment()) {
$this->command?->warn('Skipping DevelopmentRailpackExamplesSeeder outside development mode.');
return;
}
$this->ensureDevelopmentPrerequisitesExist();
$destination = StandaloneDocker::query()->find(0);
if (! $destination) {
throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.');
}
$environment = $this->prepareEnvironment();
foreach (self::examples() as $example) {
$this->upsertApplication($environment, $destination, $example);
}
}
/**
* @return array<int, array<string, mixed>>
*/
public static function examples(): array
{
return [
[
'uuid' => 'railpack-simple-webserver',
'name' => 'Railpack Simple Webserver Example',
'base_directory' => '/node/simple-webserver',
'ports_exposes' => '3000',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-expressjs',
'name' => 'Railpack Express.js Example',
'base_directory' => '/node/expressjs',
'ports_exposes' => '3000',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-fastify',
'name' => 'Railpack Fastify Example',
'base_directory' => '/node/fastify',
'ports_exposes' => '3000',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-nestjs',
'name' => 'Railpack NestJS Example',
'base_directory' => '/node/nestjs',
'ports_exposes' => '3000',
'build_command' => 'npm run build',
'start_command' => 'npm run start:prod',
],
[
'uuid' => 'railpack-adonisjs',
'name' => 'Railpack AdonisJS Example',
'base_directory' => '/node/adonisjs',
'ports_exposes' => '3333',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-hono',
'name' => 'Railpack Hono Example',
'base_directory' => '/node/hono',
'ports_exposes' => '3000',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-koa',
'name' => 'Railpack Koa Example',
'base_directory' => '/node/koa',
'ports_exposes' => '3000',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-nextjs-ssr',
'name' => 'Railpack Next.js SSR Example',
'base_directory' => '/node/nextjs/ssr',
'ports_exposes' => '3000',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-nuxtjs-ssr',
'name' => 'Railpack NuxtJS SSR Example',
'base_directory' => '/node/nuxtjs/ssr',
'ports_exposes' => '3000',
'build_command' => 'npm run build',
'start_command' => 'npm run preview -- --host 0.0.0.0 --port 3000',
],
[
'uuid' => 'railpack-astro-ssr',
'name' => 'Railpack Astro SSR Example',
'base_directory' => '/node/astro/ssr',
'ports_exposes' => '4321',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-sveltekit-ssr',
'name' => 'Railpack SvelteKit SSR Example',
'base_directory' => '/node/sveltekit/ssr',
'ports_exposes' => '3000',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-tanstack-start-ssr',
'name' => 'Railpack TanStack Start SSR Example',
'base_directory' => '/node/tanstack-start/ssr',
'ports_exposes' => '3000',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-angular-ssr',
'name' => 'Railpack Angular SSR Example',
'base_directory' => '/node/angular/ssr',
'ports_exposes' => '4000',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-vue-ssr',
'name' => 'Railpack Vue SSR Example',
'base_directory' => '/node/vue/ssr',
'ports_exposes' => '3000',
'build_command' => 'npm run build',
'start_command' => 'npm run start',
],
[
'uuid' => 'railpack-qwik-ssr',
'name' => 'Railpack Qwik SSR Example',
'base_directory' => '/node/qwik/ssr',
'ports_exposes' => '3000',
'build_command' => 'npm run build',
'start_command' => 'npm run serve',
],
[
'uuid' => 'railpack-react-static',
'name' => 'Railpack React Static Example',
'base_directory' => '/node/react',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/dist',
'is_static' => true,
'is_spa' => true,
],
[
'uuid' => 'railpack-vite-static',
'name' => 'Railpack Vite Static Example',
'base_directory' => '/node/vite',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/dist',
'is_static' => true,
'is_spa' => true,
],
[
'uuid' => 'railpack-eleventy-static',
'name' => 'Railpack Eleventy Static Example',
'base_directory' => '/node/eleventy',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/_site',
'is_static' => true,
],
[
'uuid' => 'railpack-gatsby-static',
'name' => 'Railpack Gatsby Static Example',
'base_directory' => '/node/gatsby',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/public',
'is_static' => true,
],
[
'uuid' => 'railpack-nextjs-static',
'name' => 'Railpack Next.js Static Example',
'base_directory' => '/node/nextjs/static',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/out',
'is_static' => true,
'is_spa' => true,
],
[
'uuid' => 'railpack-nuxtjs-static',
'name' => 'Railpack NuxtJS Static Example',
'base_directory' => '/node/nuxtjs/static',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/.output/public',
'is_static' => true,
'is_spa' => true,
],
[
'uuid' => 'railpack-astro-static',
'name' => 'Railpack Astro Static Example',
'base_directory' => '/node/astro/static',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/dist',
'is_static' => true,
],
[
'uuid' => 'railpack-sveltekit-static',
'name' => 'Railpack SvelteKit Static Example',
'base_directory' => '/node/sveltekit/static',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/build',
'is_static' => true,
'is_spa' => true,
],
[
'uuid' => 'railpack-tanstack-start-static',
'name' => 'Railpack TanStack Start Static Example',
'base_directory' => '/node/tanstack-start/static',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/.output/public',
'is_static' => true,
'is_spa' => true,
],
[
'uuid' => 'railpack-angular-static',
'name' => 'Railpack Angular Static Example',
'base_directory' => '/node/angular/static',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/dist/static/browser',
'is_static' => true,
'is_spa' => true,
],
[
'uuid' => 'railpack-vue-static',
'name' => 'Railpack Vue Static Example',
'base_directory' => '/node/vue/static',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/dist',
'is_static' => true,
'is_spa' => true,
],
[
'uuid' => 'railpack-qwik-static',
'name' => 'Railpack Qwik Static Example',
'base_directory' => '/node/qwik/static',
'ports_exposes' => '80',
'build_command' => 'npm run build',
'publish_directory' => '/dist',
'is_static' => true,
'is_spa' => true,
],
// Multi-language examples (only available on v4.x branch).
[
'uuid' => 'railpack-python-flask',
'name' => 'Railpack Python Flask Example',
'base_directory' => '/flask',
'ports_exposes' => '5000',
'git_branch' => 'v4.x',
'start_command' => 'flask run --host=0.0.0.0 --port=5000',
],
[
'uuid' => 'railpack-go-gin',
'name' => 'Railpack Go Gin Example',
'base_directory' => '/go/gin',
'ports_exposes' => '3000',
'git_branch' => 'v4.x',
],
[
'uuid' => 'railpack-rust',
'name' => 'Railpack Rust Example',
'base_directory' => '/rust',
'ports_exposes' => '8000',
'git_branch' => 'v4.x',
],
[
'uuid' => 'railpack-laravel',
'name' => 'Railpack Laravel Example',
'base_directory' => '/laravel',
'ports_exposes' => '80',
'git_branch' => 'v4.x',
],
[
'uuid' => 'railpack-laravel-pure',
'name' => 'Railpack Laravel Pure Example',
'base_directory' => '/laravel-pure',
'ports_exposes' => '80',
'git_branch' => 'v4.x',
],
[
'uuid' => 'railpack-laravel-inertia',
'name' => 'Railpack Laravel Inertia Example',
'base_directory' => '/laravel-inertia',
'ports_exposes' => '80',
'git_branch' => 'v4.x',
],
[
'uuid' => 'railpack-symfony',
'name' => 'Railpack Symfony Example',
'base_directory' => '/symfony',
'ports_exposes' => '80',
'git_branch' => 'v4.x',
],
[
'uuid' => 'railpack-rails',
'name' => 'Railpack Ruby on Rails Example',
'base_directory' => '/rails-example',
'ports_exposes' => '3000',
'git_branch' => 'v4.x',
],
[
'uuid' => 'railpack-elixir-phoenix',
'name' => 'Railpack Elixir Phoenix Example',
'base_directory' => '/elixir-phoenix',
'ports_exposes' => '4000',
'git_branch' => 'v4.x',
],
[
'uuid' => 'railpack-bun',
'name' => 'Railpack Bun Example',
'base_directory' => '/bun',
'ports_exposes' => '3000',
'git_branch' => 'v4.x',
],
];
}
private function ensureDevelopmentPrerequisitesExist(): void
{
Team::query()->firstOrCreate(
['id' => 0],
[
'name' => 'Root Team',
'description' => 'The root team',
'personal_team' => true,
],
);
PrivateKey::query()->firstOrCreate(
['id' => 1],
[
'uuid' => 'ssh',
'team_id' => 0,
'name' => 'Testing Host Key',
'description' => 'This is a test docker container',
'private_key' => <<<'KEY'
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----
KEY,
],
);
Server::query()->firstOrCreate(
['id' => 0],
[
'uuid' => 'localhost',
'name' => 'localhost',
'description' => 'This is a test docker container in development mode',
'ip' => 'coolify-testing-host',
'team_id' => 0,
'private_key_id' => 1,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
],
],
);
StandaloneDocker::query()->firstOrCreate(
['id' => 0],
[
'uuid' => 'docker',
'name' => 'Standalone Docker 1',
'network' => 'coolify',
'server_id' => 0,
],
);
$this->ensurePublicGithubSourceExists();
}
private function ensurePublicGithubSourceExists(): void
{
GithubApp::query()->firstOrCreate(
['id' => 0],
[
'uuid' => 'github-public',
'name' => 'Public GitHub',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => true,
'team_id' => 0,
],
);
}
private function isDevelopmentEnvironment(): bool
{
return in_array(config('app.env'), ['local', 'development', 'dev'], true);
}
private function prepareEnvironment(): Environment
{
$project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]);
$project->fill([
'name' => 'Railpack Examples',
'description' => 'Development-only Railpack examples from coollabsio/coolify-examples@next.',
'team_id' => 0,
]);
$project->save();
$environment = $project->environments()->first();
if (! $environment) {
$environment = $project->environments()->create([
'name' => 'production',
'uuid' => self::ENVIRONMENT_UUID,
]);
} else {
$environment->update([
'name' => 'production',
'uuid' => self::ENVIRONMENT_UUID,
]);
}
return $environment;
}
/**
* @param array<string, mixed> $example
*/
private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example): void
{
$application = Application::withTrashed()->firstOrNew(['uuid' => $example['uuid']]);
$application->fill([
'name' => $example['name'],
'description' => $example['name'],
'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io",
'repository_project_id' => self::REPOSITORY_PROJECT_ID,
'git_repository' => self::GIT_REPOSITORY,
'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH,
'build_pack' => 'railpack',
'ports_exposes' => $example['ports_exposes'],
'base_directory' => $example['base_directory'],
'publish_directory' => $example['publish_directory'] ?? null,
'static_image' => 'nginx:alpine',
'install_command' => $example['install_command'] ?? null,
'build_command' => $example['build_command'] ?? null,
'start_command' => $example['start_command'] ?? null,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'source_id' => 0,
'source_type' => GithubApp::class,
]);
$application->save();
if ($application->trashed()) {
$application->restore();
}
$application->settings()->updateOrCreate(
['application_id' => $application->id],
[
'is_static' => $example['is_static'] ?? false,
'is_spa' => $example['is_spa'] ?? false,
],
);
}
}

View file

@ -11,6 +11,10 @@ ARG DOCKER_BUILDX_VERSION=0.25.0
ARG PACK_VERSION=0.38.2
# https://github.com/railwayapp/nixpacks/releases
ARG NIXPACKS_VERSION=1.41.0
# https://github.com/railwayapp/railpack/releases
ARG RAILPACK_VERSION=0.22.0
# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt)
ARG MISE_VERSION=2026.3.12
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
@ -25,18 +29,34 @@ ARG DOCKER_COMPOSE_VERSION
ARG DOCKER_BUILDX_VERSION
ARG PACK_VERSION
ARG NIXPACKS_VERSION
ARG RAILPACK_VERSION
ARG MISE_VERSION
USER root
WORKDIR /artifacts
ENV RAILPACK_VERSION=${RAILPACK_VERSION}
RUN apk upgrade --no-cache && \
apk add --no-cache bash curl git git-lfs openssh-client tar tini
RUN mkdir -p ~/.docker/cli-plugins
# Install mise (musl build) at the path railpack expects (/tmp/railpack/mise/mise-VERSION).
# Railpack hardcodes a glibc mise download that fails on Alpine, so we pre-place a musl binary.
RUN mkdir -p /tmp/railpack/mise && \
if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \
curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64-musl.tar.gz" | tar xz && \
mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \
elif [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \
curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-arm64-musl.tar.gz" | tar xz && \
mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \
fi
RUN if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \
curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx && \
curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose && \
(curl -sSL https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \
(curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \
curl -sSL https://nixpacks.com/install.sh | bash && \
curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \
chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \
;fi
@ -46,6 +66,7 @@ RUN if [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \
(curl -sSL https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \
(curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux-arm64.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \
curl -sSL https://nixpacks.com/install.sh | bash && \
curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \
chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \
;fi

View file

@ -111,6 +111,7 @@
"type": "string",
"enum": [
"nixpacks",
"railpack",
"static",
"dockerfile",
"dockercompose"
@ -569,6 +570,7 @@
"type": "string",
"enum": [
"nixpacks",
"railpack",
"static",
"dockerfile",
"dockercompose"
@ -1019,6 +1021,7 @@
"type": "string",
"enum": [
"nixpacks",
"railpack",
"static",
"dockerfile",
"dockercompose"
@ -1448,10 +1451,7 @@
"build_pack": {
"type": "string",
"enum": [
"nixpacks",
"static",
"dockerfile",
"dockercompose"
"dockerfile"
],
"description": "The build pack type."
},
@ -2290,6 +2290,7 @@
"type": "string",
"enum": [
"nixpacks",
"railpack",
"static",
"dockerfile",
"dockercompose"
@ -12440,6 +12441,7 @@
"description": "Build pack.",
"enum": [
"nixpacks",
"railpack",
"static",
"dockerfile",
"dockercompose"

View file

@ -81,7 +81,7 @@ paths:
description: 'The git branch.'
build_pack:
type: string
enum: [nixpacks, static, dockerfile, dockercompose]
enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
ports_exposes:
type: string
@ -375,7 +375,7 @@ paths:
description: 'The destination UUID.'
build_pack:
type: string
enum: [nixpacks, static, dockerfile, dockercompose]
enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
name:
type: string
@ -663,7 +663,7 @@ paths:
description: 'The destination UUID.'
build_pack:
type: string
enum: [nixpacks, static, dockerfile, dockercompose]
enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
name:
type: string
@ -935,7 +935,7 @@ paths:
description: 'The Dockerfile content.'
build_pack:
type: string
enum: [nixpacks, static, dockerfile, dockercompose]
enum: [dockerfile]
description: 'The build pack type.'
ports_exposes:
type: string
@ -1479,7 +1479,7 @@ paths:
description: 'The destination UUID.'
build_pack:
type: string
enum: [nixpacks, static, dockerfile, dockercompose]
enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
name:
type: string
@ -7888,6 +7888,7 @@ components:
description: 'Build pack.'
enum:
- nixpacks
- railpack
- static
- dockerfile
- dockercompose

View file

@ -32,6 +32,7 @@
<x-forms.select x-bind:disabled="shouldDisable()" wire:model.live="buildPack" label="Build Pack"
required>
<option value="nixpacks">Nixpacks</option>
<option value="railpack">Railpack (Beta)</option>
<option value="static">Static</option>
<option value="dockerfile">Dockerfile</option>
<option value="dockercompose">Docker Compose</option>
@ -226,20 +227,24 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
id="customDockerRunOptions" label="Custom Docker Options" x-bind:disabled="!canUpdate" />
@else
@if ($application->could_set_build_commands())
@if ($buildPack === 'nixpacks')
@if ($buildPack === 'nixpacks' || $buildPack === 'railpack')
<div class="flex flex-col gap-2 xl:flex-row">
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
<x-forms.input helper="If you modify this, you probably need to have a {{ $buildPack === 'railpack' ? 'railpack.json' : 'nixpacks.toml' }}"
id="installCommand" label="Install Command" x-bind:disabled="!canUpdate" />
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
<x-forms.input helper="If you modify this, you probably need to have a {{ $buildPack === 'railpack' ? 'railpack.json' : 'nixpacks.toml' }}"
id="buildCommand" label="Build Command" x-bind:disabled="!canUpdate" />
<x-forms.input helper="If you modify this, you probably need to have a nixpacks.toml"
<x-forms.input helper="If you modify this, you probably need to have a {{ $buildPack === 'railpack' ? 'railpack.json' : 'nixpacks.toml' }}"
id="startCommand" label="Start Command" x-bind:disabled="!canUpdate" />
</div>
<div class="pt-1 text-xs">Nixpacks will detect the required configuration
automatically.
@if ($buildPack === 'nixpacks')
<div class="pt-1 text-xs">
<span class="font-medium">Nixpacks</span>
will detect the required configuration automatically.
<a class="underline" href="https://coolify.io/docs/applications/">Framework
Specific Docs</a>
</div>
@endif
@endif
@endif

View file

@ -52,6 +52,7 @@ class="loading loading-xs dark:text-warning loading-spinner"></span>
<x-forms.input id="branch" required label="Branch" />
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
<option value="nixpacks">Nixpacks</option>
<option value="railpack">Railpack (Beta)</option>
<option value="static">Static</option>
<option value="dockerfile">Dockerfile</option>
<option value="dockercompose">Docker Compose</option>
@ -60,6 +61,14 @@ class="loading loading-xs dark:text-warning loading-spinner"></span>
<x-forms.input id="publish_directory" required label="Publish Directory" />
@endif
</div>
@if ($build_pack === 'railpack')
<div>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Beta
</span>
</div>
@endif
@if ($build_pack === 'dockercompose')
<div x-data="{
baseDir: '{{ $base_directory }}',

View file

@ -83,6 +83,7 @@
</x-forms.select>
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
<option value="nixpacks">Nixpacks</option>
<option value="railpack">Railpack (Beta)</option>
<option value="static">Static</option>
<option value="dockerfile">Dockerfile</option>
<option value="dockercompose">Docker Compose</option>
@ -92,6 +93,14 @@
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
@endif
</div>
@if ($build_pack === 'railpack')
<div>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Beta
</span>
</div>
@endif
@if ($build_pack === 'dockercompose')
<div x-data="{
baseDir: '{{ $base_directory }}',

View file

@ -42,6 +42,7 @@
@endif
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
<option value="nixpacks">Nixpacks</option>
<option value="railpack">Railpack (Beta)</option>
<option value="static">Static</option>
<option value="dockerfile">Dockerfile</option>
<option value="dockercompose">Docker Compose</option>
@ -51,6 +52,14 @@
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
@endif
</div>
@if ($build_pack === 'railpack')
<div>
<span
class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:bg-warning/20 text-coollabs dark:text-warning rounded">
Beta
</span>
</div>
@endif
@if ($build_pack === 'dockercompose')
<div x-data="{
baseDir: '{{ $base_directory }}',

View file

@ -58,7 +58,7 @@
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@endif
@else
@if (!$env->is_nixpacks)
@if (!$env->is_buildpack_control)
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
@ -67,7 +67,7 @@
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$isMagicVariable)
@if (!$env->is_nixpacks)
@if (!$env->is_buildpack_control)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox instantSave id="is_literal"
@ -236,7 +236,7 @@
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@endif
@else
@if (!$env->is_nixpacks)
@if (!$env->is_buildpack_control)
<x-forms.checkbox instantSave id="is_buildtime"
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
label="Available at Buildtime" />
@ -245,7 +245,7 @@
helper="Make this variable available in the running container at runtime."
label="Available at Runtime" />
@if (!$isMagicVariable)
@if (!$env->is_nixpacks)
@if (!$env->is_buildpack_control)
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
@if ($is_multiline === false)
<x-forms.checkbox instantSave id="is_literal"

322
scripts/railpack-smoke.sh Executable file
View file

@ -0,0 +1,322 @@
#!/usr/bin/env bash
#
# Railpack end-to-end deploy smoke test against the local dev stack.
#
# Walks a curated set of railpack-* example apps from
# DevelopmentRailpackExamplesSeeder, triggers a deploy via the Coolify API,
# waits for the deployment queue to finish, then exec()s into the resulting
# container and checks that COOLIFY_*, SOURCE_COMMIT, and any RAILPACK_*
# build inputs landed correctly. Optionally curls the FQDN.
#
# Requires:
# - Dev stack running: spin up (or docker compose -f docker-compose.dev.yml up -d)
# - Seeder run: php artisan db:seed --class=DevelopmentRailpackExamplesSeeder
# - Personal token: PersonalAccessTokenSeeder run (creates Bearer 'root')
# - jq, curl available on host
#
# Usage:
# scripts/railpack-smoke.sh # default subset
# scripts/railpack-smoke.sh --app railpack-laravel # single app
# scripts/railpack-smoke.sh --all # every seeded railpack-* app
# scripts/railpack-smoke.sh --timeout 900 # build wait per app, seconds
# scripts/railpack-smoke.sh --no-curl # skip FQDN curl
# scripts/railpack-smoke.sh --extra-env KEY=VALUE # build+runtime env (alias of --both-env)
# scripts/railpack-smoke.sh --build-env KEY=VALUE # buildtime-only env (must reach build, NOT runtime)
# scripts/railpack-smoke.sh --runtime-env KEY=VALUE # runtime-only env (must reach runtime, NOT build)
# scripts/railpack-smoke.sh --both-env KEY=VALUE # buildtime+runtime env
#
set -euo pipefail
API_BASE="${COOLIFY_API_BASE:-http://localhost:8000/api/v1}"
TOKEN="${COOLIFY_API_TOKEN:-root}"
TIMEOUT="${SMOKE_TIMEOUT:-600}"
DO_CURL=1
BUILD_ENVS=()
RUNTIME_ENVS=()
BOTH_ENVS=()
APPS=()
DEFAULT_APPS=(
railpack-expressjs
railpack-nestjs
railpack-nextjs-ssr
railpack-vite-static
railpack-astro-static
railpack-python-flask
railpack-go-gin
railpack-rust
railpack-symfony
railpack-bun
)
while (( $# > 0 )); do
case "$1" in
--app) APPS+=("$2"); shift 2 ;;
--all) APPS=(__ALL__); shift ;;
--timeout) TIMEOUT="$2"; shift 2 ;;
--no-curl) DO_CURL=0; shift ;;
--extra-env|--both-env) BOTH_ENVS+=("$2"); shift 2 ;;
--build-env) BUILD_ENVS+=("$2"); shift 2 ;;
--runtime-env) RUNTIME_ENVS+=("$2"); shift 2 ;;
--base) API_BASE="$2"; shift 2 ;;
--token) TOKEN="$2"; shift 2 ;;
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
if ! command -v jq >/dev/null; then
echo "jq required" >&2; exit 2
fi
if ! command -v docker >/dev/null; then
echo "docker required" >&2; exit 2
fi
curl_api() {
local method="$1"; shift
local path="$1"; shift
curl -fsS -X "$method" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}${path}" \
"$@"
}
if (( ${#APPS[@]} == 0 )); then
APPS=("${DEFAULT_APPS[@]}")
fi
if [[ "${APPS[0]}" == "__ALL__" ]]; then
mapfile -t APPS < <(curl_api GET /applications | jq -r '.[].uuid' | grep '^railpack-' || true)
fi
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
fail() { printf '\033[31m[FAIL]\033[0m %s: %s\n' "$1" "$2"; FAILED+=("$1: $2"); }
pass() { printf '\033[32m[ OK ]\033[0m %s: %s\n' "$1" "$2"; }
upsert_env() {
local app_uuid="$1" key="$2" value="$3" buildtime="$4" runtime="$5" existing
existing=$(curl_api GET "/applications/${app_uuid}/envs" | jq -r --arg k "$key" '.[] | select(.key==$k) | .uuid' | head -1)
local payload
payload=$(jq -nc --arg k "$key" --arg v "$value" --argjson b "$buildtime" --argjson r "$runtime" \
'{key:$k, value:$v, is_buildtime:$b, is_runtime:$r, is_preview:false}')
if [[ -n "$existing" ]]; then
curl_api PATCH "/applications/${app_uuid}/envs" --data "$payload" >/dev/null
log " env ${key} updated (buildtime=${buildtime} runtime=${runtime})"
else
curl_api POST "/applications/${app_uuid}/envs" --data "$payload" >/dev/null
log " env ${key} created (buildtime=${buildtime} runtime=${runtime})"
fi
}
ensure_envs() {
local app_uuid="$1" kv key value
for kv in "${BUILD_ENVS[@]:-}"; do
[[ -z "$kv" ]] && continue
key="${kv%%=*}"; value="${kv#*=}"
upsert_env "$app_uuid" "$key" "$value" true false
done
for kv in "${RUNTIME_ENVS[@]:-}"; do
[[ -z "$kv" ]] && continue
key="${kv%%=*}"; value="${kv#*=}"
upsert_env "$app_uuid" "$key" "$value" false true
done
for kv in "${BOTH_ENVS[@]:-}"; do
[[ -z "$kv" ]] && continue
key="${kv%%=*}"; value="${kv#*=}"
upsert_env "$app_uuid" "$key" "$value" true true
done
}
trigger_deploy() {
local app_uuid="$1"
curl_api POST "/applications/${app_uuid}/start?force=true&instant_deploy=true" \
| jq -r '.deployment_uuid // empty'
}
wait_for_deploy() {
local dep_uuid="$1" deadline="$2" status
while (( $(date +%s) < deadline )); do
status=$(curl_api GET "/deployments/${dep_uuid}" | jq -r '.status // "unknown"')
case "$status" in
finished) echo finished; return 0 ;;
failed|cancelled) echo "$status"; return 1 ;;
queued|in_progress) sleep 5 ;;
*) sleep 5 ;;
esac
done
echo timeout; return 1
}
container_for_app() {
local app_uuid="$1"
docker ps --filter "name=^${app_uuid}-" --format '{{.Names}}' | head -1
}
assert_envs_present() {
local container="$1" app_uuid="$2"
local env_dump
env_dump=$(docker exec "$container" env 2>/dev/null || true)
local missing=()
for required in COOLIFY_FQDN COOLIFY_URL COOLIFY_BRANCH COOLIFY_RESOURCE_UUID COOLIFY_CONTAINER_NAME SOURCE_COMMIT; do
if ! grep -q "^${required}=" <<<"$env_dump"; then
missing+=("$required")
fi
done
local resource_uuid
resource_uuid=$(grep '^COOLIFY_RESOURCE_UUID=' <<<"$env_dump" | cut -d= -f2- || true)
if [[ "$resource_uuid" != "$app_uuid" ]]; then
missing+=("COOLIFY_RESOURCE_UUID-mismatch(got=${resource_uuid})")
fi
if (( ${#missing[@]} == 0 )); then
pass "$app_uuid" "runtime envs present (${resource_uuid})"
return 0
fi
fail "$app_uuid" "missing/incorrect envs: ${missing[*]}"
return 1
}
deploy_logs_text() {
local dep_uuid="$1"
curl_api GET "/deployments/${dep_uuid}" | jq -r '(.logs | fromjson? // []) | .[].output' 2>/dev/null
}
assert_runtime_only_envs() {
local container="$1" app_uuid="$2"
[[ ${#RUNTIME_ENVS[@]} -eq 0 ]] && return 0
local env_dump key value actual
env_dump=$(docker exec "$container" env 2>/dev/null || true)
for kv in "${RUNTIME_ENVS[@]}"; do
key="${kv%%=*}"; value="${kv#*=}"
if ! grep -q "^${key}=" <<<"$env_dump"; then
fail "$app_uuid" "runtime-only env ${key} missing at runtime"
return 1
fi
actual=$(grep "^${key}=" <<<"$env_dump" | head -1 | cut -d= -f2-)
if [[ "$actual" != "$value" ]]; then
fail "$app_uuid" "runtime env ${key} value mismatch (got=${actual} want=${value})"
return 1
fi
done
pass "$app_uuid" "runtime-only envs present at runtime (${#RUNTIME_ENVS[@]} key(s))"
}
assert_build_only_envs() {
local container="$1" app_uuid="$2" dep_uuid="$3"
[[ ${#BUILD_ENVS[@]} -eq 0 ]] && return 0
local env_dump logs key
env_dump=$(docker exec "$container" env 2>/dev/null || true)
logs=$(deploy_logs_text "$dep_uuid")
for kv in "${BUILD_ENVS[@]}"; do
key="${kv%%=*}"
# Reach build: railpack passes buildtime envs as docker buildx --secret id=KEY
if ! grep -q -- "--secret id=${key}" <<<"$logs"; then
fail "$app_uuid" "build-only env ${key} not seen as --secret in deploy logs"
return 1
fi
# Must NOT leak to runtime container
if grep -q "^${key}=" <<<"$env_dump"; then
fail "$app_uuid" "build-only env ${key} LEAKED to runtime container"
return 1
fi
done
pass "$app_uuid" "build-only envs in build secret + absent at runtime (${#BUILD_ENVS[@]} key(s))"
}
assert_both_envs() {
local container="$1" app_uuid="$2" dep_uuid="$3"
[[ ${#BOTH_ENVS[@]} -eq 0 ]] && return 0
local env_dump logs key
env_dump=$(docker exec "$container" env 2>/dev/null || true)
logs=$(deploy_logs_text "$dep_uuid")
for kv in "${BOTH_ENVS[@]}"; do
key="${kv%%=*}"
if [[ "$key" =~ ^RAILPACK_ ]]; then
# RAILPACK_* are buildtime-only by railpack convention; skip runtime check
grep -q -- "--secret id=${key}" <<<"$logs" \
|| { fail "$app_uuid" "${key} not seen in build secrets"; return 1; }
continue
fi
grep -q "^${key}=" <<<"$env_dump" \
|| { fail "$app_uuid" "both-env ${key} missing at runtime"; return 1; }
done
pass "$app_uuid" "both-envs reached runtime (${#BOTH_ENVS[@]} key(s))"
}
assert_fqdn_responds() {
local app_uuid="$1"
local fqdn
fqdn=$(curl_api GET "/applications/${app_uuid}" | jq -r '.fqdn // empty')
[[ -z "$fqdn" ]] && return 0
local code
code=$(curl -ksSL -o /dev/null -w '%{http_code}' --max-time 10 "$fqdn" || echo "000")
case "$code" in
2*|3*|4*) pass "$app_uuid" "fqdn ${fqdn} -> ${code}" ;;
*) fail "$app_uuid" "fqdn ${fqdn} -> ${code}" ;;
esac
}
run_one() {
local app_uuid="$1"
log "==> ${app_uuid}"
if ! curl_api GET "/applications/${app_uuid}" >/dev/null 2>&1; then
fail "$app_uuid" "application not found via API (run seeder?)"
return
fi
ensure_envs "$app_uuid"
local dep
dep=$(trigger_deploy "$app_uuid")
if [[ -z "$dep" ]]; then
fail "$app_uuid" "no deployment_uuid returned"
return
fi
log " deploy queued: ${dep}"
local deadline=$(( $(date +%s) + TIMEOUT ))
local result
result=$(wait_for_deploy "$dep" "$deadline") || {
fail "$app_uuid" "deploy ${result}"
return
}
pass "$app_uuid" "deploy ${result}"
sleep 2
local container
container=$(container_for_app "$app_uuid")
if [[ -z "$container" ]]; then
fail "$app_uuid" "no running container matching name=^${app_uuid}-"
return
fi
pass "$app_uuid" "container ${container} running"
assert_envs_present "$container" "$app_uuid" || true
assert_runtime_only_envs "$container" "$app_uuid" || true
assert_build_only_envs "$container" "$app_uuid" "$dep" || true
assert_both_envs "$container" "$app_uuid" "$dep" || true
if (( DO_CURL )); then
assert_fqdn_responds "$app_uuid" || true
fi
}
FAILED=()
for app in "${APPS[@]}"; do
run_one "$app"
done
echo
echo "=== summary ==="
if (( ${#FAILED[@]} == 0 )); then
echo "all apps passed"
exit 0
fi
printf '%s failure(s):\n' "${#FAILED[@]}"
printf ' - %s\n' "${FAILED[@]}"
exit 1

View file

@ -0,0 +1,345 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'railpack-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function railpackApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function makeRailpackApp(array $overrides = []): Application
{
return Application::factory()->create(array_merge([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
'build_pack' => 'railpack',
], $overrides));
}
describe('PATCH /api/v1/applications/{uuid} build_pack=railpack', function () {
test('rejects unsupported build_pack at controller layer', function () {
$app = makeRailpackApp();
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'build_pack' => 'totally-bogus',
]);
$response->assertStatus(422);
});
test('switching from dockerfile to railpack clears dockerfile fields', function () {
$app = makeRailpackApp([
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM node:20',
'dockerfile_location' => '/Dockerfile',
'dockerfile_target_build' => 'production',
'custom_healthcheck_found' => true,
]);
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'build_pack' => 'railpack',
]);
$response->assertOk();
$app->refresh();
expect($app->build_pack)->toBe('railpack');
expect($app->dockerfile)->toBeNull();
// NOTE: dockerfile_location is normalized to '/Dockerfile' by the model
// mutator when set to null, so we cannot assert it becomes null here.
expect($app->dockerfile_target_build)->toBeNull();
expect((bool) $app->custom_healthcheck_found)->toBeFalse();
});
test('switching from dockercompose to railpack clears compose fields and SERVICE_* envs', function () {
$app = makeRailpackApp([
'build_pack' => 'dockercompose',
'docker_compose_domains' => '{"app": "example.com"}',
'docker_compose_raw' => "version: '3'\nservices:\n app:\n image: nginx",
]);
$app->environment_variables()->createMany([
['key' => 'SERVICE_FQDN_APP', 'value' => 'app.example.com', 'is_buildtime' => false, 'is_preview' => false],
['key' => 'SERVICE_URL_APP', 'value' => 'http://app.example.com', 'is_buildtime' => false, 'is_preview' => false],
['key' => 'REGULAR_VAR', 'value' => 'keep_me', 'is_buildtime' => false, 'is_preview' => false],
]);
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'build_pack' => 'railpack',
]);
$response->assertOk();
$app->refresh();
expect($app->build_pack)->toBe('railpack');
expect($app->docker_compose_domains)->toBeNull();
expect($app->docker_compose_raw)->toBeNull();
expect($app->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0);
expect($app->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0);
expect($app->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
});
test('install/build/start commands persist for railpack apps', function () {
$app = makeRailpackApp();
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}", [
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'start_command' => 'node server.js',
]);
$response->assertOk();
$app->refresh();
expect($app->install_command)->toBe('npm ci');
expect($app->build_command)->toBe('npm run build');
expect($app->start_command)->toBe('node server.js');
});
});
describe('POST /api/v1/applications/{uuid}/envs RAILPACK_* handling', function () {
test('adding RAILPACK_NODE_VERSION via API surfaces in railpack_environment_variables only', function () {
$app = makeRailpackApp();
$response = $this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
]);
$response->assertCreated();
$app->refresh();
expect($app->railpack_environment_variables)->toHaveCount(1);
expect($app->railpack_environment_variables->first()->key)->toBe('RAILPACK_NODE_VERSION');
expect($app->runtime_environment_variables->where('key', 'RAILPACK_NODE_VERSION'))->toHaveCount(0);
});
test('runtime envs added via API surface in runtime_environment_variables but not railpack_*', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'APP_ENV',
'value' => 'production',
'is_runtime' => true,
'is_buildtime' => false,
'is_preview' => false,
])->assertCreated();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '18',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$runtime = $app->runtime_environment_variables;
expect($runtime->pluck('key')->all())->toBe(['APP_ENV']);
expect($app->railpack_environment_variables)->toHaveCount(0);
});
test('preview RAILPACK_* envs surface in railpack_environment_variables_preview only', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RAILPACK_BUILD_CMD',
'value' => 'npm run build',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => true,
])->assertCreated();
$app->refresh();
expect($app->railpack_environment_variables_preview)->toHaveCount(1);
expect($app->railpack_environment_variables)->toHaveCount(0);
});
test('buildtime-only env has is_buildtime=true and is_runtime=false', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'API_KEY',
'value' => 'sekret',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'API_KEY')->first();
expect($env)->not->toBeNull();
expect((bool) $env->is_buildtime)->toBeTrue();
expect((bool) $env->is_runtime)->toBeFalse();
// Buildtime-only non-RAILPACK_ var: visible to runtime relation (it's not a buildpack-control var)
// but is_runtime flag is false; consumers gate runtime via is_runtime, not via the relation alone.
expect($env->resourceable_id)->toBe($app->id);
});
test('runtime-only env has is_runtime=true and is_buildtime=false', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'LOG_LEVEL',
'value' => 'debug',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'LOG_LEVEL')->first();
expect((bool) $env->is_buildtime)->toBeFalse();
expect((bool) $env->is_runtime)->toBeTrue();
});
test('railpack build variables collection includes only is_buildtime=true entries', function () {
// Sanity check the underlying query used by the deploy job: railpack_build_variables()
// pulls $application->environment_variables()->where('is_buildtime', true)->get()
// (see ApplicationDeploymentJob::railpack_build_variables).
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'BUILD_ARG',
'value' => 'in-build',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
])->assertCreated();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RUNTIME_ARG',
'value' => 'in-runtime',
'is_buildtime' => false,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$buildtime = $app->environment_variables()->where('is_buildtime', true)->pluck('key')->all();
expect($buildtime)->toContain('BUILD_ARG');
expect($buildtime)->not->toContain('RUNTIME_ARG');
});
test('user-defined COOLIFY_FQDN takes precedence over auto-generated', function () {
// Documents generate_coolify_env_variables() override behavior:
// it skips generation when application->environment_variables already has the key.
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'COOLIFY_FQDN',
'value' => 'overridden.example.com',
'is_buildtime' => true,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'COOLIFY_FQDN')->first();
expect($env)->not->toBeNull();
expect($env->value)->toBe('overridden.example.com');
// Confirm the model relation used by override-skip logic finds it
expect($app->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty())->toBeFalse();
});
test('is_literal flag persists on create', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'RAILPACK_LITERAL_FLAG',
'value' => '$NOT_INTERPOLATED',
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
'is_literal' => true,
])->assertCreated();
$app->refresh();
$env = $app->environment_variables()->where('key', 'RAILPACK_LITERAL_FLAG')->first();
expect((bool) $env->is_literal)->toBeTrue();
});
test('PATCH env updates buildtime/runtime flags', function () {
$app = makeRailpackApp();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'TOGGLE_VAR',
'value' => 'v1',
'is_buildtime' => true,
'is_runtime' => true,
'is_preview' => false,
])->assertCreated();
$this->withHeaders(railpackApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$app->uuid}/envs", [
'key' => 'TOGGLE_VAR',
'value' => 'v2',
'is_buildtime' => false,
'is_runtime' => true,
'is_multiline' => false,
'is_shown_once' => false,
])->assertStatus(201);
$app->refresh();
$env = $app->environment_variables()->where('key', 'TOGGLE_VAR')->first();
expect($env->value)->toBe('v2');
expect((bool) $env->is_buildtime)->toBeFalse();
expect((bool) $env->is_runtime)->toBeTrue();
});
});

View file

@ -78,26 +78,29 @@
// Add environment variables that should be deleted
EnvironmentVariable::create([
'application_id' => $application->id,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_FQDN_APP',
'value' => 'app.example.com',
'is_build_time' => false,
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'application_id' => $application->id,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_URL_APP',
'value' => 'http://app.example.com',
'is_build_time' => false,
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'application_id' => $application->id,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'should_remain',
'is_build_time' => false,
'is_buildtime' => false,
'is_preview' => false,
]);
@ -117,6 +120,87 @@
expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
});
test('model clears dockerfile fields when build_pack changes from dockerfile to railpack', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM node:18',
'dockerfile_location' => '/Dockerfile',
'dockerfile_target_build' => 'production',
'custom_healthcheck_found' => true,
]);
$application->build_pack = 'railpack';
$application->save();
$application->refresh();
expect($application->build_pack)->toBe('railpack');
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
});
test('model clears dockercompose fields when build_pack changes from dockercompose to railpack', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'build_pack' => 'dockercompose',
'docker_compose_domains' => '{"app": "example.com"}',
'docker_compose_raw' => 'version: "3.8"\nservices:\n app:\n image: nginx',
]);
// Add environment variables that should be deleted
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_FQDN_APP',
'value' => 'app.example.com',
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'SERVICE_URL_APP',
'value' => 'http://app.example.com',
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'should_remain',
'is_buildtime' => false,
'is_preview' => false,
]);
$application->build_pack = 'railpack';
$application->save();
$application->refresh();
expect($application->build_pack)->toBe('railpack');
expect($application->docker_compose_domains)->toBeNull();
expect($application->docker_compose_raw)->toBeNull();
// Verify SERVICE_FQDN_* and SERVICE_URL_* were deleted
expect($application->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0);
expect($application->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0);
// Verify regular variables remain
expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1);
});
test('model does not clear dockerfile fields when switching to dockerfile', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);

View file

@ -0,0 +1,388 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
uses(RefreshDatabase::class);
class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob
{
public array $recordedCommands = [];
public ?string $writtenDockerfile = null;
public function __construct() {}
public function execute_remote_command(...$commands)
{
$this->recordedCommands[] = $commands;
foreach ($commands as $command) {
$commandString = is_array($command) ? ($command['command'] ?? $command[0] ?? null) : $command;
if (! is_string($commandString)) {
continue;
}
if (preg_match('/echo .*?([A-Za-z0-9+\\/=]{16,}).*?\\| base64 -d \\| tee \\/artifacts\\/test-app\\/Dockerfile > \\/dev\\/null/', $commandString, $matches) === 1) {
$this->writtenDockerfile = base64_decode($matches[1]) ?: null;
}
}
}
}
function makeDeploymentControlVarFixture(array $applicationAttributes = []): array
{
$team = Team::create([
'name' => 'Control Var Team',
'description' => 'Team for deployment control var tests.',
'personal_team' => false,
'show_boarding' => false,
]);
$project = Project::create([
'name' => 'Control Var Project',
'team_id' => $team->id,
]);
$environment = Environment::where('project_id', $project->id)->firstOrFail();
$server = Server::factory()->create([
'team_id' => $team->id,
]);
$application = Application::factory()->create([
'environment_id' => $environment->id,
'build_pack' => 'dockerfile',
...$applicationAttributes,
]);
$application->settings()->update([
'inject_build_args_to_dockerfile' => true,
'include_source_commit_in_build' => false,
'is_env_sorting_enabled' => false,
]);
return [$application->fresh(), $server];
}
function createApplicationEnvironmentVariable(Application $application, array $attributes): EnvironmentVariable
{
return EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => true,
'is_multiline' => false,
'is_literal' => false,
...$attributes,
]);
}
function makeControlVarFilteringJob(Application $application, Server $server, array $overrides = []): array
{
$job = new TestableControlVarFilteringDeploymentJob;
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$queue = Mockery::mock(ApplicationDeploymentQueue::class);
$queue->shouldReceive('addLogEntry')->andReturnNull();
$properties = [
'application' => $application->fresh(),
'application_deployment_queue' => $queue,
'build_pack' => $application->build_pack,
'mainServer' => $server,
'pull_request_id' => 0,
'commit' => 'HEAD',
'workdir' => '/artifacts/test-app',
'deployment_uuid' => 'deployment-uuid',
'dockerfile_location' => '/Dockerfile',
'container_name' => 'control-var-app',
'coolify_variables' => null,
'dockerSecretsSupported' => false,
];
$mergedProperties = array_merge($properties, $overrides);
$mergedProperties['saved_outputs'] = new Collection($overrides['saved_outputs'] ?? []);
if (($mergedProperties['pull_request_id'] ?? 0) !== 0 && ! array_key_exists('preview', $mergedProperties)) {
$mergedProperties['preview'] = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => $mergedProperties['pull_request_id'],
'pull_request_html_url' => 'https://example.com/pr/'.$mergedProperties['pull_request_id'],
'fqdn' => 'https://preview.example.com',
]);
}
foreach ($mergedProperties as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
return [$job, $reflection];
}
function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed
{
$reflectionMethod = $reflection->getMethod($method);
$reflectionMethod->setAccessible(true);
return $reflectionMethod->invoke($job);
}
function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed
{
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
return $reflectionProperty->getValue($job);
}
it('filters buildpack control vars from generic build args', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
/** @var Collection $envArgs */
$envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
expect($envArgs->get('APP_ENV'))->toBe('production');
expect($envArgs->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($envArgs->has('RAILPACK_NODE_VERSION'))->toBeFalse();
});
it('filters buildpack control vars from preview build-time env files', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_preview' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_preview' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_preview' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'pull_request_id' => 42,
]);
/** @var Collection $buildtimeEnvs */
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_ENV=')))->toBeTrue();
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse();
expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
});
it('filters buildpack control vars from preview runtime env fallback', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_NAME',
'value' => 'coolify',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'PREVIEW_FLAG',
'value' => 'enabled',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
$application->environment_variables_preview()
->whereIn('key', ['APP_NAME', 'NIXPACKS_NODE_VERSION', 'RAILPACK_NODE_VERSION'])
->delete();
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'pull_request_id' => 99,
]);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=')))->toBeTrue();
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue();
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse();
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse();
});
it('filters buildpack control vars from dockerfile arg injection', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_runtime' => false,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'saved_outputs' => [
'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
],
]);
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
expect($job->writtenDockerfile)->toContain('ARG APP_ENV=production');
expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION=');
expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION=');
});
it('builds railpack variables from generic buildtime vars railpack vars and coolify vars only', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'railpack',
'fqdn' => 'https://railpack.example.com',
'install_command' => 'pnpm install --frozen-lockfile',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RUNTIME_ONLY',
'value' => 'runtime',
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_runtime' => false,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'build_pack' => 'railpack',
'branch' => 'main',
]);
/** @var Collection $variables */
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
expect($variables->get('APP_ENV'))->toBe('production');
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($variables->has('RUNTIME_ONLY'))->toBeFalse();
});
it('builds preview railpack variables without leaking stale nixpacks vars', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'railpack',
'fqdn' => 'https://railpack.example.com',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'PREVIEW_BUILD_FLAG',
'value' => 'enabled',
'is_preview' => true,
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'PREVIEW_RUNTIME_ONLY',
'value' => 'runtime',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_preview' => true,
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_preview' => true,
'is_runtime' => false,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'build_pack' => 'railpack',
'branch' => 'feature/railpack',
'pull_request_id' => 123,
]);
/** @var Collection $variables */
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled');
expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20');
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget');
expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid);
expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse();
expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse();
});

View file

@ -0,0 +1,86 @@
<?php
use App\Livewire\Project\Application\General;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $this->team->id,
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
test('existing application buildpack selector lists nixpacks before railpack', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSeeInOrder([
'<option value="nixpacks">Nixpacks</option>',
'<option value="railpack">Railpack (Beta)</option>',
], false);
});
test('existing application shows railpack beta badge in build helper copy', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'railpack',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('Railpack')
->assertSee('Beta');
});

View file

@ -0,0 +1,150 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
describe('Application Railpack Support', function () {
beforeEach(function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
});
test('could_set_build_commands returns true for railpack', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
expect($application->could_set_build_commands())->toBeTrue();
});
test('could_set_build_commands returns true for nixpacks', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'nixpacks',
]);
expect($application->could_set_build_commands())->toBeTrue();
});
test('could_set_build_commands returns false for dockerfile', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
]);
expect($application->could_set_build_commands())->toBeFalse();
});
test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_buildtime' => true,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'value',
'is_buildtime' => false,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '18',
'is_buildtime' => true,
'is_preview' => false,
]);
$railpackVars = $application->railpack_environment_variables;
expect($railpackVars)->toHaveCount(1);
expect($railpackVars->first()->key)->toBe('RAILPACK_NODE_VERSION');
});
test('runtime_environment_variables excludes RAILPACK_ and NIXPACKS_ prefixed vars', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'RAILPACK_NODE_VERSION',
'value' => '20',
'is_buildtime' => true,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '18',
'is_buildtime' => true,
'is_preview' => false,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'APP_ENV',
'value' => 'production',
'is_buildtime' => false,
'is_preview' => false,
]);
$runtimeVars = $application->runtime_environment_variables;
expect($runtimeVars)->toHaveCount(1);
expect($runtimeVars->first()->key)->toBe('APP_ENV');
});
test('railpack_environment_variables_preview returns only RAILPACK_ prefixed preview vars', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'railpack',
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'RAILPACK_BUILD_CMD',
'value' => 'npm run build',
'is_buildtime' => true,
'is_preview' => true,
]);
EnvironmentVariable::create([
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'key' => 'REGULAR_VAR',
'value' => 'value',
'is_buildtime' => false,
'is_preview' => true,
]);
$previewVars = $application->railpack_environment_variables_preview;
expect($previewVars)->toHaveCount(1);
expect($previewVars->first()->key)->toBe('RAILPACK_BUILD_CMD');
});
});

View file

@ -0,0 +1,51 @@
<?php
use App\Models\Application;
use Database\Seeders\ApplicationSeeder;
use Database\Seeders\GithubAppSeeder;
use Database\Seeders\PrivateKeySeeder;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ServerSeeder;
use Database\Seeders\StandaloneDockerSeeder;
use Database\Seeders\TeamSeeder;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('seeds a railpack nodejs fastify example alongside the existing nixpacks example', function () {
$this->seed([
UserSeeder::class,
TeamSeeder::class,
PrivateKeySeeder::class,
ServerSeeder::class,
ProjectSeeder::class,
StandaloneDockerSeeder::class,
GithubAppSeeder::class,
ApplicationSeeder::class,
]);
$nixpacksExample = Application::where('uuid', 'nodejs')->first();
$railpackExample = Application::where('uuid', 'railpack-nodejs')->first();
expect($nixpacksExample)
->not->toBeNull()
->and($nixpacksExample->name)->toBe('NodeJS Fastify Example')
->and($nixpacksExample->build_pack)->toBe('nixpacks')
->and($nixpacksExample->base_directory)->toBe('/nodejs')
->and($nixpacksExample->ports_exposes)->toBe('3000');
expect($railpackExample)
->not->toBeNull()
->and($railpackExample->name)->toBe('Railpack NodeJS Fastify Example')
->and($railpackExample->fqdn)->toBe('http://railpack-nodejs.127.0.0.1.sslip.io')
->and($railpackExample->repository_project_id)->toBe(603035348)
->and($railpackExample->git_repository)->toBe('coollabsio/coolify-examples')
->and($railpackExample->git_branch)->toBe('v4.x')
->and($railpackExample->base_directory)->toBe('/nodejs')
->and($railpackExample->build_pack)->toBe('railpack')
->and($railpackExample->ports_exposes)->toBe('3000')
->and($railpackExample->environment_id)->toBe(1)
->and($railpackExample->destination_id)->toBe(0)
->and($railpackExample->source_id)->toBe(1);
});

View file

@ -111,6 +111,29 @@
expect($application->dockerfile)->toBeNull();
});
test('clears dockerfile fields when switching from dockerfile to railpack', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'build_pack' => 'dockerfile',
'dockerfile' => 'FROM node:18',
'dockerfile_location' => '/Dockerfile',
'dockerfile_target_build' => 'production',
'custom_healthcheck_found' => true,
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->set('buildPack', 'railpack')
->call('updatedBuildPack');
$application->refresh();
expect($application->build_pack)->toBe('railpack');
expect($application->dockerfile)->toBeNull();
expect($application->dockerfile_location)->toBeNull();
expect($application->dockerfile_target_build)->toBeNull();
expect($application->custom_healthcheck_found)->toBeFalse();
});
test('clears dockerfile fields when switching from dockerfile to dockercompose', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,

View file

@ -0,0 +1,141 @@
<?php
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Database\Seeders\DevelopmentRailpackExamplesSeeder;
use Database\Seeders\GithubAppSeeder;
use Database\Seeders\PrivateKeySeeder;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\ServerSeeder;
use Database\Seeders\StandaloneDockerSeeder;
use Database\Seeders\TeamSeeder;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function seedRailpackExamplePrerequisites(): void
{
test()->seed([
UserSeeder::class,
TeamSeeder::class,
PrivateKeySeeder::class,
ServerSeeder::class,
ProjectSeeder::class,
StandaloneDockerSeeder::class,
GithubAppSeeder::class,
]);
}
it('can seed the railpack examples directly on a clean development database', function () {
config()->set('app.env', 'local');
$this->seed(DevelopmentRailpackExamplesSeeder::class);
expect(Team::query()->find(0))->not->toBeNull();
expect(PrivateKey::query()->find(1))->not->toBeNull();
expect(Server::query()->find(0))->not->toBeNull();
expect(StandaloneDocker::query()->find(0))->not->toBeNull();
expect(GithubApp::query()->find(0))->not->toBeNull();
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue();
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
});
it('seeds the railpack examples in development mode', function () {
config()->set('app.env', 'local');
seedRailpackExamplePrerequisites();
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$project = Project::query()
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
->first();
expect($project)
->not->toBeNull()
->and($project->name)->toBe('Railpack Examples')
->and($project->environments)->toHaveCount(1)
->and($project->environments->first()->uuid)->toBe(DevelopmentRailpackExamplesSeeder::ENVIRONMENT_UUID);
$applications = $project->applications()->with('settings')->orderBy('uuid')->get();
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue();
$examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid');
expect($applications->every(
fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
))->toBeTrue();
$nestjs = $applications->firstWhere('uuid', 'railpack-nestjs');
$angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static');
$eleventyStatic = $applications->firstWhere('uuid', 'railpack-eleventy-static');
$pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask');
$goGin = $applications->firstWhere('uuid', 'railpack-go-gin');
$rust = $applications->firstWhere('uuid', 'railpack-rust');
expect($nestjs)
->not->toBeNull()
->and($nestjs->base_directory)->toBe('/node/nestjs')
->and($nestjs->ports_exposes)->toBe('3000')
->and($nestjs->build_command)->toBe('npm run build')
->and($nestjs->start_command)->toBe('npm run start:prod')
->and($nestjs->settings->is_static)->toBeFalse();
expect($angularStatic)
->not->toBeNull()
->and($angularStatic->publish_directory)->toBe('/dist/static/browser')
->and($angularStatic->ports_exposes)->toBe('80')
->and($angularStatic->settings->is_static)->toBeTrue()
->and($angularStatic->settings->is_spa)->toBeTrue();
expect($eleventyStatic)
->not->toBeNull()
->and($eleventyStatic->publish_directory)->toBe('/_site')
->and($eleventyStatic->settings->is_static)->toBeTrue()
->and($eleventyStatic->settings->is_spa)->toBeFalse();
expect($pythonFlask)
->not->toBeNull()
->and($pythonFlask->ports_exposes)->toBe('5000')
->and($pythonFlask->start_command)->toBe('flask run --host=0.0.0.0 --port=5000');
expect($goGin)
->not->toBeNull()
->and($goGin->ports_exposes)->toBe('3000');
expect($rust)
->not->toBeNull()
->and($rust->ports_exposes)->toBe('8000');
});
it('skips the railpack examples outside development mode', function () {
config()->set('app.env', 'testing');
seedRailpackExamplePrerequisites();
$this->seed(DevelopmentRailpackExamplesSeeder::class);
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse();
expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse();
});
it('is idempotent when run multiple times', function () {
config()->set('app.env', 'local');
seedRailpackExamplePrerequisites();
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$project = Project::query()
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
->first();
expect($project)->not->toBeNull();
expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
});

View file

@ -0,0 +1,39 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\Add;
use Livewire\Livewire;
it('rejects environment variable keys Docker cannot represent in the add form', function () {
Livewire::test(Add::class)
->set('key', 'BAD=KEY')
->set('value', 'value')
->call('submit')
->assertHasErrors(['key' => 'regex']);
});
it('allows Docker-compatible environment variable keys in the add form', function (string $key) {
Livewire::test(Add::class)
->set('key', $key)
->set('value', 'value')
->call('submit')
->assertHasNoErrors()
->assertDispatched('saveKey', function ($event, array $data) use ($key) {
return data_get($data, 'key') === $key || data_get($data, '0.key') === $key;
});
})->with([
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
]);
it('trims surrounding whitespace in environment variable keys in the add form', function () {
Livewire::test(Add::class)
->set('key', ' node.name ')
->set('value', 'value')
->call('submit')
->assertHasNoErrors()
->assertDispatched('saveKey', function ($event, array $data) {
return data_get($data, 'key') === 'node.name' || data_get($data, '0.key') === 'node.name';
});
});

View file

@ -0,0 +1,121 @@
<?php
use App\Livewire\Project\Application\General;
use App\Livewire\Project\New\PublicGitRepository;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::unguarded(function () {
InstanceSettings::updateOrCreate(['id' => 0], []);
});
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
describe('PublicGitRepository port handling for railpack', function () {
test('switching to railpack resets port to 3000 when not static', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('build_pack', 'dockerfile')
->assertSet('port', 3000)
->set('build_pack', 'railpack')
->assertSet('port', 3000);
});
test('switching to railpack preserves port when isStatic is true', function () {
$component = Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('isStatic', true)
->call('instantSave');
// After instantSave with isStatic=true, port becomes 80
$component->assertSet('port', 80);
// Switching from nixpacks to railpack should NOT clobber port back to 3000
$component->set('build_pack', 'railpack')
->assertSet('port', 80);
});
test('switching to static sets port to 80 and disables show_is_static', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('build_pack', 'static')
->assertSet('port', 80)
->assertSet('isStatic', false)
->assertSet('show_is_static', false);
});
});
describe('General view railpack helper text', function () {
beforeEach(function () {
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
'team_id' => $this->team->id,
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
});
test('railpack app shows railpack.json helper text and not nixpacks.toml', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'railpack',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('railpack.json')
->assertDontSee('nixpacks.toml');
});
test('nixpacks app shows nixpacks.toml helper text and not railpack.json', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->assertSuccessful()
->assertSee('nixpacks.toml')
->assertDontSee('railpack.json');
});
});

View file

@ -0,0 +1,51 @@
<?php
use App\Livewire\Project\New\GithubPrivateRepository;
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
use App\Livewire\Project\New\PublicGitRepository;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('new application buildpack defaults', function () {
test('github app repository flow defaults to nixpacks', function () {
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
->assertSet('build_pack', 'nixpacks');
});
test('deploy key repository flow defaults to nixpacks', function () {
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
->assertSet('build_pack', 'nixpacks');
});
test('public repository flow defaults to nixpacks and lists railpack second', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->assertSet('build_pack', 'nixpacks');
});
test('public repository flow keeps railpack available after branch lookup', function () {
Livewire::test(PublicGitRepository::class, ['type' => 'public'])
->set('branchFound', true)
->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)'])
->assertSee('Beta');
});
test('deploy key repository flow shows railpack beta label in build pack selector', function () {
Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
->set('current_step', 'repository')
->assertSee('Railpack (Beta)')
->assertSee('Beta');
});
});

View file

@ -0,0 +1,249 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use Illuminate\Support\Collection;
use Tests\TestCase;
uses(TestCase::class);
class TestableRailpackDeploymentJob extends ApplicationDeploymentJob
{
public array $recordedCommands = [];
public function __construct() {}
public function execute_remote_command(...$commands)
{
$this->recordedCommands[] = $commands;
}
}
function makeRailpackDeploymentJob(array $applicationAttributes = [], array $savedOutputs = []): array
{
$job = new TestableRailpackDeploymentJob;
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$application = new Application($applicationAttributes);
foreach ([
'application' => $application,
'workdir' => '/artifacts/test-app',
'deployment_uuid' => 'deployment-uuid',
'saved_outputs' => new Collection($savedOutputs),
'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'",
'force_rebuild' => false,
'addHosts' => '',
'secrets_hash_key' => 'testing-app-key',
] as $property => $value) {
$reflectionProperty = $reflection->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($job, $value);
}
return [$job, $reflection];
}
function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $method, array $arguments = []): mixed
{
$reflectionMethod = $reflection->getMethod($method);
$reflectionMethod->setAccessible(true);
return $reflectionMethod->invokeArgs($job, $arguments);
}
it('deep merges repository railpack config with coolify overrides', function () {
$repositoryConfigJson = json_encode([
'$schema' => 'https://schema.railpack.com',
'packages' => [
'node' => '20',
],
'steps' => [
'build' => [
'inputs' => [['step' => 'install']],
'commands' => ['npm run build'],
],
],
'deploy' => [
'variables' => [
'NODE_ENV' => 'production',
],
'startCommand' => 'node index.js',
],
], JSON_THROW_ON_ERROR);
[$job, $reflection] = makeRailpackDeploymentJob(
[
'install_command' => 'npm ci',
'build_command' => 'npm run build:prod',
'start_command' => 'node server.js',
],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => $repositoryConfigJson,
],
);
$repositoryConfig = invokeRailpackMethod(
$job,
$reflection,
'decode_railpack_config',
[$repositoryConfigJson, 'repository railpack.json'],
);
$overrides = [
'deploy' => [
'variables' => [
'APP_ENV' => 'production',
],
],
'packages' => [
'python' => '3.13',
],
];
$generatedConfig = invokeRailpackMethod($job, $reflection, 'merge_railpack_config', [$repositoryConfig, $overrides]);
expect($generatedConfig)->toMatchArray([
'$schema' => 'https://schema.railpack.com',
'packages' => [
'node' => '20',
'python' => '3.13',
],
'steps' => [
'build' => [
'inputs' => [['step' => 'install']],
'commands' => ['npm run build'],
],
],
'deploy' => [
'variables' => [
'NODE_ENV' => 'production',
'APP_ENV' => 'production',
],
'startCommand' => 'node index.js',
],
]);
});
it('writes a generated railpack config file when repository config exists', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
['build_command' => 'npm run build'],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => json_encode([
'$schema' => 'https://schema.railpack.com',
'steps' => [
'build' => [
'commands' => ['npm run build'],
],
],
], JSON_THROW_ON_ERROR),
],
);
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
expect($configPath)->toBe('.coolify/railpack.generated.json');
expect($job->recordedCommands)->toHaveCount(3);
});
it('does not generate a railpack config file for command overrides alone', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'start_command' => 'node server.js',
]);
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
expect($configPath)->toBeNull();
expect($job->recordedCommands)->toHaveCount(1);
});
it('fails fast when repository railpack config is invalid json', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
['build_command' => 'npm run build'],
[
'railpack_config_exists' => 'exists',
'railpack_repository_config' => '{"steps":{"build":',
],
);
expect(fn () => invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'))
->toThrow(DeploymentException::class, 'Invalid repository railpack.json');
});
it('builds railpack prepare command using railpack env for install and cli flags for build/start overrides', function () {
[$job, $reflection] = makeRailpackDeploymentJob(
[
'install_command' => 'npm ci',
'build_command' => 'npm run build',
'start_command' => 'node server.js',
],
);
$envRailpackArgsProperty = $reflection->getProperty('env_railpack_args');
$envRailpackArgsProperty->setAccessible(true);
$envRailpackArgsProperty->setValue($job, "--env 'RAILPACK_NODE_VERSION=22' --env 'RAILPACK_INSTALL_CMD=npm ci'");
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_prepare_command',
['.coolify/railpack.generated.json'],
);
expect($command)->toContain('railpack prepare');
expect($command)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
expect($command)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci'");
expect($command)->toContain('--build-cmd '.escapeshellarg('npm run build'));
expect($command)->toContain('--start-cmd '.escapeshellarg('node server.js'));
expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json'));
expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app');
expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD=");
expect($command)->not->toContain("--env 'RAILPACK_START_CMD=");
expect($command)->not->toContain('RAILPACK_BUILD_CMD=');
expect($command)->not->toContain('RAILPACK_START_CMD=');
});
it('fails fast when docker buildx is unavailable for railpack builds', function () {
[$job, $reflection] = makeRailpackDeploymentJob();
$dockerBuildxAvailableProperty = $reflection->getProperty('dockerBuildxAvailable');
$dockerBuildxAvailableProperty->setAccessible(true);
$dockerBuildxAvailableProperty->setValue($job, false);
expect(fn () => invokeRailpackMethod($job, $reflection, 'ensure_docker_buildx_available_for_railpack'))
->toThrow(DeploymentException::class, 'Railpack deployments require the Docker buildx CLI plugin');
});
it('builds railpack docker command with matching env and secret flags for all railpack variables', function () {
[$job, $reflection] = makeRailpackDeploymentJob([
'uuid' => 'application-uuid',
]);
$command = invokeRailpackMethod(
$job,
$reflection,
'railpack_build_command',
[
'coollabsio/coolify:test',
collect([
'RAILPACK_NODE_VERSION' => '22',
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'SECRET_JSON' => '{"token":"abc"}',
]),
],
);
expect($command)->toContain("env 'RAILPACK_NODE_VERSION=22'");
expect($command)->toContain("'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
expect($command)->toContain("'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($command)->toContain("'SECRET_JSON={\"token\":\"abc\"}'");
expect($command)->toContain("--secret 'id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'");
expect($command)->toContain("--secret 'id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'");
expect($command)->toContain("--secret 'id=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES'");
expect($command)->toContain("--secret 'id=SECRET_JSON,env=SECRET_JSON'");
expect($command)->toContain(' --build-arg secrets-hash=');
expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version').'"');
});

View file

@ -0,0 +1,267 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\Server;
it('generates escaped railpack env args from resolved values and includes install command', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn('npm ci && npm run postinstall');
$nodeVersion = Mockery::mock(EnvironmentVariable::class)->makePartial();
$nodeVersion->forceFill([
'key' => 'RAILPACK_NODE_VERSION',
'is_literal' => false,
'is_multiline' => false,
]);
$nodeVersion->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('22');
$literalValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$literalValue->forceFill([
'key' => 'RAILPACK_CUSTOM_FLAG',
'is_literal' => true,
'is_multiline' => false,
]);
$literalValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn("'hello world'");
$jsonValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$jsonValue->forceFill([
'key' => 'RAILPACK_JSON',
'is_literal' => false,
'is_multiline' => false,
]);
$jsonValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('{"token":"abc"}');
$nullValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$nullValue->forceFill([
'key' => 'RAILPACK_NULL',
'is_literal' => false,
'is_multiline' => false,
]);
$nullValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn(null);
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($variables->all())->toBe([
'RAILPACK_NODE_VERSION' => '22',
'RAILPACK_CUSTOM_FLAG' => 'hello world',
'RAILPACK_JSON' => '{"token":"abc"}',
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
]);
expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'");
expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'");
expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->not->toContain('RAILPACK_NULL');
});
it('uses preview railpack environment variables for preview deployments', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$previewValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
$previewValue->forceFill([
'key' => 'RAILPACK_PREVIEW_ONLY',
'is_literal' => false,
'is_multiline' => false,
]);
$previewValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('preview-value');
$previewQuery = Mockery::mock();
$previewQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$previewQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$previewQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables_preview')->once()->andReturn($previewQuery);
$railpackPreviewQuery = Mockery::mock();
$railpackPreviewQuery->shouldReceive('get')->once()->andReturn(collect([$previewValue]));
$application->shouldReceive('railpack_environment_variables_preview')->once()->andReturn($railpackPreviewQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 42);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->all())->toBe([
'RAILPACK_PREVIEW_ONLY' => 'preview-value',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
]);
});
it('merges coolify env variables into railpack build variables', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$userVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
$userVar->forceFill([
'key' => 'MY_BUILD_VAR',
'is_literal' => false,
'is_multiline' => false,
]);
$userVar->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('hello');
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([$userVar]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')
->with(true)
->andReturn(collect([
'COOLIFY_URL' => 'https://app.example.com',
'COOLIFY_FQDN' => 'app.example.com',
'COOLIFY_BRANCH' => 'main',
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
'SOURCE_COMMIT' => 'abc123',
'EMPTY_VAR' => '',
'NULL_VAR' => null,
]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->all())->toBe([
'MY_BUILD_VAR' => 'hello',
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
'COOLIFY_URL' => 'https://app.example.com',
'COOLIFY_FQDN' => 'app.example.com',
'COOLIFY_BRANCH' => 'main',
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
'SOURCE_COMMIT' => 'abc123',
]);
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'");
expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
expect($envArgs)->not->toContain('EMPTY_VAR');
expect($envArgs)->not->toContain('NULL_VAR');
});
it('preserves user railpack deploy apt packages while adding healthcheck tools once', function () {
$application = Mockery::mock(Application::class);
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
$deployPackages = Mockery::mock(EnvironmentVariable::class)->makePartial();
$deployPackages->forceFill([
'key' => 'RAILPACK_DEPLOY_APT_PACKAGES',
'is_literal' => false,
'is_multiline' => false,
]);
$deployPackages->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('ffmpeg curl');
$envQuery = Mockery::mock();
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
$railpackQuery = Mockery::mock();
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$deployPackages]));
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$job->shouldAllowMockingProtectedMethods();
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, $application);
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$mainServerProperty = $reflection->getProperty('mainServer');
$mainServerProperty->setAccessible(true);
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
$method = $reflection->getMethod('generate_railpack_env_variables');
$method->setAccessible(true);
$variables = $method->invoke($job);
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget');
$envArgsProperty = $reflection->getProperty('env_railpack_args');
$envArgsProperty->setAccessible(true);
$envArgs = $envArgsProperty->getValue($job);
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'");
});

View file

@ -0,0 +1,74 @@
<?php
use App\Models\EnvironmentVariable;
use App\Models\SharedEnvironmentVariable;
it('flags NIXPACKS_ keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'NIXPACKS_NODE_VERSION';
expect($env->is_buildpack_control)->toBeTrue();
});
it('flags RAILPACK_ keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'RAILPACK_NODE_VERSION';
expect($env->is_buildpack_control)->toBeTrue();
});
it('does not flag user-defined keys as buildpack control variables', function () {
$env = new EnvironmentVariable;
$env->key = 'MY_BUILD_VAR';
expect($env->is_buildpack_control)->toBeFalse();
});
it('does not flag empty key as buildpack control variable', function () {
$env = new EnvironmentVariable;
expect($env->is_buildpack_control)->toBeFalse();
});
it('lists is_buildpack_control in appends and drops legacy is_nixpacks', function () {
$env = new EnvironmentVariable;
expect($env->getAppends())->toContain('is_buildpack_control');
expect($env->getAppends())->not->toContain('is_nixpacks');
});
it('normalizes environment variable keys before storing them on the model', function () {
$env = new EnvironmentVariable;
$env->key = ' node.name ';
expect($env->key)->toBe('node.name');
});
it('allows Docker-compatible environment variable keys on the model', function (string $key) {
$env = new EnvironmentVariable;
$env->key = $key;
expect($env->key)->toBe($key);
})->with([
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
'semicolon' => 'BAD;KEY',
]);
it('rejects environment variable keys Docker cannot represent on the model', function () {
$env = new EnvironmentVariable;
expect(function () use ($env) {
$env->key = 'BAD=KEY';
})->toThrow(InvalidArgumentException::class, 'Docker-compatible');
});
it('rejects shared environment variable keys Docker cannot represent on the model', function () {
$env = new SharedEnvironmentVariable;
expect(function () use ($env) {
$env->key = 'BAD=KEY';
})->toThrow(InvalidArgumentException::class, 'Docker-compatible');
});

View file

@ -1,5 +1,6 @@
<?php
use App\Models\EnvironmentVariable;
use App\Support\ValidationPatterns;
it('accepts valid names with common characters', function (string $name) {
@ -130,3 +131,45 @@
expect($rules)->toContain('nullable')
->not->toContain('required');
});
it('accepts Docker-compatible environment variable keys', function (string $key) {
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeTrue();
})->with([
'letters' => 'APP_ENV',
'leading underscore' => '_TOKEN',
'railpack control variable' => 'RAILPACK_NODE_VERSION',
'digits after first character' => 'NODE_VERSION_20',
'starts with digit' => '1BAD',
'hyphen' => 'BAD-KEY',
'dot' => 'node.name',
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
'semicolon' => 'BAD;KEY',
'space' => 'BAD KEY',
]);
it('rejects environment variable keys Docker cannot represent', function (string $key) {
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeFalse();
})->with([
'equals' => 'BAD=KEY',
'empty' => '',
]);
it('generates environment variable key rules with correct defaults', function () {
$rules = ValidationPatterns::environmentVariableKeyRules();
expect($rules)->toContain('required')
->toContain('string')
->toContain('max:255')
->toContain('regex:'.ValidationPatterns::ENVIRONMENT_VARIABLE_KEY_PATTERN);
});
it('normalizes environment variable keys by trimming surrounding whitespace', function () {
expect(ValidationPatterns::normalizeEnvironmentVariableKey(' node.name '))->toBe('node.name');
});
it('normalizes environment variable keys before model validation', function () {
$environmentVariable = new EnvironmentVariable;
$environmentVariable->key = ' APP_ENV ';
expect($environmentVariable->key)->toBe('APP_ENV');
});