feat(backups): support scheduled backups for application storage targets

Add polymorphic volume backup scheduling for persistent volumes and directories, expose schedule management via API, and reorganize backup configuration and execution views.
This commit is contained in:
Andras Bacsai committed 2026-07-15 17:34:22 +02:00
1 parent 995ec5fbb6
commit d7385ad0c4
60 files changed
+3570 -985

No files matched your search

@@ -1,11 +1,11 @@
<form class="flex flex-col w-full gap-4 rounded-sm" wire:submit="submit">
@if ($volumes->isEmpty())
<div class="text-warning">Add a persistent volume before configuring a backup.</div>
@if ($targets->isEmpty())
<div class="text-warning">Add a persistent volume or directory mount before configuring a backup.</div>
@else
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.select id="volumeId" label="Volume" required :disabled="$volumeLocked">
@foreach ($volumes as $volume)
<option value="{{ $volume->id }}">{{ $volume->name }}</option>
<x-forms.select id="targetKey" label="Backup Target" required :disabled="$targetLocked">
@foreach ($targets as $target)
<option value="{{ $target['key'] }}">{{ $target['type'] }}: {{ $target['name'] }}</option>
@endforeach
</x-forms.select>
<x-forms.input id="frequency" placeholder="0 0 * * * or daily"
@@ -13,24 +13,6 @@
label="Frequency" required />
</div>
<div>
<h2 class="pb-2">S3</h2>
@if ($definedS3s->isEmpty())
<div class="text-sm text-neutral-600 dark:text-neutral-400">No validated S3 storages found.</div>
@else
<div class="flex flex-col gap-3">
<x-forms.checkbox live id="saveToS3" label="Save to S3" />
@if ($saveToS3)
<x-forms.select id="s3StorageId" label="S3 Storage">
@foreach ($definedS3s as $s3)
<option value="{{ $s3->id }}">{{ $s3->name }}</option>
@endforeach
</x-forms.select>
@endif
</div>
@endif
</div>
<x-forms.button type="submit">Save</x-forms.button>
@endif
</form>
@@ -1,13 +1,14 @@
<div x-data="{
search: '',
search: @js($search),
backups: @js($backups->map(fn ($backup) => [
'name' => strtolower($backup->volume->name),
'name' => strtolower($backup->targetName()),
'type' => strtolower($backup->targetType()),
'frequency' => strtolower($backup->frequency),
])->values()),
hasMatches() {
const query = this.search.toLowerCase();
return this.backups.some((backup) => backup.name.includes(query) || backup.frequency.includes(query));
return this.backups.some((backup) => backup.name.includes(query) || backup.type.includes(query) || backup.frequency.includes(query));
},
}">
<x-slot:title>
@@ -28,7 +29,7 @@
</div>
<div class="max-w-md pb-4">
<x-forms.input id="null" type="search" x-model="search" placeholder="Search by volume name or frequency..." />
<x-forms.input id="null" type="search" x-model="search" placeholder="Search by target name, type, or frequency..." />
</div>
<div class="flex flex-col gap-2">
@@ -37,7 +38,7 @@
</div>
@forelse ($backups as $backup)
@php($latestExecution = $backup->latestExecution)
<a x-show="search === '' || @js(strtolower($backup->volume->name)).includes(search.toLowerCase()) || @js(strtolower($backup->frequency)).includes(search.toLowerCase())" @class([
<a x-show="search === '' || @js(strtolower($backup->targetName())).includes(search.toLowerCase()) || @js(strtolower($backup->targetType())).includes(search.toLowerCase()) || @js(strtolower($backup->frequency)).includes(search.toLowerCase())" @class([
'flex flex-col border-l-2 transition-colors p-4 cursor-pointer bg-white hover:bg-gray-100 dark:bg-coolgray-100 dark:hover:bg-coolgray-200 text-black dark:text-white',
'border-blue-500/50 border-dashed' => $latestExecution?->status === 'running',
'border-error' => $latestExecution?->status === 'failed',
@@ -66,7 +67,7 @@
@endif
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
Volume: {{ $backup->volume->name }}
{{ $backup->targetType() }}: {{ $backup->targetName() }}
@if ($latestExecution?->finished_at)
• Last run {{ $latestExecution->finished_at->diffForHumans() }}
@else
@@ -1,11 +1,37 @@
<div>
<x-slot:title>
{{ data_get_str($application, 'name')->limit(10) }} > Volume Backups | Coolify
{{ data_get_str($application, 'name')->limit(10) }} > Storage Backups | Coolify
</x-slot>
<h1>Volume Backups</h1>
<h1>Storage Backups</h1>
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" />
<livewire:project.shared.storages.volume-backups :storage="$backup->volume" :resource="$application"
wire:key="volume-backup-{{ $backup->uuid }}" />
<div class="flex flex-col h-full gap-2 md:gap-8 md:flex-row">
<div class="sub-menu-wrapper">
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'general']) {{ wireNavigate() }}
href="{{ route('project.application.backup.show', $parameters) }}">
<span class="menu-item-label">General</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 's3']) {{ wireNavigate() }}
href="{{ route('project.application.backup.s3', $parameters) }}">
<span class="menu-item-label">S3</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'retention']) {{ wireNavigate() }}
href="{{ route('project.application.backup.retention', $parameters) }}">
<span class="menu-item-label">Retention</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'executions']) {{ wireNavigate() }}
href="{{ route('project.application.backup.executions', $parameters) }}">
<span class="menu-item-label">Executions</span>
</a>
<a @class(['sub-menu-item', 'menu-item-active' => $section === 'danger']) {{ wireNavigate() }}
href="{{ route('project.application.backup.danger', $parameters) }}">
<span class="menu-item-label">Danger Zone</span>
</a>
</div>
<div class="w-full md:flex-grow">
<livewire:project.shared.storages.volume-backups :storage="$backup->backupable" :resource="$application"
:section="$section" wire:key="volume-backup-{{ $backup->uuid }}-{{ $section }}" />
</div>
</div>
</div>
@@ -14,7 +14,7 @@
[
'label' => 'Backups',
'route' => 'project.application.backup.index',
'active' => request()->routeIs('project.application.backup.index', 'project.application.backup.show'),
'active' => request()->routeIs('project.application.backup.*'),
],
[
'label' => 'Logs',
@@ -452,7 +452,7 @@
href="{{ route('project.application.deployment.index', $parameters) }}">
Deployments
</a>
<a class="hidden md:block shrink-0 {{ request()->routeIs('project.application.backup.index', 'project.application.backup.show') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
<a class="hidden md:block shrink-0 {{ request()->routeIs('project.application.backup.*') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.application.backup.index', $parameters) }}">
Backups
</a>