diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index ce9e723d4..065d7f767 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -1512,9 +1512,32 @@ private function create_application(Request $request, $type)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
- if (! $request->docker_registry_image_tag) {
- $request->offsetSet('docker_registry_image_tag', 'latest');
+ // Process docker image name and tag for SHA256 digests
+ $dockerImageName = $request->docker_registry_image_name;
+ $dockerImageTag = $request->docker_registry_image_tag;
+
+ // Strip 'sha256:' prefix if user provided it in the tag
+ if ($dockerImageTag) {
+ $dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag));
}
+
+ // Remove @sha256 from image name if user added it
+ if ($dockerImageName) {
+ $dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName));
+ }
+
+ // Check if tag is a valid SHA256 hash (64 hex characters)
+ $isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag);
+
+ // Append @sha256 to image name if using digest and not already present
+ if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) {
+ $dockerImageName .= '@sha256';
+ }
+
+ // Set processed values back to request
+ $request->offsetSet('docker_registry_image_name', $dockerImageName);
+ $request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest');
+
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 965eab68e..91e105f56 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -503,7 +503,12 @@ private function deploy_dockerimage_buildpack()
} else {
$this->dockerImageTag = $this->application->docker_registry_image_tag;
}
- $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}.");
+
+ // Check if this is an image hash deployment
+ $isImageHash = str($this->dockerImageTag)->startsWith('sha256-');
+ $displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}";
+
+ $this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}.");
$this->generate_image_names();
$this->prepare_builder_image();
$this->generate_compose_file();
@@ -934,7 +939,13 @@ private function generate_image_names()
$this->production_image_name = "{$this->application->uuid}:latest";
}
} elseif ($this->application->build_pack === 'dockerimage') {
- $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
+ // Check if this is an image hash deployment
+ if (str($this->dockerImageTag)->startsWith('sha256-')) {
+ $hash = str($this->dockerImageTag)->after('sha256-');
+ $this->production_image_name = "{$this->dockerImage}@sha256:{$hash}";
+ } else {
+ $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
+ }
} elseif ($this->pull_request_id !== 0) {
if ($this->application->docker_registry_image_name) {
$this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build";
diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php
index dbb223de2..e105c956a 100644
--- a/app/Livewire/Project/New/DockerImage.php
+++ b/app/Livewire/Project/New/DockerImage.php
@@ -12,7 +12,11 @@
class DockerImage extends Component
{
- public string $dockerImage = '';
+ public string $imageName = '';
+
+ public string $imageTag = '';
+
+ public string $imageSha256 = '';
public array $parameters;
@@ -26,12 +30,41 @@ public function mount()
public function submit()
{
+ // Strip 'sha256:' prefix if user pasted it
+ if ($this->imageSha256) {
+ $this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
+ }
+
+ // Remove @sha256 from image name if user added it
+ if ($this->imageName) {
+ $this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName));
+ }
+
$this->validate([
- 'dockerImage' => 'required',
+ 'imageName' => ['required', 'string'],
+ 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
+ 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
]);
+ // Validate that either tag or sha256 is provided, but not both
+ if ($this->imageTag && $this->imageSha256) {
+ $this->addError('imageTag', 'Provide either a tag or SHA256 digest, not both.');
+ $this->addError('imageSha256', 'Provide either a tag or SHA256 digest, not both.');
+
+ return;
+ }
+
+ // Build the full Docker image string
+ if ($this->imageSha256) {
+ $dockerImage = $this->imageName.'@sha256:'.$this->imageSha256;
+ } elseif ($this->imageTag) {
+ $dockerImage = $this->imageName.':'.$this->imageTag;
+ } else {
+ $dockerImage = $this->imageName.':latest';
+ }
+
$parser = new DockerImageParser;
- $parser->parse($this->dockerImage);
+ $parser->parse($dockerImage);
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
@@ -45,6 +78,16 @@ public function submit()
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
+
+ // Determine the image tag based on whether it's a hash or regular tag
+ $imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
+
+ // Append @sha256 to image name if using digest and not already present
+ $imageName = $parser->getFullImageNameWithoutTag();
+ if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) {
+ $imageName .= '@sha256';
+ }
+
$application = Application::create([
'name' => 'docker-image-'.new Cuid2,
'repository_project_id' => 0,
@@ -52,7 +95,7 @@ public function submit()
'git_branch' => 'main',
'build_pack' => 'dockerimage',
'ports_exposes' => 80,
- 'docker_registry_image_name' => $parser->getFullImageNameWithoutTag(),
+ 'docker_registry_image_name' => $imageName,
'docker_registry_image_tag' => $parser->getTag(),
'environment_id' => $environment->id,
'destination_id' => $destination->id,
diff --git a/app/Rules/DockerImageFormat.php b/app/Rules/DockerImageFormat.php
new file mode 100644
index 000000000..a6a78a76c
--- /dev/null
+++ b/app/Rules/DockerImageFormat.php
@@ -0,0 +1,41 @@
+ strrpos($imageString, '/'))) {
- $mainPart = substr($imageString, 0, $lastColon);
- $this->tag = substr($imageString, $lastColon + 1);
+ // Check for @sha256: format first (e.g., nginx@sha256:abc123...)
+ if (preg_match('/^(.+)@sha256:([a-f0-9]{64})$/i', $imageString, $matches)) {
+ $mainPart = $matches[1];
+ $this->tag = $matches[2];
+ $this->isImageHash = true;
} else {
- $mainPart = $imageString;
- $this->tag = 'latest';
+ // Split by : to handle the tag, but be careful with registry ports
+ $lastColon = strrpos($imageString, ':');
+ $hasSlash = str_contains($imageString, '/');
+
+ // If the last colon appears after the last slash, it's a tag
+ // Otherwise it might be a port in the registry URL
+ if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) {
+ $mainPart = substr($imageString, 0, $lastColon);
+ $this->tag = substr($imageString, $lastColon + 1);
+
+ // Check if the tag is a SHA256 hash
+ $this->isImageHash = $this->isSha256Hash($this->tag);
+ } else {
+ $mainPart = $imageString;
+ $this->tag = 'latest';
+ $this->isImageHash = false;
+ }
}
// Split the main part by / to handle registry and image name
@@ -41,6 +54,37 @@ public function parse(string $imageString): self
return $this;
}
+ /**
+ * Check if the given string is a SHA256 hash
+ */
+ private function isSha256Hash(string $hash): bool
+ {
+ // SHA256 hashes are 64 characters long and contain only hexadecimal characters
+ return preg_match('/^[a-f0-9]{64}$/i', $hash) === 1;
+ }
+
+ /**
+ * Check if the current tag is an image hash
+ */
+ public function isImageHash(): bool
+ {
+ return $this->isImageHash;
+ }
+
+ /**
+ * Get the full image name with hash if present
+ */
+ public function getFullImageNameWithHash(): string
+ {
+ $imageName = $this->getFullImageNameWithoutTag();
+
+ if ($this->isImageHash) {
+ return $imageName.'@sha256:'.$this->tag;
+ }
+
+ return $imageName.':'.$this->tag;
+ }
+
public function getFullImageNameWithoutTag(): string
{
if ($this->registryUrl) {
@@ -73,6 +117,10 @@ public function toString(): string
}
$parts[] = $this->imageName;
+ if ($this->isImageHash) {
+ return implode('/', $parts).'@sha256:'.$this->tag;
+ }
+
return implode('/', $parts).':'.$this->tag;
}
}
diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php
index 8587b2ab5..e7e26c134 100644
--- a/resources/views/livewire/project/application/general.blade.php
+++ b/resources/views/livewire/project/application/general.blade.php
@@ -166,12 +166,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@if ($application->destination->server->isSwarm())