feat(settings): configure CDN URL for stored images

Add a persisted instance setting for S3 image CDN URLs and use it when building image links. Cache profile avatars and project icons with immutable one-year headers.
This commit is contained in:
Andras Bacsai 2026-09-04 21:15:06 +02:00
parent 9c3fb1da39
commit 3c0e43f482
13 changed files with 82 additions and 16 deletions

View file

@ -53,4 +53,3 @@ DUSK_DRIVER_URL=http://selenium:4444
BUNNY_API_KEY= BUNNY_API_KEY=
# For asset uploads # For asset uploads
BUNNY_STORAGE_API_KEY= BUNNY_STORAGE_API_KEY=
AVATAR_CDN_URL=

View file

@ -11,4 +11,3 @@ REDIS_PASSWORD=coolify
PUSHER_APP_ID=coolify PUSHER_APP_ID=coolify
PUSHER_APP_KEY=coolify PUSHER_APP_KEY=coolify
PUSHER_APP_SECRET=coolify PUSHER_APP_SECRET=coolify
AVATAR_CDN_URL=

View file

@ -14,7 +14,7 @@ public function __invoke(AvatarStorageService $avatarStorage): Response
return response($contents, 200, [ return response($contents, 200, [
'Content-Type' => 'image/jpeg', 'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=300', 'Cache-Control' => 'private, max-age=31536000, immutable',
]); ]);
} }
} }

View file

@ -15,6 +15,9 @@ public function __invoke(string $project_uuid, ProjectIconStorageService $iconSt
abort_if($contents === null, 404); abort_if($contents === null, 404);
return response($contents)->header('Content-Type', 'image/jpeg'); return response($contents, 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'private, max-age=31536000, immutable',
]);
} }
} }

View file

@ -53,6 +53,8 @@ class Advanced extends Component
public string $avatar_storage = 'local'; public string $avatar_storage = 'local';
public ?string $image_cdn_url = null;
public array $avatar_storage_options = []; public array $avatar_storage_options = [];
public function rules() public function rules()
@ -71,6 +73,7 @@ public function rules()
'webhook_allowed_internal_hosts' => 'nullable|string', 'webhook_allowed_internal_hosts' => 'nullable|string',
'webhook_allow_localhost' => 'boolean', 'webhook_allow_localhost' => 'boolean',
'domain_connect_private_key' => 'nullable|string', 'domain_connect_private_key' => 'nullable|string',
'image_cdn_url' => 'nullable|url|max:255',
]; ];
} }
@ -97,6 +100,7 @@ public function mount()
$this->avatar_storage = $this->settings->avatar_storage_type === 's3' && $this->settings->avatar_s3_storage_id $this->avatar_storage = $this->settings->avatar_storage_type === 's3' && $this->settings->avatar_s3_storage_id
? 's3:'.$this->settings->avatar_s3_storage_id ? 's3:'.$this->settings->avatar_s3_storage_id
: 'local'; : 'local';
$this->image_cdn_url = $this->settings->image_cdn_url;
$this->avatar_storage_options = [ $this->avatar_storage_options = [
['value' => 'local', 'label' => 'Local storage'], ['value' => 'local', 'label' => 'Local storage'],
...S3Storage::query() ...S3Storage::query()
@ -210,6 +214,7 @@ public function instantSave(?array $webhookAllowedInternalHosts = null)
$this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? []; $this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? [];
$this->settings->webhook_allow_localhost = $this->webhook_allow_localhost; $this->settings->webhook_allow_localhost = $this->webhook_allow_localhost;
$this->settings->image_cdn_url = filled($this->image_cdn_url) ? rtrim($this->image_cdn_url, '/') : null;
$this->saveAvatarStorageSetting(); $this->saveAvatarStorageSetting();
$this->settings->save(); $this->settings->save();
$this->dispatch('success', 'Settings updated!'); $this->dispatch('success', 'Settings updated!');

View file

@ -56,6 +56,7 @@ class InstanceSettings extends Model
'webhook_allow_localhost', 'webhook_allow_localhost',
'avatar_storage_type', 'avatar_storage_type',
'avatar_s3_storage_id', 'avatar_s3_storage_id',
'image_cdn_url',
'is_dashboard_force_https_enabled', 'is_dashboard_force_https_enabled',
]; ];

View file

@ -860,7 +860,7 @@ function s3_image_url(?int $storageId, ?string $path, int $version): ?string
return null; return null;
} }
$baseUrl = config('constants.coolify.avatar_cdn_url') ?: $storage->awsUrl(); $baseUrl = instanceSettings()->image_cdn_url ?: $storage->awsUrl();
return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version; return rtrim($baseUrl, '/').'/'.ltrim($path, '/').'?v='.$version;
} }

View file

@ -14,7 +14,6 @@
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'), 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'avatar_cdn_url' => env('AVATAR_CDN_URL'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'), 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'), 'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'),

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('image_cdn_url')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('image_cdn_url');
});
}
};

View file

@ -9,7 +9,7 @@
listboxes (API, MCP, telemetry, ) update the snapshot on the server listboxes (API, MCP, telemetry, ) update the snapshot on the server
immediately; without wire:target they briefly flash this bar. --}} immediately; without wire:target they briefly flash this bar. --}}
<x-unsaved-bar action="submit" <x-unsaved-bar action="submit"
targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost,domain_connect_private_key" /> targets="custom_dns_servers,allowed_ips,webhook_allowed_internal_hosts,webhook_allow_localhost,domain_connect_private_key,image_cdn_url" />
<x-application.settings-section id="access-section" title="Access"> <x-application.settings-section id="access-section" title="Access">
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
@ -144,9 +144,11 @@
<x-application.settings-section id="avatar-storage-section" title="Image storage" <x-application.settings-section id="avatar-storage-section" title="Image storage"
helper="Choose where compressed profile pictures and project icons are stored. Use S3 for multi-instance or cloud deployments so every application replica can access the same files."> helper="Choose where compressed profile pictures and project icons are stored. Use S3 for multi-instance or cloud deployments so every application replica can access the same files.">
<div class="max-w-md"> <div class="flex max-w-md flex-col gap-4">
<x-forms.listbox id="avatar_storage" label="Storage destination" onChange="instantSave" <x-forms.listbox id="avatar_storage" label="Storage destination" onChange="instantSave"
:options="$avatar_storage_options" /> :options="$avatar_storage_options" />
<x-forms.input id="image_cdn_url" label="Image CDN URL"
helper="Optional public CDN URL for profile pictures and project icons stored on S3. Leave empty to use the S3 endpoint." placeholder="https://images.example.com" />
</div> </div>
@if (count($avatar_storage_options) === 1) @if (count($avatar_storage_options) === 1)
<x-callout type="info" title="No usable S3 storage configured" class="mt-4"> <x-callout type="info" title="No usable S3 storage configured" class="mt-4">

View file

@ -72,11 +72,14 @@
$this->withoutMiddleware()->actingAs($user) $this->withoutMiddleware()->actingAs($user)
->get(route('profile.avatar')) ->get(route('profile.avatar'))
->assertSuccessful() ->assertSuccessful()
->assertHeader('content-type', 'image/jpeg'); ->assertHeader('content-type', 'image/jpeg')
->assertHeader('cache-control', 'immutable, max-age=31536000, private');
}); });
it('loads an S3 profile picture from the configured CDN', function () { it('loads an S3 profile picture from the configured CDN', function () {
config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com/media/'); InstanceSettings::findOrFail(0)->update([
'image_cdn_url' => 'https://avatars.example.com/media',
]);
Team::factory()->create(['id' => 0]); Team::factory()->create(['id' => 0]);
$storage = S3Storage::query()->create([ $storage = S3Storage::query()->create([
'team_id' => 0, 'team_id' => 0,
@ -98,7 +101,6 @@
}); });
it('loads an S3 profile picture directly from S3 when the CDN is not configured', function () { it('loads an S3 profile picture directly from S3 when the CDN is not configured', function () {
config()->set('constants.coolify.avatar_cdn_url');
Team::factory()->create(['id' => 0]); Team::factory()->create(['id' => 0]);
$storage = S3Storage::query()->create([ $storage = S3Storage::query()->create([
'team_id' => 0, 'team_id' => 0,
@ -120,7 +122,6 @@
}); });
it('does not use an unrelated S3 storage URL for a profile picture', function () { it('does not use an unrelated S3 storage URL for a profile picture', function () {
config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com');
$storage = S3Storage::query()->create([ $storage = S3Storage::query()->create([
'team_id' => Team::factory()->create()->id, 'team_id' => Team::factory()->create()->id,
'name' => 'Unrelated storage', 'name' => 'Unrelated storage',

View file

@ -61,7 +61,8 @@
$this->withoutMiddleware()->get(route('project.icon', ['project_uuid' => $this->project->uuid])) $this->withoutMiddleware()->get(route('project.icon', ['project_uuid' => $this->project->uuid]))
->assertSuccessful() ->assertSuccessful()
->assertHeader('content-type', 'image/jpeg'); ->assertHeader('content-type', 'image/jpeg')
->assertHeader('cache-control', 'immutable, max-age=31536000, private');
$otherUser = User::factory()->create(); $otherUser = User::factory()->create();
$otherTeam = Team::factory()->create(); $otherTeam = Team::factory()->create();
@ -104,7 +105,9 @@
}); });
it('loads an S3 project icon from the configured CDN', function () { it('loads an S3 project icon from the configured CDN', function () {
config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com/media/'); InstanceSettings::findOrFail(0)->update([
'image_cdn_url' => 'https://avatars.example.com/media',
]);
Team::factory()->create(['id' => 0]); Team::factory()->create(['id' => 0]);
$storage = S3Storage::query()->create([ $storage = S3Storage::query()->create([
'team_id' => 0, 'team_id' => 0,
@ -127,7 +130,6 @@
}); });
it('loads an S3 project icon directly from S3 when the CDN is not configured', function () { it('loads an S3 project icon directly from S3 when the CDN is not configured', function () {
config()->set('constants.coolify.avatar_cdn_url');
Team::factory()->create(['id' => 0]); Team::factory()->create(['id' => 0]);
$storage = S3Storage::query()->create([ $storage = S3Storage::query()->create([
'team_id' => 0, 'team_id' => 0,
@ -149,7 +151,6 @@
}); });
it('does not use an unusable S3 storage URL for a project icon', function () { it('does not use an unusable S3 storage URL for a project icon', function () {
config()->set('constants.coolify.avatar_cdn_url', 'https://avatars.example.com');
Team::factory()->create(['id' => 0]); Team::factory()->create(['id' => 0]);
$storage = S3Storage::query()->create([ $storage = S3Storage::query()->create([
'team_id' => 0, 'team_id' => 0,

View file

@ -29,6 +29,12 @@
->not->toContain('Two-step confirmations enabled'); ->not->toContain('Two-step confirmations enabled');
}); });
test('image storage fields use the standard settings field gap', function () {
$contents = file_get_contents(resource_path('views/livewire/settings/advanced.blade.php'));
expect($contents)->toContain('<div class="flex max-w-md flex-col gap-4">');
});
test('instance admin can toggle registration via listbox instantSave', function () { test('instance admin can toggle registration via listbox instantSave', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
@ -82,6 +88,28 @@
expect((bool) $settings->fresh()->disable_two_step_confirmation)->toBeTrue(); expect((bool) $settings->fresh()->disable_two_step_confirmation)->toBeTrue();
}); });
test('instance admin can configure the image CDN URL at runtime', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
$settings = InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
$user = User::factory()->create();
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
$this->actingAs($user);
session(['currentTeam' => ['id' => $rootTeam->id]]);
Livewire::test(Advanced::class)
->assertSee('Image CDN URL')
->set('image_cdn_url', 'https://images.example.com/media/')
->call('submit')
->assertHasNoErrors()
->assertDispatched('success');
expect($settings->fresh()->image_cdn_url)->toBe('https://images.example.com/media');
});
test('open API allowlist warning is hidden when API access is disabled', function () { test('open API allowlist warning is hidden when API access is disabled', function () {
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);