diff --git a/.ai/lessons.md b/.ai/lessons.md
index 03429efdad..01d0d48671 100644
--- a/.ai/lessons.md
+++ b/.ai/lessons.md
@@ -90,4 +90,6 @@
## Non-root SSH users: keep file access behind sudo
- `parseCommandsByLineForSudo()` does not prefix `cd` or `echo`, and the SSH user's shell opens redirects (`>`, `<`) and expands globs. On the Coolify host, `/data/coolify` is `9999:root 0700`, so these fail for a non-root user.
-- In remote commands, use absolute paths (`docker compose -f
/docker-compose.yml`), `echo ... | tee > /dev/null`, and `find` instead of globs.
+- In remote commands, use absolute paths (`docker compose -f /docker-compose.yml`, `--project-directory `), `echo ... | tee > /dev/null`, and `find` instead of globs. scp also runs as the SSH user; stage files outside `/data/coolify`.
+- The parsers add sudo only to line starts (and after `&&`, `||`, `|`, `$(`). Put `if`/`else` branches on their own lines. When a redirect or `cd` must stay, make the whole line one `sh -c '...'` script; it runs as `sudo sh -c` without inner sudo or bash.
+- Tests that replace `DatabaseStartCommandExecutor` or use root servers miss non-root bugs. Test the parsed commands of a non-root server.
diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php
index 4332050317..912612ae00 100644
--- a/app/Actions/Database/StartClickhouse.php
+++ b/app/Actions/Database/StartClickhouse.php
@@ -107,7 +107,7 @@ class StartClickhouse
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
- $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
+ $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php
index 1c4f0ca486..2667b944a6 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -179,7 +179,7 @@ class StartDragonfly
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
- $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
+ $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index 1b434345aa..38fc9d0f2d 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -194,7 +194,7 @@ class StartKeydb
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
- $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
+ $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php
index 0479703dae..7e88b10cf5 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -197,7 +197,7 @@ class StartMariadb
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
- $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
+ $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php
index 388bda0b30..df9bbd7db0 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -249,7 +249,7 @@ class StartMongodb
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
- $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
+ $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php
index ff06e0f464..dc6b8311ce 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -200,7 +200,7 @@ class StartMysql
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
- $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
+ $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index 940e1abdde..5db827be07 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -209,7 +209,7 @@ class StartPostgresql
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
- $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
+ $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
@@ -293,7 +293,8 @@ class StartPostgresql
private function generate_init_scripts()
{
- $this->commands[] = "rm -rf $this->configuration_dir/docker-entrypoint-initdb.d/*";
+ // find instead of a shell glob: a non-root SSH user cannot read the directory to expand it.
+ $this->commands[] = "find $this->configuration_dir/docker-entrypoint-initdb.d -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true";
if (blank($this->database->init_scripts) || count($this->database->init_scripts) === 0) {
return;
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index 2fb74e3e45..c17936a128 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -191,7 +191,7 @@ class StartRedis
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
- $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
+ $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
diff --git a/app/Actions/Database/StartSqlite.php b/app/Actions/Database/StartSqlite.php
index 1e602c1d1a..377a680594 100644
--- a/app/Actions/Database/StartSqlite.php
+++ b/app/Actions/Database/StartSqlite.php
@@ -95,7 +95,7 @@ class StartSqlite
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
- $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
+ $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null";
$this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image.");
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php
index c1249ba496..32f5ed5f1f 100644
--- a/app/Actions/Proxy/GetProxyConfiguration.php
+++ b/app/Actions/Proxy/GetProxyConfiguration.php
@@ -113,7 +113,14 @@ class GetProxyConfiguration
$readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1;
$result = instant_remote_process([
"mkdir -p $proxy_path",
- "if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(stat -c %s {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi",
+ // One command per line: the non-root sudo parser adds sudo to line starts, not to `then`/`else` branches.
+ "if [ -f {$configurationPath} ]; then",
+ " if [ \"$(stat -c %s {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES.' ]; then',
+ " echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'",
+ ' else',
+ " head -c {$readLimit} {$configurationPath}",
+ ' fi',
+ 'fi',
], $server, false);
if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) {
diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php
index eb419992d5..8c0922642f 100644
--- a/app/Actions/Server/StartLogDrain.php
+++ b/app/Actions/Server/StartLogDrain.php
@@ -200,7 +200,7 @@ Files:
"echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
"echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null",
"echo 'Starting Fluent Bit'",
- "cd $config_path && docker compose up -d",
+ "docker compose -f $compose_path up -d",
];
$command = array_merge($command, $this->logDrainNetworkConnectCommands($server));
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 5b405036ba..f21e2bdb39 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -997,8 +997,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->write_deployment_configurations();
if ($this->preserveRepository) {
+ $start_in_workdir = "cd {$server_workdir} && {$start_command}";
+ if ($this->server->isNonRoot()) {
+ // A non-root SSH user cannot cd into the resource directory on the Coolify host. As one
+ // `sh -c` script, the sudo parser runs the cd and the start command as root.
+ $start_in_workdir = 'sh -c '.escapeshellarg($start_in_workdir);
+ }
$this->execute_remote_command(
- ['command' => "cd {$server_workdir} && {$start_command}", 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
+ ['command' => $start_in_workdir, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
);
} else {
$this->execute_remote_command(
@@ -1233,7 +1239,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
'skip_command_log' => true,
],
[
- "echo '{$readme}' > $mainDir/README.md",
+ "echo '{$readme}' | tee $mainDir/README.md > /dev/null",
]
);
if ($this->use_build_server) {
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 31fa878f85..bc28f4a968 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -610,7 +610,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
}
- $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
+ $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
@@ -667,7 +667,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
$commands[] = $backupCommand;
- $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
+ $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
@@ -694,7 +694,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec {$escapedContainerName} mysqldump -u root -p$escapedPassword $escapedDatabase > {$escapedBackupLocation}";
}
- $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
+ $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
@@ -721,7 +721,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec {$escapedContainerName} mariadb-dump -u root -p$escapedPassword $escapedDatabase > {$escapedBackupLocation}";
}
- $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
+ $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
@@ -744,7 +744,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
backupDirectory: $this->backup_dir,
);
- $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
+ $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
@@ -768,7 +768,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$script = 'f=$(mktemp) && sqlite3 -readonly '.escapeshellarg(StandaloneSqlite::DATA_DIRECTORY.'/'.$database).' "VACUUM INTO \'$f\'" && cat "$f"; s=$?; rm -f "$f"; exit $s';
$dumpCommand = 'docker exec '.escapeshellarg($this->container_name).' sh -c '.escapeshellarg($script);
$commands[] = $this->buildCompressedDumpCommand($dumpCommand).' > '.escapeshellarg($this->backup_location);
- $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
+ $this->backup_output = instant_remote_process($this->writeBackupFileAsRoot($commands), $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
@@ -882,6 +882,24 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
return "{$helperImage}:{$latestVersion}";
}
+ /**
+ * The SSH user's shell opens a `>` redirect. For a non-root SSH user, the dump and the redirect into
+ * the backup directory must run in one root shell, so each line that writes the backup file becomes
+ * one `sh -c` script. The exit status of the dump stays the exit status of the line.
+ *
+ * @param list $commands
+ * @return list
+ */
+ private function writeBackupFileAsRoot(array $commands): array
+ {
+ $redirect = '> '.escapeshellarg($this->backup_location);
+
+ return array_map(
+ fn (string $command): string => str_contains($command, $redirect) ? 'sh -c '.escapeshellarg($command) : $command,
+ $commands,
+ );
+ }
+
private function buildCompressedDumpCommand(string $dumpCommand): string
{
$cpuPercentage = BackupCompression::cpuPercentage($this->server->settings->backup_compression_cpu_percentage);
diff --git a/app/Jobs/HostPathCloneJob.php b/app/Jobs/HostPathCloneJob.php
index 2b26fc5516..1a7300b880 100644
--- a/app/Jobs/HostPathCloneJob.php
+++ b/app/Jobs/HostPathCloneJob.php
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Models\Server;
+use App\Traits\StagesCloneArchives;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -17,9 +18,7 @@ use Illuminate\Support\Str;
*/
class HostPathCloneJob implements ShouldBeEncrypted, ShouldQueue
{
- use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
-
- protected string $cloneDir = '/data/coolify/clone';
+ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, StagesCloneArchives;
public int $timeout = 3600;
@@ -64,8 +63,8 @@ class HostPathCloneJob implements ShouldBeEncrypted, ShouldQueue
{
$archiveName = 'hostpath-data.tar.gz';
$token = Str::uuid()->toString();
- $sourceCloneDir = "{$this->cloneDir}/hostpath-{$token}";
- $targetCloneDir = "{$this->cloneDir}/hostpath-{$token}";
+ $sourceCloneDir = $this->cloneArchiveDirectory($this->sourceServer, "hostpath-{$token}");
+ $targetCloneDir = $this->cloneArchiveDirectory($this->targetServer, "hostpath-{$token}");
$srcDir = escapeshellarg($sourceCloneDir);
$tgtDir = escapeshellarg($targetCloneDir);
$srcPath = escapeshellarg($this->sourcePath);
@@ -78,16 +77,12 @@ class HostPathCloneJob implements ShouldBeEncrypted, ShouldQueue
File::ensureDirectoryExists($localTempDir, 0755);
instant_remote_process([
- "mkdir -p {$srcDir}",
- "chmod 777 {$srcDir}",
+ ...$this->prepareCloneArchiveDirectory($this->sourceServer, $sourceCloneDir),
"test -e {$srcPath}",
"docker run --rm -v {$srcPath}:/source:ro -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/{$archiveName} .'",
], $this->sourceServer);
- instant_remote_process([
- "mkdir -p {$tgtDir}",
- "chmod 777 {$tgtDir}",
- ], $this->targetServer);
+ instant_remote_process($this->prepareCloneArchiveDirectory($this->targetServer, $targetCloneDir), $this->targetServer);
instant_scp_from_server(
"{$sourceCloneDir}/{$archiveName}",
diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php
index f511d2546b..d43705bc5a 100644
--- a/app/Jobs/VolumeCloneJob.php
+++ b/app/Jobs/VolumeCloneJob.php
@@ -4,6 +4,7 @@ namespace App\Jobs;
use App\Models\LocalPersistentVolume;
use App\Models\Server;
+use App\Traits\StagesCloneArchives;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -15,9 +16,7 @@ use Illuminate\Support\Str;
class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
{
- use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
-
- protected string $cloneDir = '/data/coolify/clone';
+ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, StagesCloneArchives;
public int $timeout = 3600;
@@ -65,8 +64,8 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
{
$srcVol = escapeshellarg($this->sourceVolume);
$tgtVol = escapeshellarg($this->targetVolume);
- $sourceCloneDir = "{$this->cloneDir}/{$this->sourceVolume}";
- $targetCloneDir = "{$this->cloneDir}/{$this->targetVolume}";
+ $sourceCloneDir = $this->cloneArchiveDirectory($this->sourceServer, $this->sourceVolume);
+ $targetCloneDir = $this->cloneArchiveDirectory($this->targetServer, $this->targetVolume);
$srcDir = escapeshellarg($sourceCloneDir);
$tgtDir = escapeshellarg($targetCloneDir);
$localTempDir = storage_path('app/tmp/volume-clones/'.Str::uuid()->toString());
@@ -76,15 +75,11 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
File::ensureDirectoryExists($localTempDir, 0755);
instant_remote_process([
- "mkdir -p {$srcDir}",
- "chmod 777 {$srcDir}",
+ ...$this->prepareCloneArchiveDirectory($this->sourceServer, $sourceCloneDir),
"docker run --rm -v {$srcVol}:/source -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'",
], $this->sourceServer);
- instant_remote_process([
- "mkdir -p {$tgtDir}",
- "chmod 777 {$tgtDir}",
- ], $this->targetServer);
+ instant_remote_process($this->prepareCloneArchiveDirectory($this->targetServer, $targetCloneDir), $this->targetServer);
// Coolify host is the intermediary: download from source, upload to target.
instant_scp_from_server(
diff --git a/app/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php
index 856b7e2a45..615cc99246 100644
--- a/app/Livewire/Settings/Updates.php
+++ b/app/Livewire/Settings/Updates.php
@@ -80,9 +80,7 @@ class Updates extends Component
}
try {
- instant_remote_process([
- $this->registryEnvSyncCommand($registryUrl),
- ], $this->server);
+ instant_remote_process($this->registryEnvSyncCommand($registryUrl), $this->server);
} catch (\Exception $e) {
Log::warning('Failed to sync REGISTRY_URL to .env', [
'error' => $e->getMessage(),
@@ -92,13 +90,25 @@ class Updates extends Component
}
}
- private function registryEnvSyncCommand(string $registryUrl): string
+ /**
+ * One command per line: the non-root sudo parser adds sudo to line starts, not to `then`/`else` branches,
+ * and a `>>` redirect would be opened by the SSH user.
+ *
+ * @return list
+ */
+ private function registryEnvSyncCommand(string $registryUrl): array
{
$envFile = '/data/coolify/source/.env';
$sedExpression = escapeshellarg("s|^REGISTRY_URL=.*|REGISTRY_URL={$registryUrl}|");
$registryLine = escapeshellarg("REGISTRY_URL={$registryUrl}");
- return "if grep -q '^REGISTRY_URL=' {$envFile}; then sed -i {$sedExpression} {$envFile}; else printf '%s\\n' {$registryLine} >> {$envFile}; fi";
+ return [
+ "if grep -q '^REGISTRY_URL=' {$envFile}; then",
+ " sed -i {$sedExpression} {$envFile}",
+ 'else',
+ " printf '%s\\n' {$registryLine} | tee -a {$envFile} > /dev/null",
+ 'fi',
+ ];
}
public function submit()
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 76a11f3b3a..e2694d01c1 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -551,7 +551,8 @@ class Application extends BaseModel
$persistentStorages = $this->persistentStorages()->get() ?? collect();
if ($this->build_pack === 'dockercompose') {
$server = data_get($this, 'destination.server');
- instant_remote_process(["cd {$this->dirOnServer()} && docker compose down -v"], $server, false);
+ // --project-directory instead of cd: a non-root SSH user cannot enter the directory on the Coolify host.
+ instant_remote_process(["docker compose --project-directory {$this->dirOnServer()} down -v"], $server, false);
} else {
if ($persistentStorages->count() === 0) {
return;
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index 1e01d4f43f..072f51cd9e 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -208,7 +208,7 @@ class LocalFileVolume extends BaseModel
protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
{
$sizeOutput = instant_remote_process(
- ["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"],
+ ["stat -c%s {$escapedPath} 2>/dev/null"],
$server,
false,
);
@@ -308,7 +308,6 @@ class LocalFileVolume extends BaseModel
$escapedFsPath = escapeshellarg($this->fs_path);
$commands->push("mkdir -p {$escapedFsPath} > /dev/null 2>&1 || true");
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
- $commands->push("cd {$escapedWorkdir}");
}
$path = data_get_str($this, 'fs_path');
$content = data_get($this, 'content');
diff --git a/app/Models/Service.php b/app/Models/Service.php
index 2ced7ad2b9..545250013b 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -15,7 +15,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
-use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
use Symfony\Component\Yaml\Yaml;
@@ -1573,20 +1572,12 @@ class Service extends BaseModel
}
$workdir = $this->workdir();
-
- instant_remote_process([
+ // Absolute paths and tee, no cd or scp: a non-root SSH user cannot enter /data/coolify on the Coolify host.
+ $commands = [
"mkdir -p $workdir",
- "cd $workdir",
- ], $this->server);
-
- $filename = new_public_id().'-docker-compose.yml';
- Storage::disk('local')->put("tmp/{$filename}", $this->docker_compose);
- $path = Storage::path("tmp/{$filename}");
- instant_scp($path, "{$workdir}/docker-compose.yml", $this->server);
- Storage::disk('local')->delete("tmp/{$filename}");
-
- $commands[] = "cd $workdir";
- $environmentFilename = new_public_id().'.env.tmp';
+ "echo '".base64_encode($this->docker_compose)."' | base64 -d | tee $workdir/docker-compose.yml > /dev/null",
+ ];
+ $environmentFile = "$workdir/".new_public_id().'.env.tmp';
$envs = collect([]);
@@ -1617,11 +1608,12 @@ class Service extends BaseModel
$envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}");
}
if ($envs->count() === 0) {
- $commands[] = "touch {$environmentFilename} && mv {$environmentFilename} .env";
+ $commands[] = "touch {$environmentFile}";
} else {
$envs_base64 = base64_encode($envs->implode("\n"));
- $commands[] = "echo '$envs_base64' | base64 -d | tee {$environmentFilename} > /dev/null && mv {$environmentFilename} .env";
+ $commands[] = "echo '$envs_base64' | base64 -d | tee {$environmentFile} > /dev/null";
}
+ $commands[] = "mv {$environmentFile} $workdir/.env";
instant_remote_process($commands, $this->server);
}
diff --git a/app/Services/DatabaseStartCommandExecutor.php b/app/Services/DatabaseStartCommandExecutor.php
index dab3599101..96e2f500bd 100644
--- a/app/Services/DatabaseStartCommandExecutor.php
+++ b/app/Services/DatabaseStartCommandExecutor.php
@@ -14,7 +14,7 @@ class DatabaseStartCommandExecutor
{
$server = $database->destination->server;
if ($server->isNonRoot()) {
- $commands = parseCommandsByLineForSudo(collect($commands), $server)->all();
+ $commands = parseCommandsByLineForSudo(collect($commands), $server);
}
$secrets = method_exists($database, 'resolvedSecretManagerValuesForRedaction')
diff --git a/app/Services/ServerTransfer/ServerTransferClaimer.php b/app/Services/ServerTransfer/ServerTransferClaimer.php
index dcdfb01bb4..593bdd91ed 100644
--- a/app/Services/ServerTransfer/ServerTransferClaimer.php
+++ b/app/Services/ServerTransfer/ServerTransferClaimer.php
@@ -165,7 +165,8 @@ class ServerTransferClaimer
try {
instant_remote_process([
'mkdir -p '.escapeshellarg(ServerTransferBundle::MAILBOX_DIR),
- 'echo '.escapeshellarg($b64).' | base64 -d > '.escapeshellarg($path),
+ // tee, not a redirect: the SSH user's shell opens a redirect, and a non-root user cannot enter /data/coolify.
+ 'echo '.escapeshellarg($b64).' | base64 -d | tee '.escapeshellarg($path).' > /dev/null',
'chmod 600 '.escapeshellarg($path),
'chown 9999:root '.escapeshellarg($path).' || true',
], $server, true);
diff --git a/app/Traits/StagesCloneArchives.php b/app/Traits/StagesCloneArchives.php
new file mode 100644
index 0000000000..68e08798a6
--- /dev/null
+++ b/app/Traits/StagesCloneArchives.php
@@ -0,0 +1,41 @@
+isNonRoot() ? '/var/tmp/coolify-clone' : $this->cloneDir)."/{$name}";
+ }
+
+ /**
+ * @return list
+ */
+ protected function prepareCloneArchiveDirectory(Server $server, string $directory): array
+ {
+ $escapedDirectory = escapeshellarg($directory);
+
+ if ($server->isNonRoot()) {
+ return [
+ "mkdir -p {$escapedDirectory}",
+ 'chown '.escapeshellarg($server->user)." {$escapedDirectory}",
+ "chmod 700 {$escapedDirectory}",
+ ];
+ }
+
+ return [
+ "mkdir -p {$escapedDirectory}",
+ "chmod 777 {$escapedDirectory}",
+ ];
+ }
+}
diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php
index cce4be5742..0b81342c77 100644
--- a/bootstrap/helpers/services.php
+++ b/bootstrap/helpers/services.php
@@ -201,7 +201,6 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
$escapedWorkdir = escapeshellarg($workdir);
$commands = collect([
"mkdir -p -- {$escapedWorkdir} > /dev/null 2>&1 || true",
- "cd {$escapedWorkdir}",
]);
instant_remote_process($commands, $server);
foreach ($fileVolumes as $fileVolume) {
diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php
index 25ab683b4e..a53889ffdf 100644
--- a/bootstrap/helpers/sudo.php
+++ b/bootstrap/helpers/sudo.php
@@ -30,6 +30,15 @@ function ownershipCommand(string $path, Server $server): string
return "find $path -user root -exec chown $server->user:$server->user {} + && chmod o-rwx $path";
}
+/**
+ * A line that is one `sudo sh -c '...'` call already runs completely as root. Adding sudo inside the script
+ * fails where root is not in sudoers (Alpine), and wrapping it again in `bash -c` needs bash.
+ */
+function isSingleSudoShellScript(string $line): bool
+{
+ return preg_match("/^\\s*sudo (?:ba)?sh -c '(?:[^']|'\\\\'')*'\\s*$/", $line) === 1;
+}
+
function parseCommandsByLineForSudo(Collection $commands, Server $server): array
{
$commands = $commands->map(function ($line) {
@@ -105,6 +114,10 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array
});
$commands = $commands->map(function ($line) {
+ if (isSingleSudoShellScript($line)) {
+ return $line;
+ }
+
$line = str($line);
// Detect complex piped commands that should be wrapped in bash -c
@@ -156,6 +169,9 @@ function parseLineForSudo(string $command, Server $server): string
$command = "$command && ".ownershipCommand($path, $server);
}
}
+ if (isSingleSudoShellScript($command)) {
+ return $command;
+ }
if (str($command)->contains('$(') || str($command)->contains('`')) {
$command = str($command)->replace('$(', '$(sudo ')->replace('`', '`sudo ')->value();
}
@@ -165,6 +181,8 @@ function parseLineForSudo(string $command, Server $server): string
if (str($command)->contains('&&')) {
$command = str($command)->replace('&&', '&& sudo ')->value();
}
+ // Each pipe stage is a separate process; without sudo, `| tee file` writes as the SSH user.
+ $command = preg_replace('/ \| (?!sudo )/', ' | sudo ', $command);
return $command;
}
diff --git a/tests/Feature/ComposeExternalVolumeTest.php b/tests/Feature/ComposeExternalVolumeTest.php
index 9c2597bd19..e66797826d 100644
--- a/tests/Feature/ComposeExternalVolumeTest.php
+++ b/tests/Feature/ComposeExternalVolumeTest.php
@@ -683,7 +683,9 @@ describe('delete', function () {
(new DeleteResourceJob($application))->handle();
$all = implode("\n", $commands);
- expect($all)->toContain('docker compose down -v')
+ // --project-directory, not cd: a non-root SSH user cannot enter the directory on the Coolify host.
+ expect($all)->toContain("docker compose --project-directory {$application->dirOnServer()} down -v")
+ ->not->toContain("cd {$application->dirOnServer()}")
->not->toContain('docker volume rm')
->and($application->docker_compose)->toContain("shared-data:\n external: true");
});
diff --git a/tests/Feature/DeleteResourceJobAtomicityTest.php b/tests/Feature/DeleteResourceJobAtomicityTest.php
index beb96cb5cd..7696a2ffbd 100644
--- a/tests/Feature/DeleteResourceJobAtomicityTest.php
+++ b/tests/Feature/DeleteResourceJobAtomicityTest.php
@@ -304,7 +304,7 @@ it('removes service containers with a command that works for non-root SSH users'
// A leading variable assignment becomes "sudo container_ids=...", which sudo rejects.
expect($commands->implode("\n"))
->not->toContain('sudo container_ids=')
- ->toContain("sudo bash -c 'sh -c")
+ ->toContain("sudo sh -c '")
->toContain("label=coolify.service.subUuid={$application->uuid}")
->toContain('docker rm -f $container_ids');
});
diff --git a/tests/Feature/NonRootDataDirectoryCommandsTest.php b/tests/Feature/NonRootDataDirectoryCommandsTest.php
new file mode 100644
index 0000000000..def281e0b2
--- /dev/null
+++ b/tests/Feature/NonRootDataDirectoryCommandsTest.php
@@ -0,0 +1,139 @@
+ 0]);
+ config(['app.maintenance.store' => 'array', 'constants.ssh.mux_enabled' => false]);
+ Process::fake();
+ Queue::fake();
+
+ $team = Team::factory()->create();
+ $this->server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'user' => 'cooluser',
+ 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
+ ]);
+ $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
+ $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $project->id]);
+});
+
+/**
+ * @return array
+ */
+function nonRootDataDirectoryRemoteLines(): array
+{
+ $lines = [];
+ Process::assertRan(function ($process) use (&$lines) {
+ array_push($lines, ...explode("\n", $process->command));
+
+ return true;
+ });
+
+ return $lines;
+}
+
+function expectNoUnprivilegedDataDirectoryAccess(array $lines): void
+{
+ foreach ($lines as $line) {
+ expect(trim($line))->not->toStartWith('cd ')
+ ->and($line)->not->toMatch('/[^2&]>>?\s*\'?\/data\/coolify/')
+ ->and($line)->not->toStartWith('scp ');
+ }
+}
+
+it('starts a database through the queued executor on a non-root server', function () {
+ $database = create_standalone_postgresql($this->environment->id, $this->destination);
+ $configurationDirectory = database_configuration_dir().'/'.$database->uuid;
+
+ StartPostgresql::run($database->fresh(), activity()->log('Starting database'));
+
+ $lines = nonRootDataDirectoryRemoteLines();
+ expectNoUnprivilegedDataDirectoryAccess($lines);
+ expect(implode("\n", $lines))
+ ->toContain("| sudo tee {$configurationDirectory}/README.md > /dev/null")
+ ->toContain("sudo find {$configurationDirectory}/docker-entrypoint-initdb.d -mindepth 1 -maxdepth 1 -exec rm -rf {} +")
+ ->toContain("sudo docker compose -f {$configurationDirectory}/docker-compose.yml up -d");
+});
+
+it('writes service compose files without cd or scp on a non-root server', function () {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'docker_compose' => "services:\n app:\n image: nginx\n",
+ ]);
+ $workdir = $service->workdir();
+
+ $service->saveComposeConfigs();
+
+ $lines = nonRootDataDirectoryRemoteLines();
+ expectNoUnprivilegedDataDirectoryAccess($lines);
+ expect(implode("\n", $lines))
+ ->toContain("| sudo tee {$workdir}/docker-compose.yml > /dev/null")
+ ->toMatch('/^sudo mv '.preg_quote($workdir, '/').'\/\S+\.env\.tmp '.preg_quote($workdir, '/').'\/\.env$/m');
+});
+
+it('writes a database dump and its redirect in one root shell', function () {
+ $backupLocation = '/data/coolify/backups/databases/team-1/db/pg-dump-db-1.dmp';
+ $job = (new ReflectionClass(DatabaseBackupJob::class))->newInstanceWithoutConstructor();
+ (new ReflectionProperty(DatabaseBackupJob::class, 'backup_location'))->setValue($job, $backupLocation);
+
+ $commands = (new ReflectionMethod(DatabaseBackupJob::class, 'writeBackupFileAsRoot'))->invoke($job, [
+ "mkdir -p '/data/coolify/backups/databases/team-1/db'",
+ "docker exec 'db' pg_dump --format=custom 'app' > '{$backupLocation}'",
+ ]);
+
+ expect(parseCommandsByLineForSudo(collect($commands), $this->server))->toBe([
+ "sudo mkdir -p '/data/coolify/backups/databases/team-1/db'",
+ "sudo sh -c 'docker exec '\\''db'\\'' pg_dump --format=custom '\\''app'\\'' > '\\''{$backupLocation}'\\'''",
+ ]);
+});
+
+it('stages clone archives outside /data/coolify only for a non-root SSH user', function () {
+ $stager = new class
+ {
+ use StagesCloneArchives {
+ cloneArchiveDirectory as public;
+ prepareCloneArchiveDirectory as public;
+ }
+ };
+ $rootServer = Server::factory()->create(['team_id' => $this->server->team_id, 'user' => 'root', 'private_key_id' => $this->server->private_key_id]);
+
+ $nonRootDirectory = $stager->cloneArchiveDirectory($this->server, 'volume-a');
+
+ expect($nonRootDirectory)->toBe('/var/tmp/coolify-clone/volume-a')
+ ->and($stager->prepareCloneArchiveDirectory($this->server, $nonRootDirectory))->toBe([
+ "mkdir -p '/var/tmp/coolify-clone/volume-a'",
+ "chown 'cooluser' '/var/tmp/coolify-clone/volume-a'",
+ "chmod 700 '/var/tmp/coolify-clone/volume-a'",
+ ])
+ ->and($stager->cloneArchiveDirectory($rootServer, 'volume-a'))->toBe('/data/coolify/clone/volume-a')
+ ->and($stager->prepareCloneArchiveDirectory($rootServer, '/data/coolify/clone/volume-a'))->toBe([
+ "mkdir -p '/data/coolify/clone/volume-a'",
+ "chmod 777 '/data/coolify/clone/volume-a'",
+ ]);
+});
diff --git a/tests/Feature/Proxy/NonRootProxyCommandsTest.php b/tests/Feature/Proxy/NonRootProxyCommandsTest.php
index 49cd73584f..f1cda7eb28 100644
--- a/tests/Feature/Proxy/NonRootProxyCommandsTest.php
+++ b/tests/Feature/Proxy/NonRootProxyCommandsTest.php
@@ -80,7 +80,9 @@ it('starts the proxy without entering proxy directories as the SSH user', functi
expectNoUnprivilegedProxyFileAccess($lines);
expect($lines)
->toContain("sudo docker compose -f {$proxyPath}/docker-compose.yml pull")
- ->toContain("sudo docker compose -f {$proxyPath}/docker-compose.yml up -d --wait --remove-orphans");
+ ->toContain("sudo docker compose -f {$proxyPath}/docker-compose.yml up -d --wait --remove-orphans")
+ // The backfill reads a stored configuration in an `else` branch, which must run through sudo too.
+ ->and(implode("\n", $lines))->toMatch("/^sudo\\s+head -c \\d+ '\\S+\\/docker-compose\\.yml'$/m");
})->with([
'traefik' => [ProxyTypes::TRAEFIK->value, '/data/coolify/proxy'],
'caddy' => [ProxyTypes::CADDY->value, '/data/coolify/proxy/caddy'],
diff --git a/tests/Feature/TraefikCertificateRemoteFileTest.php b/tests/Feature/TraefikCertificateRemoteFileTest.php
index 12e4e08939..ae50e45236 100644
--- a/tests/Feature/TraefikCertificateRemoteFileTest.php
+++ b/tests/Feature/TraefikCertificateRemoteFileTest.php
@@ -56,7 +56,7 @@ it('reads the ACME file with sudo on non-root servers', function () {
$certificates = GetTraefikCertificates::run($this->server);
expect($certificates)->toHaveCount(40);
- Process::assertRan(fn ($process) => str_contains($process->command, "\nsudo bash -c 'sh -c '\\''if [ ! -f")
+ Process::assertRan(fn ($process) => str_contains($process->command, "\nsudo sh -c 'if [ ! -f")
&& str_contains($process->command, 'head -c'));
});
@@ -70,7 +70,7 @@ it('uploads a large ACME file instead of passing it as a shell argument', functi
->and($remaining)->toHaveCount(39)
->and(collect($remaining)->pluck('domain.main'))->not->toContain('app1.example.com');
- Process::assertRan(fn ($process) => str_contains($process->command, "\nsudo bash -c 'sh -c '\\''set -e;")
+ Process::assertRan(fn ($process) => str_contains($process->command, "\nsudo sh -c 'set -e;")
&& str_contains($process->command, 'umask 077')
&& str_contains($process->command, 'chmod 600')
&& str_contains($process->command, 'mv --'));
diff --git a/tests/Unit/ParseCommandsByLineForSudoTest.php b/tests/Unit/ParseCommandsByLineForSudoTest.php
index 55deb7b12d..ec13295438 100644
--- a/tests/Unit/ParseCommandsByLineForSudoTest.php
+++ b/tests/Unit/ParseCommandsByLineForSudoTest.php
@@ -777,3 +777,24 @@ test('do keyword with word boundary is not given sudo', function () {
expect($result[0])->toBe('do');
});
+
+test('keeps a single sudo sh -c script unchanged in both parsers', function () {
+ // A redirect inside the script is opened by root. Inner sudo fails where root is not in sudoers (Alpine).
+ $line = 'sh -c '.escapeshellarg("docker exec 'db' pg_dump 'app' > '/data/coolify/backups/app.dmp' && echo it's done | cat");
+
+ expect(parseCommandsByLineForSudo(collect([$line]), $this->server)[0])->toBe("sudo {$line}")
+ ->and(parseLineForSudo($line, $this->server))->toBe("sudo {$line}");
+});
+
+test('still wraps a sh -c call that is not the whole line', function () {
+ $line = "docker exec db pg_dumpall | docker run --rm -i helper sh -c 'gzip' > /data/coolify/backups/all.gz";
+
+ expect(parseCommandsByLineForSudo(collect([$line]), $this->server)[0])->toStartWith("sudo bash -c '");
+});
+
+test('parseLineForSudo adds sudo to each pipe stage without doubling it', function () {
+ expect(parseLineForSudo("echo 'ZW52' | base64 -d | tee /data/coolify/applications/app/.env > /dev/null", $this->server))
+ ->toBe("sudo echo 'ZW52' | sudo base64 -d | sudo tee /data/coolify/applications/app/.env > /dev/null")
+ ->and(parseLineForSudo('docker ps | sudo grep app', $this->server))
+ ->toBe('sudo docker ps | sudo grep app');
+});
diff --git a/tests/Unit/UpdateCoolifyTest.php b/tests/Unit/UpdateCoolifyTest.php
index ffb8766e01..d27a634018 100644
--- a/tests/Unit/UpdateCoolifyTest.php
+++ b/tests/Unit/UpdateCoolifyTest.php
@@ -273,10 +273,18 @@ it('appends registry url to env file when the key is missing', function () {
$component = new Updates;
$method = new ReflectionMethod(Updates::class, 'registryEnvSyncCommand');
- expect($method->invoke($component, 'ghcr.io'))
- ->toContain("grep -q '^REGISTRY_URL=' /data/coolify/source/.env")
- ->toContain("sed -i 's|^REGISTRY_URL=.*|REGISTRY_URL=ghcr.io|' /data/coolify/source/.env")
- ->toContain("printf '%s\\n' 'REGISTRY_URL=ghcr.io' >> /data/coolify/source/.env");
+ $server = new Server;
+ $server->user = 'cooluser';
+ $lines = parseCommandsByLineForSudo(collect($method->invoke($component, 'ghcr.io')), $server);
+
+ // Each branch runs through sudo for a non-root SSH user; nothing is opened by the SSH user's shell.
+ expect($lines)->toBe([
+ "if sudo grep -q '^REGISTRY_URL=' /data/coolify/source/.env; then",
+ "sudo sed -i 's|^REGISTRY_URL=.*|REGISTRY_URL=ghcr.io|' /data/coolify/source/.env",
+ 'else',
+ "sudo printf '%s\\n' 'REGISTRY_URL=ghcr.io' | sudo tee -a /data/coolify/source/.env > /dev/null",
+ 'fi',
+ ]);
});
it('prevents downgrade even with manual update', function () {