Merge branch 'next' into add-service-gramps-web

This commit is contained in:
Halil Özgür 2025-10-09 00:17:08 +03:00
commit 2fb7a6f9c7
No known key found for this signature in database
77 changed files with 4500 additions and 786 deletions

156
.AI_INSTRUCTIONS_SYNC.md Normal file
View file

@ -0,0 +1,156 @@
# AI Instructions Synchronization Guide
This document explains how AI instructions are organized and synchronized across different AI tools used with Coolify.
## Overview
Coolify maintains AI instructions in two parallel systems:
1. **CLAUDE.md** - For Claude Code (claude.ai/code)
2. **.cursor/rules/** - For Cursor IDE and other AI assistants
Both systems share core principles but are optimized for their respective workflows.
## Structure
### CLAUDE.md
- **Purpose**: Condensed, workflow-focused guide for Claude Code
- **Format**: Single markdown file
- **Includes**:
- Quick-reference development commands
- High-level architecture overview
- Core patterns and guidelines
- Embedded Laravel Boost guidelines
- References to detailed .cursor/rules/ documentation
### .cursor/rules/
- **Purpose**: Detailed, topic-specific documentation
- **Format**: Multiple .mdc files organized by topic
- **Structure**:
- `README.mdc` - Main index and overview
- `cursor_rules.mdc` - Maintenance guidelines
- Topic-specific files (testing-patterns.mdc, security-patterns.mdc, etc.)
- **Used by**: Cursor IDE, Claude Code (for detailed reference), other AI assistants
## Cross-References
Both systems reference each other:
- **CLAUDE.md** → references `.cursor/rules/` for detailed documentation
- **.cursor/rules/README.mdc** → references `CLAUDE.md` for Claude Code workflow
- **.cursor/rules/cursor_rules.mdc** → notes that changes should sync with CLAUDE.md
## Maintaining Consistency
When updating AI instructions, follow these guidelines:
### 1. Core Principles (MUST be consistent)
- Laravel version (currently Laravel 12)
- PHP version (8.4)
- Testing execution rules (Docker for Feature tests, mocking for Unit tests)
- Security patterns and authorization requirements
- Code style requirements (Pint, PSR-12)
### 2. Where to Make Changes
**For workflow changes** (how to run commands, development setup):
- Primary: `CLAUDE.md`
- Secondary: `.cursor/rules/development-workflow.mdc`
**For architectural patterns** (how code should be structured):
- Primary: `.cursor/rules/` topic files
- Secondary: Reference in `CLAUDE.md` "Additional Documentation" section
**For testing patterns**:
- Both: Must be synchronized
- `CLAUDE.md` - Contains condensed testing execution rules
- `.cursor/rules/testing-patterns.mdc` - Contains detailed examples and patterns
### 3. Update Checklist
When making significant changes:
- [ ] Identify if change affects core principles (version numbers, critical patterns)
- [ ] Update primary location (CLAUDE.md or .cursor/rules/)
- [ ] Check if update affects cross-referenced content
- [ ] Update secondary location if needed
- [ ] Verify cross-references are still accurate
- [ ] Run: `./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc` (if applicable)
### 4. Common Inconsistencies to Watch
- **Version numbers**: Laravel, PHP, package versions
- **Testing instructions**: Docker execution requirements
- **File paths**: Ensure relative paths work from root
- **Command syntax**: Docker commands, artisan commands
- **Architecture decisions**: Laravel 10 structure vs Laravel 12+ structure
## File Organization
```
/
├── CLAUDE.md # Claude Code instructions (condensed)
├── .AI_INSTRUCTIONS_SYNC.md # This file
└── .cursor/
└── rules/
├── README.mdc # Index and overview
├── cursor_rules.mdc # Maintenance guide
├── testing-patterns.mdc # Testing details
├── development-workflow.mdc # Dev setup details
├── security-patterns.mdc # Security details
├── application-architecture.mdc
├── deployment-architecture.mdc
├── database-patterns.mdc
├── frontend-patterns.mdc
├── api-and-routing.mdc
├── form-components.mdc
├── technology-stack.mdc
├── project-overview.mdc
└── laravel-boost.mdc # Laravel-specific patterns
```
## Recent Updates
### 2025-10-07
- ✅ Added cross-references between CLAUDE.md and .cursor/rules/
- ✅ Synchronized Laravel version (12) across all files
- ✅ Added comprehensive testing execution rules (Docker for Feature tests)
- ✅ Added test design philosophy (prefer mocking over database)
- ✅ Fixed inconsistencies in testing documentation
- ✅ Created this synchronization guide
## Maintenance Commands
```bash
# Check for version inconsistencies
grep -r "Laravel [0-9]" CLAUDE.md .cursor/rules/*.mdc
# Check for PHP version consistency
grep -r "PHP [0-9]" CLAUDE.md .cursor/rules/*.mdc
# Format all documentation
./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc
# Search for specific patterns across all docs
grep -r "pattern_to_check" CLAUDE.md .cursor/rules/
```
## Contributing
When contributing documentation:
1. Check both CLAUDE.md and .cursor/rules/ for existing documentation
2. Add to appropriate location(s) based on guidelines above
3. Add cross-references if creating new patterns
4. Update this file if changing organizational structure
5. Verify consistency before submitting PR
## Questions?
If unsure about where to document something:
- **Quick reference / workflow** → CLAUDE.md
- **Detailed patterns / examples** → .cursor/rules/[topic].mdc
- **Both?** → Start with .cursor/rules/, then reference in CLAUDE.md
When in doubt, prefer detailed documentation in .cursor/rules/ and concise references in CLAUDE.md.

View file

@ -9,6 +9,10 @@ alwaysApply: false
This comprehensive set of Cursor Rules provides deep insights into **Coolify**, an open-source self-hostable alternative to Heroku/Netlify/Vercel. These rules will help you understand, navigate, and contribute to this complex Laravel-based deployment platform.
> **Cross-Reference**: This directory is for **detailed, topic-specific rules** used by Cursor IDE and other AI assistants. For Claude Code specifically, also see **[CLAUDE.md](mdc:CLAUDE.md)** which provides a condensed, workflow-focused guide. Both systems share core principles but are optimized for their respective tools.
>
> **Maintaining Rules**: When updating these rules, see **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for synchronization guidelines to keep CLAUDE.md and .cursor/rules/ consistent.
## Rule Categories
### 🏗️ Architecture & Foundation
@ -71,7 +75,7 @@ Coolify uses a **team-based multi-tenancy** model where:
- **Multi-server** support with SSH connections
### 3. Technology Stack
- **Backend**: Laravel 11 + PHP 8.4
- **Backend**: Laravel 12 + PHP 8.4
- **Frontend**: Livewire 3.5 + Alpine.js + Tailwind CSS 4.1
- **Database**: PostgreSQL 15 + Redis 7
- **Containerization**: Docker + Docker Compose

View file

@ -4,6 +4,12 @@ globs: .cursor/rules/*.mdc
alwaysApply: true
---
# Cursor Rules Maintenance Guide
> **Important**: These rules in `.cursor/rules/` are shared between Cursor IDE and other AI assistants. Changes here should be reflected in **[CLAUDE.md](mdc:CLAUDE.md)** when they affect core workflows or patterns.
>
> **Synchronization Guide**: See **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for detailed guidelines on maintaining consistency between CLAUDE.md and .cursor/rules/.
- **Required Rule Structure:**
```markdown
---

View file

@ -185,7 +185,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.

View file

@ -5,11 +5,56 @@ alwaysApply: false
---
# Coolify Testing Architecture & Patterns
> **Cross-Reference**: These detailed testing patterns align with the testing guidelines in **[CLAUDE.md](mdc:CLAUDE.md)**. Both documents share the same core principles about Docker execution and mocking preferences.
## Testing Philosophy
Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions.
!Important: Always run tests inside `coolify` container.
### Test Execution Rules
**CRITICAL**: Tests are categorized by database dependency:
#### Unit Tests (`tests/Unit/`)
- **MUST NOT** use database connections
- **MUST** use mocking for models and external dependencies
- **CAN** run outside Docker: `./vendor/bin/pest tests/Unit`
- Purpose: Test isolated logic, helper functions, and business rules
#### Feature Tests (`tests/Feature/`)
- **MAY** use database connections (factories, migrations, models)
- **MUST** run inside Docker container: `docker exec coolify php artisan test`
- **MUST** use `RefreshDatabase` trait if touching database
- Purpose: Test API endpoints, workflows, and integration scenarios
**Rule of thumb**: If your test needs `Server::factory()->create()` or any database operation, it's a Feature test and MUST run in Docker.
### Prefer Mocking Over Database
When writing tests, always prefer mocking over real database operations:
```php
// ❌ BAD: Unit test using database
it('extracts custom commands', function () {
$server = Server::factory()->create(['ip' => '1.2.3.4']);
$commands = extract_custom_proxy_commands($server, $yaml);
expect($commands)->toBeArray();
});
// ✅ GOOD: Unit test using mocking
it('extracts custom commands', function () {
$server = Mockery::mock('App\Models\Server');
$server->shouldReceive('proxyType')->andReturn('traefik');
$commands = extract_custom_proxy_commands($server, $yaml);
expect($commands)->toBeArray();
});
```
**Design principles for testable code:**
- Use dependency injection instead of global state
- Create interfaces for external dependencies (SSH, Docker, etc.)
- Separate business logic from data persistence
- Make functions accept interfaces instead of concrete models when possible
## Testing Framework Stack

View file

@ -1,6 +1,10 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to **Claude Code** (claude.ai/code) when working with code in this repository.
> **Note for AI Assistants**: This file is specifically for Claude Code. If you're using Cursor IDE, refer to the `.cursor/rules/` directory for detailed rule files. Both systems share core principles but are optimized for their respective workflows.
>
> **Maintaining Instructions**: When updating AI instructions, see [.AI_INSTRUCTIONS_SYNC.md](.AI_INSTRUCTIONS_SYNC.md) for synchronization guidelines between CLAUDE.md and .cursor/rules/.
## Project Overview
@ -23,7 +27,14 @@ ### Backend Development
### Code Quality
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
- `./vendor/bin/pest` - Run Pest tests
- `./vendor/bin/pest` - Run Pest tests (unit tests only, without database)
### Running Tests
**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container:
- **Inside Docker**: `docker exec coolify php artisan test` (for feature tests requiring database)
- **Outside Docker**: `./vendor/bin/pest tests/Unit` (for pure unit tests without database dependencies)
- Unit tests should use mocking and avoid database connections
- Feature tests that require database must be run in the `coolify` container
## Architecture Overview
@ -173,6 +184,21 @@ ### Testing Strategy
- **Mocking**: Use Laravel's built-in mocking for external services
- **Database**: Use RefreshDatabase trait for test isolation
#### Test Execution Environment
**CRITICAL**: Database-dependent tests MUST run inside Docker container:
- **Unit Tests** (`tests/Unit/`): Should NOT use database. Use mocking. Run with `./vendor/bin/pest tests/Unit`
- **Feature Tests** (`tests/Feature/`): May use database. MUST run inside Docker with `docker exec coolify php artisan test`
- If a test needs database (factories, migrations, etc.), it belongs in `tests/Feature/`
- Always mock external services and SSH connections in tests
#### Test Design Philosophy
**PREFER MOCKING**: When designing features and writing tests:
- **Design for testability**: Structure code so it can be tested without database (use dependency injection, interfaces)
- **Mock by default**: Unit tests should mock models and external dependencies using Mockery
- **Avoid database when possible**: If you can test the logic without database, write it as a Unit test
- **Only use database when necessary**: Feature tests should test integration points, not isolated logic
- **Example**: Instead of `Server::factory()->create()`, use `Mockery::mock('App\Models\Server')` in unit tests
### Routing Conventions
- Group routes by middleware and prefix
- Use route model binding for cleaner controllers
@ -228,7 +254,9 @@ ## Important Reminders
## Additional Documentation
For more detailed guidelines and patterns, refer to the `.cursor/rules/` directory:
This file contains high-level guidelines for Claude Code. For **more detailed, topic-specific documentation**, refer to the `.cursor/rules/` directory (also accessible by Cursor IDE and other AI assistants):
> **Cross-Reference**: The `.cursor/rules/` directory contains comprehensive, detailed documentation organized by topic. Start with [.cursor/rules/README.mdc](.cursor/rules/README.mdc) for an overview, then explore specific topics below.
### Architecture & Patterns
- [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure
@ -434,7 +462,7 @@ ### Laravel 10 Structure
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
@ -543,6 +571,10 @@ ### Pest Tests
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- **Unit tests** MUST use mocking and avoid database. They can run outside Docker.
- **Feature tests** can use database but MUST run inside Docker container.
- **Design for testability**: Structure code to be testable without database when possible. Use dependency injection and interfaces.
- **Mock by default**: Prefer `Mockery::mock()` over `Model::factory()->create()` in unit tests.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
@ -551,11 +583,23 @@ ### Pest Tests
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
**IMPORTANT**: Always run tests in the correct environment based on database dependencies:
**Unit Tests (no database):**
- Run outside Docker: `./vendor/bin/pest tests/Unit`
- Run specific file: `./vendor/bin/pest tests/Unit/ProxyCustomCommandsTest.php`
- These tests use mocking and don't require PostgreSQL
**Feature Tests (with database):**
- Run inside Docker: `docker exec coolify php artisan test`
- Run specific file: `docker exec coolify php artisan test tests/Feature/ExampleTest.php`
- Filter by name: `docker exec coolify php artisan test --filter=testName`
- These tests require PostgreSQL and use factories/migrations
**General Guidelines:**
- Run the minimal number of tests using an appropriate filter before finalizing code edits
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite
- If you get database connection errors, you're running a Feature test outside Docker - move it inside
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
@ -650,7 +694,12 @@ ### Replaced Utilities
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
- Run the minimum number of tests needed to ensure code quality and speed.
- **For Unit tests**: Use `./vendor/bin/pest tests/Unit/YourTest.php` (runs outside Docker)
- **For Feature tests**: Use `docker exec coolify php artisan test --filter=YourTest` (runs inside Docker)
- Choose the correct test type based on database dependency:
- No database needed? → Unit test with mocking
- Database needed? → Feature test in Docker
</laravel-boost-guidelines>

View file

@ -33,7 +33,13 @@ public function handle(Server $server, bool $forceRegenerate = false): string
// 1. Force regenerate is requested
// 2. Configuration file doesn't exist or is empty
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
$proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
// Extract custom commands from existing config before regenerating
$custom_commands = [];
if (! empty(trim($proxy_configuration ?? ''))) {
$custom_commands = extractCustomProxyCommands($server, $proxy_configuration);
}
$proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value();
}
if (empty($proxy_configuration)) {

View file

@ -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);

View file

@ -116,16 +116,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_args;
private $environment_variables;
private $env_nixpacks_args;
private $docker_compose;
private $docker_compose_base64;
private ?string $env_filename = null;
private ?string $nixpacks_plan = null;
private Collection $nixpacks_plan_json;
@ -503,7 +499,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();
@ -571,7 +572,6 @@ private function deploy_docker_compose_buildpack()
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$this->application->oldRawParser();
$yaml = $composeFile = $this->application->docker_compose_raw;
$this->generate_runtime_environment_variables();
// For raw compose, we cannot automatically add secrets configuration
// User must define it manually in their docker-compose file
@ -580,16 +580,14 @@ private function deploy_docker_compose_buildpack()
}
} else {
$composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'));
$this->generate_runtime_environment_variables();
if (filled($this->env_filename)) {
$services = collect(data_get($composeFile, 'services', []));
$services = $services->map(function ($service, $name) {
$service['env_file'] = [$this->env_filename];
// Always add .env file to services
$services = collect(data_get($composeFile, 'services', []));
$services = $services->map(function ($service, $name) {
$service['env_file'] = ['.env'];
return $service;
});
$composeFile['services'] = $services->toArray();
}
return $service;
});
$composeFile['services'] = $services->toArray();
if (empty($composeFile)) {
$this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.');
$this->fail('Failed to parse docker-compose file.');
@ -615,6 +613,9 @@ private function deploy_docker_compose_buildpack()
// Build new container to limit downtime.
$this->application_deployment_queue->addLogEntry('Pulling & building required images.');
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
if ($this->docker_compose_custom_build_command) {
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
$build_command = $this->docker_compose_custom_build_command;
@ -630,9 +631,8 @@ private function deploy_docker_compose_buildpack()
if ($this->dockerBuildkitSupported) {
$command = "DOCKER_BUILDKIT=1 {$command}";
}
if (filled($this->env_filename)) {
$command .= " --env-file {$this->workdir}/{$this->env_filename}";
}
// Use build-time .env file from /artifacts (outside Docker context to prevent it from being in the image)
$command .= ' --env-file /artifacts/build-time.env';
if ($this->force_rebuild) {
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache";
} else {
@ -652,6 +652,10 @@ private function deploy_docker_compose_buildpack()
);
}
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->stop_running_container(force: true);
$this->application_deployment_queue->addLogEntry('Starting new application.');
$networkId = $this->application->uuid;
@ -685,9 +689,8 @@ private function deploy_docker_compose_buildpack()
$this->docker_compose_location = '/docker-compose.yaml';
$command = "{$this->coolify_variables} docker compose";
if (filled($this->env_filename)) {
$command .= " --env-file {$server_workdir}/{$this->env_filename}";
}
// Always use .env file
$command .= " --env-file {$server_workdir}/.env";
$command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
['command' => $command, 'hidden' => true],
@ -702,9 +705,8 @@ private function deploy_docker_compose_buildpack()
} else {
$command = "{$this->coolify_variables} docker compose";
if ($this->preserveRepository) {
if (filled($this->env_filename)) {
$command .= " --env-file {$server_workdir}/{$this->env_filename}";
}
// Always use .env file
$command .= " --env-file {$server_workdir}/.env";
$command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->write_deployment_configurations();
@ -712,9 +714,8 @@ private function deploy_docker_compose_buildpack()
['command' => $command, 'hidden' => true],
);
} else {
if (filled($this->env_filename)) {
$command .= " --env-file {$this->workdir}/{$this->env_filename}";
}
// Always use .env file
$command .= " --env-file {$this->workdir}/.env";
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $command), 'hidden' => true],
@ -748,9 +749,18 @@ private function deploy_dockerfile_buildpack()
}
$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->add_build_env_variables_to_dockerfile();
$this->build_image();
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
@ -774,11 +784,15 @@ private function deploy_nixpacks_buildpack()
$this->cleanup_git();
$this->generate_nixpacks_confs();
$this->generate_compose_file();
// Save build-time .env file BEFORE the build for Nixpacks
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables();
$this->build_image();
// For Nixpacks, save runtime environment variables AFTER the build
// to prevent them from being accessible during the build process
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
@ -802,7 +816,16 @@ private function deploy_static_buildpack()
$this->clone_repository();
$this->cleanup_git();
$this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->build_static_image();
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
@ -934,7 +957,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";
@ -976,6 +1005,10 @@ private function should_skip_build()
$this->skip_build = true;
$this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
$this->generate_compose_file();
// Save runtime environment variables even when skipping build
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
@ -985,6 +1018,10 @@ private function should_skip_build()
$this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
$this->skip_build = true;
$this->generate_compose_file();
// Save runtime environment variables even when skipping build
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
@ -1049,8 +1086,6 @@ private function generate_runtime_environment_variables()
$envs->push($key.'='.$item);
});
if ($this->pull_request_id === 0) {
$this->env_filename = '.env';
// Generate SERVICE_ variables first for dockercompose
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
@ -1109,8 +1144,6 @@ private function generate_runtime_environment_variables()
$envs->push('HOST=0.0.0.0');
}
} else {
$this->env_filename = '.env';
// Generate SERVICE_ variables first for dockercompose preview
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
@ -1165,99 +1198,250 @@ private function generate_runtime_environment_variables()
$envs->push('HOST=0.0.0.0');
}
}
if ($envs->isEmpty()) {
if ($this->env_filename) {
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
'hidden' => true,
'ignore_errors' => true,
]
);
$this->server = $this->build_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
'hidden' => true,
'ignore_errors' => true,
]
);
} else {
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
'hidden' => true,
'ignore_errors' => true,
]
);
}
}
$this->env_filename = null;
} else {
// For Nixpacks builds, we save the .env file AFTER the build to prevent
// runtime-only variables from being accessible during the build process
if ($this->application->build_pack !== 'nixpacks' || $this->skip_build) {
$envs_base64 = base64_encode($envs->implode("\n"));
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"),
],
);
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
}
}
}
$this->environment_variables = $envs;
// Return the generated environment variables instead of storing them globally
return $envs;
}
private function save_runtime_environment_variables()
{
// This method saves the .env file with runtime variables
// It should be called AFTER the build for Nixpacks to prevent runtime-only variables
// from being accessible during the build process
// This method saves the .env file with ALL runtime variables
// For builds, it should be called AFTER the build to include runtime-only variables
if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) {
$envs_base64 = base64_encode($this->environment_variables->implode("\n"));
// Generate runtime environment variables locally
$environment_variables = $this->generate_runtime_environment_variables();
// Write .env file to workdir (for container runtime)
// Handle empty environment variables
if ($environment_variables->isEmpty()) {
// For Docker Compose, we need to create an empty .env file
// because we always reference it in the compose file
if ($this->build_pack === 'dockercompose') {
$this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).');
// Create empty .env file
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "touch $this->workdir/.env"),
]
);
// Also create in configuration directory
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
"touch $this->configuration_dir/.env",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"touch $this->configuration_dir/.env",
]
);
}
} else {
// For non-Docker Compose deployments, clean up any existing .env files
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/.env",
'hidden' => true,
'ignore_errors' => true,
]
);
$this->server = $this->build_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/.env",
'hidden' => true,
'ignore_errors' => true,
]
);
} else {
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/.env",
'hidden' => true,
'ignore_errors' => true,
]
);
}
}
return;
}
// Write the environment variables to file
$envs_base64 = base64_encode($environment_variables->implode("\n"));
// Write .env file to workdir (for container runtime)
$this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
],
[
executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"),
'hidden' => true,
]
);
// Write .env file to configuration directory
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"),
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
]
);
}
}
private function generate_buildtime_environment_variables()
{
$envs = collect([]);
$coolify_envs = $this->generate_coolify_env_variables();
// Add COOLIFY variables
$coolify_envs->each(function ($item, $key) use ($envs) {
$envs->push($key.'='.$item);
});
// Add SERVICE_NAME variables for Docker Compose builds
if ($this->build_pack === 'dockercompose') {
if ($this->pull_request_id === 0) {
// Generate SERVICE_NAME for dockercompose services from processed compose
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$dockerCompose = Yaml::parse($this->application->docker_compose_raw);
} else {
$dockerCompose = Yaml::parse($this->application->docker_compose);
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName);
}
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
}
}
} else {
// Generate SERVICE_NAME for preview deployments
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
}
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
}
}
}
}
// Add build-time user variables only
if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these
if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
}
foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values
if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
}
foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
}
}
// Return the generated environment variables
return $envs;
}
private function save_buildtime_environment_variables()
{
// Generate build-time environment variables locally
$environment_variables = $this->generate_buildtime_environment_variables();
// Save .env file for build phase in /artifacts to prevent it from being copied into Docker images
if ($environment_variables->isNotEmpty()) {
$envs_base64 = base64_encode($environment_variables->implode("\n"));
$this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee /artifacts/build-time.env > /dev/null"),
],
[
executeInDocker($this->deployment_uuid, 'cat /artifacts/build-time.env'),
'hidden' => true,
],
);
} elseif ($this->build_pack === 'dockercompose') {
// For Docker Compose, create an empty .env file even if there are no build-time variables
// This ensures the file exists when referenced in docker-compose commands
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
// Write .env file to configuration directory
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
}
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, 'touch /artifacts/build-time.env'),
]
);
}
}
@ -1472,15 +1656,18 @@ private function deploy_pull_request()
$this->generate_nixpacks_confs();
}
$this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables();
if ($this->application->build_pack === 'dockerfile') {
$this->add_build_env_variables_to_dockerfile();
}
$this->build_image();
// For Nixpacks, save runtime environment variables AFTER the build
if ($this->application->build_pack === 'nixpacks') {
$this->save_runtime_environment_variables();
}
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
@ -1515,7 +1702,7 @@ private function create_workdir()
}
}
private function prepare_builder_image()
private function prepare_builder_image(bool $firstTry = true)
{
$this->checkForCancellation();
$settings = instanceSettings();
@ -1538,7 +1725,12 @@ private function prepare_builder_image()
$runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
}
}
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage.");
if ($firstTry) {
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage");
} else {
$this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.');
}
$this->graceful_shutdown_container($this->deployment_uuid);
$this->execute_remote_command(
[
@ -1561,7 +1753,7 @@ private function restart_builder_container_with_actual_commit()
$this->env_args = null;
// Restart the helper container with updated environment variables (including actual SOURCE_COMMIT)
$this->prepare_builder_image();
$this->prepare_builder_image(firstTry: false);
}
private function deploy_to_additional_destinations()
@ -1809,6 +2001,15 @@ private function generate_nixpacks_confs()
if ($this->nixpacks_type === 'elixir') {
$this->elixir_finetunes();
}
if ($this->nixpacks_type === 'node') {
// Check if NIXPACKS_NODE_VERSION is set
$variables = data_get($parsed, 'variables', []);
if (! isset($variables['NIXPACKS_NODE_VERSION'])) {
$this->application_deployment_queue->addLogEntry('----------------------------------------');
$this->application_deployment_queue->addLogEntry('⚠️ NIXPACKS_NODE_VERSION not set. Nixpacks will use Node.js 18 by default, which is EOL.');
$this->application_deployment_queue->addLogEntry('You can override this by setting NIXPACKS_NODE_VERSION=22 in your environment variables.');
}
}
$this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT);
$this->nixpacks_plan_json = collect($parsed);
$this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true);
@ -1954,10 +2155,14 @@ private function generate_env_variables()
{
$this->env_args = collect([]);
$this->env_args->put('SOURCE_COMMIT', $this->commit);
$coolify_envs = $this->generate_coolify_env_variables();
$coolify_envs->each(function ($value, $key) {
$this->env_args->put($key, $value);
});
// For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) {
// Get environment variables that are marked as available during build
$envs = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
@ -1966,24 +2171,9 @@ private function generate_env_variables()
foreach ($envs as $env) {
if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value);
if (str($env->real_value)->startsWith('$')) {
$variable_key = str($env->real_value)->after('$');
if ($variable_key->startsWith('COOLIFY_')) {
$variable = $coolify_envs->get($variable_key->value());
if (filled($variable)) {
$this->env_args->prepend($variable, $variable_key->value());
}
} else {
$variable = $this->application->environment_variables()->where('key', $variable_key)->first();
if ($variable) {
$this->env_args->prepend($variable->real_value, $env->key);
}
}
}
}
}
} else {
// Get preview environment variables that are marked as available during build
$envs = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
@ -1992,29 +2182,9 @@ private function generate_env_variables()
foreach ($envs as $env) {
if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value);
if (str($env->real_value)->startsWith('$')) {
$variable_key = str($env->real_value)->after('$');
if ($variable_key->startsWith('COOLIFY_')) {
$variable = $coolify_envs->get($variable_key->value());
if (filled($variable)) {
$this->env_args->prepend($variable, $variable_key->value());
}
} else {
$variable = $this->application->environment_variables_preview()->where('key', $variable_key)->first();
if ($variable) {
$this->env_args->prepend($variable->real_value, $env->key);
}
}
}
}
}
}
// Merge COOLIFY_* variables into env_args for build process
// This ensures they're available for both build args and build secrets
$coolify_envs->each(function ($value, $key) {
$this->env_args->put($key, $value);
});
}
private function generate_compose_file()
@ -2025,7 +2195,6 @@ private function generate_compose_file()
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->application->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$this->generate_runtime_environment_variables();
if (data_get($this->application, 'custom_labels')) {
$this->application->parseContainerLabels();
$labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels)));
@ -2094,9 +2263,8 @@ private function generate_compose_file()
],
],
];
if (filled($this->env_filename)) {
$docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename];
}
// Always use .env file
$docker_compose['services'][$this->container_name]['env_file'] = ['.env'];
$docker_compose['services'][$this->container_name]['healthcheck'] = [
'test' => [
'CMD-SHELL',
@ -2381,6 +2549,18 @@ private function build_static_image()
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
}
/**
* Wrap a docker build command with environment export from /artifacts/build-time.env
* This enables shell interpolation of variables (e.g., APP_URL=$COOLIFY_URL)
*
* @param string $build_command The docker build command to wrap
* @return string The wrapped command with export statement
*/
private function wrap_build_command_with_env_export(string $build_command): string
{
return "cd {$this->workdir} && set -a && source /artifacts/build-time.env && set +a && {$build_command}";
}
private function build_image()
{
// Add Coolify related variables to the build args/secrets
@ -2388,13 +2568,12 @@ private function build_image()
// Coolify variables are already included in the secrets from generate_build_env_variables
// build_secrets is already a string at this point
} else {
// Traditional build args approach
$this->environment_variables->filter(function ($key, $value) {
return str($key)->startsWith('COOLIFY_');
})->each(function ($key, $value) {
// Traditional build args approach - generate COOLIFY_ variables locally
// Generate COOLIFY_ variables locally for build args
$coolify_envs = $this->generate_coolify_env_variables();
$coolify_envs->each(function ($value, $key) {
$this->build_args->push("--build-arg '{$key}'");
});
$this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
? $this->build_args->implode(' ')
: (string) $this->build_args;
@ -2431,12 +2610,13 @@ private function build_image()
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
ray($build_command);
} else {
$build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@ -2446,13 +2626,18 @@ private function build_image()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported) {
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
}
@ -2479,16 +2664,25 @@ private function build_image()
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
$build_command = "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -2521,7 +2715,7 @@ private function build_image()
$nginx_config = base64_encode(defaultNginxConfiguration());
}
}
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
@ -2558,9 +2752,9 @@ private function build_image()
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -2590,13 +2784,18 @@ private function build_image()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported) {
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@ -2606,13 +2805,18 @@ private function build_image()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported) {
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -2633,22 +2837,31 @@ private function build_image()
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
} else {
// Dockerfile buildpack
if ($this->dockerBuildkitSupported) {
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
// Use BuildKit with secrets
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -2924,6 +3137,7 @@ private function add_build_env_variables_to_dockerfile()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
'save' => 'dockerfile',
'ignore_errors' => true,
]);
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
if ($this->pull_request_id === 0) {
@ -2982,10 +3196,17 @@ private function add_build_env_variables_to_dockerfile()
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
'hidden' => true,
]);
$this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info');
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
'ignore_errors' => true,
]);
}
}
@ -3143,7 +3364,6 @@ private function modify_dockerfiles_for_compose($composeFile)
$argsToAdd->push("ARG {$key}");
}
ray($argsToAdd);
if ($argsToAdd->isEmpty()) {
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add.");

View file

@ -49,7 +49,7 @@ public function handle()
} elseif ($this->status === ProcessStatus::ERROR) {
$this->body = "The preview deployment failed. 🔴\n\n";
}
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/{$this->application->environment->name}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
$this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n";
$this->body .= 'Last updated at: '.now()->toDateTimeString().' CET';

View file

@ -15,6 +15,7 @@
use App\Models\Team;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@ -74,6 +75,7 @@ public function __construct(public ScheduledDatabaseBackup $backup)
{
$this->onQueue('high');
$this->timeout = $backup->timeout;
$this->backup_log_uuid = (string) new Cuid2;
}
public function handle(): void
@ -298,6 +300,10 @@ public function handle(): void
} while ($exists);
$size = 0;
$localBackupSucceeded = false;
$s3UploadError = null;
// Step 1: Create local backup
try {
if (str($databaseType)->contains('postgres')) {
$this->backup_file = "/pg-dump-$database-".Carbon::now()->timestamp.'.dmp';
@ -310,6 +316,7 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_postgresql($database);
} elseif (str($databaseType)->contains('mongo')) {
@ -330,6 +337,7 @@ public function handle(): void
'database_name' => $databaseName,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mongodb($database);
} elseif (str($databaseType)->contains('mysql')) {
@ -343,6 +351,7 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mysql($database);
} elseif (str($databaseType)->contains('mariadb')) {
@ -356,56 +365,77 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mariadb($database);
} else {
throw new \Exception('Unsupported database type');
}
$size = $this->calculate_size();
if ($this->backup->save_s3) {
// Verify local backup succeeded
if ($size > 0) {
$localBackupSucceeded = true;
} else {
throw new \Exception('Local backup file is empty or was not created');
}
} catch (\Throwable $e) {
// Local backup failed
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'failed',
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
'size' => $size,
'filename' => null,
's3_uploaded' => null,
]);
}
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
continue;
}
// Step 2: Upload to S3 if enabled (independent of local backup)
$localStorageDeleted = false;
if ($this->backup->save_s3 && $localBackupSucceeded) {
try {
$this->upload_to_s3();
// If local backup is disabled, delete the local file immediately after S3 upload
if ($this->backup->disable_local_backup) {
deleteBackupsLocally($this->backup_location, $this->server);
$localStorageDeleted = true;
}
} catch (\Throwable $e) {
// S3 upload failed but local backup succeeded
$s3UploadError = $e->getMessage();
}
}
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
// Step 3: Update status and send notifications based on results
if ($localBackupSucceeded) {
$message = $this->backup_output;
if ($s3UploadError) {
$message = $message
? $message."\n\nWarning: S3 upload failed: ".$s3UploadError
: 'Warning: S3 upload failed: '.$s3UploadError;
}
$this->backup_log->update([
'status' => 'success',
'message' => $this->backup_output,
'message' => $message,
'size' => $size,
's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null,
'local_storage_deleted' => $localStorageDeleted,
]);
} catch (\Throwable $e) {
// Check if backup actually failed or if it's just a post-backup issue
$actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3;
if ($actualBackupFailed || $size === 0) {
// Real backup failure
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'failed',
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
'size' => $size,
'filename' => null,
]);
}
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
// Send appropriate notification
if ($s3UploadError) {
$this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
} else {
// Backup succeeded but post-processing failed (cleanup, notification, etc.)
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'success',
'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(),
'size' => $size,
]);
}
// Send success notification since the backup itself succeeded
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
// Log the post-backup issue
ray('Post-backup operation failed but backup was successful: '.$e->getMessage());
}
}
}
@ -591,24 +621,24 @@ private function upload_to_s3(): void
$fullImageName = $this->getFullImageName();
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup->uuid}"], $this->server, false);
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false);
if (filled($containerExists)) {
instant_remote_process(["docker rm -f backup-of-{$this->backup->uuid}"], $this->server, false);
instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false);
}
if (isDev()) {
if ($this->database->name === 'coolify-db') {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
} else {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file;
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
}
} else {
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
}
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
instant_remote_process($commands, $this->server);
$this->s3_uploaded = true;
@ -617,7 +647,7 @@ private function upload_to_s3(): void
$this->add_to_error_output($e->getMessage());
throw $e;
} finally {
$command = "docker rm -f backup-of-{$this->backup->uuid}";
$command = "docker rm -f backup-of-{$this->backup_log_uuid}";
instant_remote_process([$command], $this->server);
}
}

View file

@ -16,7 +16,8 @@ public function deployments()
{
$servers = Server::ownedByCurrentTeam()->get();
return ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])
return ApplicationDeploymentQueue::with(['application.environment.project'])
->whereIn('status', ['in_progress', 'queued'])
->whereIn('server_id', $servers->pluck('id'))
->orderBy('id')
->get([

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,12 @@ public function submit()
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.show', $project->uuid);
$productionEnvironment = $project->environments()->where('name', 'production')->first();
return redirect()->route('project.resource.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $productionEnvironment->uuid,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -544,6 +544,9 @@ public function submit($showToaster = true)
{
try {
$this->authorize('update', $this->application);
$this->validate();
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
@ -584,7 +587,6 @@ public function submit($showToaster = true)
return;
}
}
$this->validate();
if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) {
$this->resetDefaultLabels();

View file

@ -208,7 +208,7 @@ private function customValidate()
// Validate that disable_local_backup can only be true when S3 backup is enabled
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
throw new \Exception('Local backup can only be disabled when S3 backup is enabled.');
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
}
$isValid = validate_cron_expression($this->backup->frequency);

View file

@ -202,11 +202,6 @@ public function server()
public function render()
{
return view('livewire.project.database.backup-executions', [
'checkboxes' => [
['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'],
// ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently from SFTP Storage'],
],
]);
return view('livewire.project.database.backup-executions');
}
}

View file

@ -21,6 +21,14 @@ public function mount()
$this->projects = Project::ownedByCurrentTeam()->get()->map(function ($project) {
$project->settingsRoute = route('project.edit', ['project_uuid' => $project->uuid]);
$project->canUpdate = auth()->user()->can('update', $project);
$project->canCreateResource = auth()->user()->can('createAnyResource');
$firstEnvironment = $project->environments->first();
$project->addResourceRoute = $firstEnvironment
? route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $firstEnvironment->uuid,
])
: null;
return $project;
});

View file

@ -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,

View file

@ -55,7 +55,7 @@ class GithubPrivateRepository extends Component
public ?string $publish_directory = null;
// In case of docker compose
public ?string $base_directory = null;
public ?string $base_directory = '/';
public ?string $docker_compose_location = '/docker-compose.yaml';
// End of docker compose
@ -198,6 +198,7 @@ public function submit()
'build_pack' => $this->build_pack,
'ports_exposes' => $this->port,
'publish_directory' => $this->publish_directory,
'base_directory' => $this->base_directory,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,
@ -212,7 +213,6 @@ public function submit()
}
if ($this->build_pack === 'dockercompose') {
$application['docker_compose_location'] = $this->docker_compose_location;
$application['base_directory'] = $this->base_directory;
}
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->fqdn = $fqdn;

View file

@ -34,6 +34,8 @@ class FileStorage extends Component
public bool $permanently_delete = true;
public bool $isReadOnly = false;
protected $rules = [
'fileStorage.is_directory' => 'required',
'fileStorage.fs_path' => 'required',
@ -52,6 +54,8 @@ public function mount()
$this->workdir = null;
$this->fs_path = $this->fileStorage->fs_path;
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
}
public function convertToDirectory()

View file

@ -212,6 +212,12 @@ private function handleSingleSubmit($data)
$environment = $this->createEnvironmentVariable($data);
$environment->order = $maxOrder + 1;
$environment->save();
// Clear computed property cache to force refresh
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
$this->dispatch('success', 'Environment variable added.');
}
private function createEnvironmentVariable($data)
@ -300,6 +306,9 @@ private function updateOrCreateVariables($isPreview, $variables)
public function refreshEnvs()
{
$this->resource->refresh();
// Clear computed property cache to force refresh
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
$this->getDevView();
}
}

View file

@ -37,6 +37,11 @@ class Show extends Component
'host_path' => 'host',
];
public function mount()
{
$this->isReadOnly = $this->storage->isReadOnlyVolume();
}
public function submit()
{
$this->authorize('update', $this->resource);

View file

@ -120,6 +120,8 @@ public function addCoolifyDatabase()
public function submit()
{
$this->validate();
$this->database->update([
'name' => $this->name,
'description' => $this->description,

View file

@ -5,6 +5,7 @@
use App\Models\S3Storage;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Form extends Component
@ -91,9 +92,24 @@ public function submit()
try {
$this->authorize('update', $this->storage);
$this->validate();
$this->testConnection();
DB::transaction(function () {
$this->validate();
$this->storage->save();
// Test connection with new values - if this fails, transaction will rollback
$this->storage->testConnection(shouldSave: false);
// If we get here, the connection test succeeded
$this->storage->is_usable = true;
$this->storage->unusable_email_sent = false;
$this->storage->save();
});
$this->dispatch('success', 'Storage settings updated and connection verified.');
} catch (\Throwable $e) {
// Refresh the model to revert UI to database values after rollback
$this->storage->refresh();
return handleError($e, $this);
}
}

View file

@ -35,7 +35,7 @@ public function submit()
'personal_team' => false,
]);
auth()->user()->teams()->attach($team, ['role' => 'admin']);
refreshSession();
refreshSession($team);
return redirect()->route('team.index');
} catch (\Throwable $e) {

View file

@ -182,6 +182,21 @@ protected static function booted()
]);
$application->compose_parsing_version = self::$parserVersion;
$application->save();
// Add default NIXPACKS_NODE_VERSION environment variable for Nixpacks applications
if ($application->build_pack === 'nixpacks') {
EnvironmentVariable::create([
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_multiline' => false,
'is_literal' => false,
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
});
static::forceDeleting(function ($application) {
$application->update(['fqdn' => null]);
@ -739,9 +754,9 @@ public function environment_variables()
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC
@ -767,9 +782,9 @@ public function environment_variables_preview()
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC

View file

@ -41,11 +41,9 @@ class ApplicationDeploymentQueue extends Model
{
protected $guarded = [];
public function application(): Attribute
public function application()
{
return Attribute::make(
get: fn () => Application::find($this->application_id),
);
return $this->belongsTo(Application::class);
}
public function server(): Attribute

View file

@ -35,6 +35,11 @@ protected static function booted()
});
}
public static function ownedByCurrentTeam()
{
return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name');
}
public function isEmpty()
{
return $this->applications()->count() == 0 &&

View file

@ -5,6 +5,7 @@
use App\Events\FileStorageChanged;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel
{
@ -192,4 +193,61 @@ public function scopeWherePlainMountPath($query, $path)
{
return $query->get()->where('plain_mount_path', $path);
}
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
try {
// Only check for services
$service = $this->service;
if (! $service || ! method_exists($service, 'service')) {
return false;
}
$actualService = $service->service;
if (! $actualService || ! $actualService->docker_compose_raw) {
return false;
}
// Parse the docker-compose content
$compose = Yaml::parse($actualService->docker_compose_raw);
if (! isset($compose['services'])) {
return false;
}
// Find the service that this volume belongs to
$serviceName = $service->name;
if (! isset($compose['services'][$serviceName]['volumes'])) {
return false;
}
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
// Check if this volume matches our fs_path and mount_path
if (count($parts) >= 2) {
$hostPath = $parts[0];
$containerPath = $parts[1];
$options = $parts[2] ?? null;
// Match based on fs_path and mount_path
if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
return $options === 'ro';
}
}
}
}
return false;
} catch (\Throwable $e) {
ray($e->getMessage(), 'Error checking read-only volume');
return false;
}
}
}

View file

@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends Model
{
@ -48,4 +49,69 @@ protected function hostPath(): Attribute
}
);
}
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
try {
// Get the resource (can be application, service, or database)
$resource = $this->resource;
if (! $resource) {
return false;
}
// Only check for services
if (! method_exists($resource, 'service')) {
return false;
}
$actualService = $resource->service;
if (! $actualService || ! $actualService->docker_compose_raw) {
return false;
}
// Parse the docker-compose content
$compose = Yaml::parse($actualService->docker_compose_raw);
if (! isset($compose['services'])) {
return false;
}
// Find the service that this volume belongs to
$serviceName = $resource->name;
if (! isset($compose['services'][$serviceName]['volumes'])) {
return false;
}
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
// Check if this volume matches our mount_path
if (count($parts) >= 2) {
$containerPath = $parts[1];
$options = $parts[2] ?? null;
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $options === 'ro';
}
}
}
}
return false;
} catch (\Throwable $e) {
ray($e->getMessage(), 'Error checking read-only persistent volume');
return false;
}
}
}

View file

@ -8,6 +8,15 @@ class ScheduledDatabaseBackupExecution extends BaseModel
{
protected $guarded = [];
protected function casts(): array
{
return [
's3_uploaded' => 'boolean',
'local_storage_deleted' => 'boolean',
's3_storage_deleted' => 'boolean',
];
}
public function scheduledDatabaseBackup(): BelongsTo
{
return $this->belongsTo(ScheduledDatabaseBackup::class);

View file

@ -547,6 +547,21 @@ public function extraFields()
}
$fields->put('Grafana', $data->toArray());
break;
case $image->contains('elasticsearch'):
$data = collect([]);
$elastic_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ELASTICSEARCH')->first();
if ($elastic_password) {
$data = $data->merge([
'Password (default user: elastic)' => [
'key' => data_get($elastic_password, 'key'),
'value' => data_get($elastic_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Elasticsearch', $data->toArray());
break;
case $image->contains('directus'):
$data = collect([]);
$admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first();
@ -1231,9 +1246,9 @@ public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC

View file

@ -0,0 +1,116 @@
<?php
namespace App\Notifications\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class BackupSuccessWithS3Warning extends CustomEmailNotification
{
public string $name;
public string $frequency;
public ?string $s3_storage_url = null;
public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name, public $s3_error)
{
$this->onQueue('high');
$this->name = $database->name;
$this->frequency = $backup->frequency;
if ($backup->s3) {
$this->s3_storage_url = base_url().'/storages/'.$backup->s3->uuid;
}
}
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('backup_failure');
}
public function toMail(): MailMessage
{
$mail = new MailMessage;
$mail->subject("Coolify: Backup succeeded locally but S3 upload failed for {$this->database->name}");
$mail->view('emails.backup-success-with-s3-warning', [
'name' => $this->name,
'database_name' => $this->database_name,
'frequency' => $this->frequency,
's3_error' => $this->s3_error,
's3_storage_url' => $this->s3_storage_url,
]);
return $mail;
}
public function toDiscord(): DiscordMessage
{
$message = new DiscordMessage(
title: ':warning: Database backup succeeded locally, S3 upload failed',
description: "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.",
color: DiscordMessage::warningColor(),
);
$message->addField('Frequency', $this->frequency, true);
$message->addField('S3 Error', $this->s3_error);
if ($this->s3_storage_url) {
$message->addField('S3 Storage', '[Check Configuration]('.$this->s3_storage_url.')');
}
return $message;
}
public function toTelegram(): array
{
$message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} succeeded locally but failed to upload to S3.\n\nS3 Error:\n{$this->s3_error}";
if ($this->s3_storage_url) {
$message .= "\n\nCheck S3 Configuration: {$this->s3_storage_url}";
}
return [
'message' => $message,
];
}
public function toPushover(): PushoverMessage
{
$message = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.<br/><br/><b>Frequency:</b> {$this->frequency}.<br/><b>S3 Error:</b> {$this->s3_error}";
if ($this->s3_storage_url) {
$message .= "<br/><br/><a href=\"{$this->s3_storage_url}\">Check S3 Configuration</a>";
}
return new PushoverMessage(
title: 'Database backup succeeded locally, S3 upload failed',
level: 'warning',
message: $message,
);
}
public function toSlack(): SlackMessage
{
$title = 'Database backup succeeded locally, S3 upload failed';
$description = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.";
$description .= "\n\n*Frequency:* {$this->frequency}";
$description .= "\n\n*S3 Error:* {$this->s3_error}";
if ($this->s3_storage_url) {
$description .= "\n\n*S3 Storage:* <{$this->s3_storage_url}|Check Configuration>";
}
return new SlackMessage(
title: $title,
description: $description,
color: SlackMessage::warningColor()
);
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class DockerImageFormat implements ValidationRule
{
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Check if the value contains ":sha256:" or ":sha" which is incorrect format
if (preg_match('/:sha256?:/i', $value)) {
$fail('The :attribute must use @ before sha256 digest (e.g., image@sha256:hash, not image:sha256:hash).');
return;
}
// Valid formats:
// 1. image:tag (e.g., nginx:latest)
// 2. registry/image:tag (e.g., ghcr.io/user/app:v1.2.3)
// 3. image@sha256:hash (e.g., nginx@sha256:abc123...)
// 4. registry/image@sha256:hash
// 5. registry:port/image:tag (e.g., localhost:5000/app:latest)
$pattern = '/^
(?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/)? # Optional registry with optional port
[a-z0-9]+(?:[._\/-][a-z0-9]+)* # Image name (required)
(?::[a-z0-9][a-z0-9._-]*|@sha256:[a-f0-9]{64})? # Optional :tag or @sha256:hash
$/ix';
if (! preg_match($pattern, $value)) {
$fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.');
}
}
}

View file

@ -10,20 +10,33 @@ class DockerImageParser
private string $tag = 'latest';
private bool $isImageHash = false;
public function parse(string $imageString): self
{
// First 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 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;
}
}

View file

@ -21,13 +21,23 @@
'bitnami/mariadb',
'bitnami/mongodb',
'bitnami/redis',
'bitnamilegacy/mariadb',
'bitnamilegacy/mongodb',
'bitnamilegacy/redis',
'bitnamisecure/mariadb',
'bitnamisecure/mongodb',
'bitnamisecure/redis',
'mysql',
'bitnami/mysql',
'bitnamilegacy/mysql',
'bitnamisecure/mysql',
'mysql/mysql-server',
'mariadb',
'postgis/postgis',
'postgres',
'bitnami/postgresql',
'bitnamilegacy/postgresql',
'bitnamisecure/postgresql',
'supabase/postgres',
'elestio/postgres',
'mongo',

View file

@ -237,12 +237,11 @@ function removeOldBackups($backup): void
{
try {
if ($backup->executions) {
// If local backup is disabled, mark all executions as having local storage deleted
if ($backup->disable_local_backup && $backup->save_s3) {
$backup->executions()
->where('local_storage_deleted', false)
->update(['local_storage_deleted' => true]);
} else {
// Delete old local backups (only if local backup is NOT disabled)
// Note: When disable_local_backup is enabled, each execution already marks its own
// local_storage_deleted status at the time of backup, so we don't need to retroactively
// update old executions
if (! $backup->disable_local_backup) {
$localBackupsToDelete = deleteOldBackupsLocally($backup);
if ($localBackupsToDelete->isNotEmpty()) {
$backup->executions()
@ -261,18 +260,18 @@ function removeOldBackups($backup): void
}
}
// Delete executions where both local and S3 storage are marked as deleted
// or where only S3 is enabled and S3 storage is deleted
if ($backup->disable_local_backup && $backup->save_s3) {
$backup->executions()
->where('s3_storage_deleted', true)
->delete();
} else {
$backup->executions()
->where('local_storage_deleted', true)
->where('s3_storage_deleted', true)
->delete();
}
// Delete execution records where all backup copies are gone
// Case 1: Both local and S3 backups are deleted
$backup->executions()
->where('local_storage_deleted', true)
->where('s3_storage_deleted', true)
->delete();
// Case 2: Local backup is deleted and S3 was never used (s3_uploaded is null)
$backup->executions()
->where('local_storage_deleted', true)
->whereNull('s3_uploaded')
->delete();
} catch (\Exception $e) {
throw $e;

View file

@ -1122,9 +1122,10 @@ function escapeDollarSign($value)
/**
* Generate Docker build arguments from environment variables collection
* Returns only keys (no values) since values are sourced from environment via export
*
* @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
* @return \Illuminate\Support\Collection Collection of formatted --build-arg strings
* @return \Illuminate\Support\Collection Collection of formatted --build-arg strings (keys only)
*/
function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
{
@ -1132,21 +1133,9 @@ function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
return $variables->map(function ($var) {
$key = is_array($var) ? data_get($var, 'key') : $var->key;
$value = is_array($var) ? data_get($var, 'value') : $var->value;
$isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false);
if ($isMultiline) {
// For multiline variables, strip surrounding quotes and escape for bash
$raw_value = trim($value, "'");
$escaped_value = str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $raw_value);
return "--build-arg {$key}=\"{$escaped_value}\"";
}
// For regular variables, use escapeshellarg for security
$value = escapeshellarg($value);
return "--build-arg {$key}={$value}";
// Only return the key - Docker will get the value from the environment
return "--build-arg {$key}";
});
}

View file

@ -108,7 +108,63 @@ function connectProxyToNetworks(Server $server)
return $commands->flatten();
}
function generate_default_proxy_configuration(Server $server)
function extractCustomProxyCommands(Server $server, string $existing_config): array
{
$custom_commands = [];
$proxy_type = $server->proxyType();
if ($proxy_type !== ProxyTypes::TRAEFIK->value || empty($existing_config)) {
return $custom_commands;
}
try {
$yaml = Yaml::parse($existing_config);
$existing_commands = data_get($yaml, 'services.traefik.command', []);
if (empty($existing_commands)) {
return $custom_commands;
}
// Define default commands that Coolify generates
$default_command_prefixes = [
'--ping=',
'--api.',
'--entrypoints.http.address=',
'--entrypoints.https.address=',
'--entrypoints.http.http.encodequerysemicolons=',
'--entryPoints.http.http2.maxConcurrentStreams=',
'--entrypoints.https.http.encodequerysemicolons=',
'--entryPoints.https.http2.maxConcurrentStreams=',
'--entrypoints.https.http3',
'--providers.file.',
'--certificatesresolvers.',
'--providers.docker',
'--providers.swarm',
'--log.level=',
'--accesslog.',
];
// Extract commands that don't match default prefixes (these are custom)
foreach ($existing_commands as $command) {
$is_default = false;
foreach ($default_command_prefixes as $prefix) {
if (str_starts_with($command, $prefix)) {
$is_default = true;
break;
}
}
if (! $is_default) {
$custom_commands[] = $command;
}
}
} catch (\Exception $e) {
// If we can't parse the config, return empty array
// Silently fail to avoid breaking the proxy regeneration
}
return $custom_commands;
}
function generateDefaultProxyConfiguration(Server $server, array $custom_commands = [])
{
$proxy_path = $server->proxyPath();
$proxy_type = $server->proxyType();
@ -228,6 +284,13 @@ function generate_default_proxy_configuration(Server $server)
$config['services']['traefik']['command'][] = '--providers.docker=true';
$config['services']['traefik']['command'][] = '--providers.docker.exposedbydefault=false';
}
// Append custom commands (e.g., trustedIPs for Cloudflare)
if (! empty($custom_commands)) {
foreach ($custom_commands as $custom_command) {
$config['services']['traefik']['command'][] = $custom_command;
}
}
} elseif ($proxy_type === 'CADDY') {
$config = [
'networks' => $array_of_networks->toArray(),

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.434',
'version' => '4.0.0-beta.435',
'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up()
{
// Change the default value for the 'image' column
Schema::table('standalone_clickhouses', function (Blueprint $table) {
$table->string('image')->default('bitnamilegacy/clickhouse')->change();
});
// Optionally, update any existing rows with the old default to the new one
DB::table('standalone_clickhouses')
->where('image', 'bitnami/clickhouse')
->update(['image' => 'bitnamilegacy/clickhouse']);
}
public function down()
{
Schema::table('standalone_clickhouses', function (Blueprint $table) {
$table->string('image')->default('bitnami/clickhouse')->change();
});
// Optionally, revert any changed values
DB::table('standalone_clickhouses')
->where('image', 'bitnamilegacy/clickhouse')
->update(['image' => 'bitnami/clickhouse']);
}
};

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('scheduled_database_backup_executions', function (Blueprint $table) {
$table->boolean('s3_uploaded')->nullable()->after('filename');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('scheduled_database_backup_executions', function (Blueprint $table) {
$table->dropColumn('s3_uploaded');
});
}
};

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.433"
"version": "4.0.0-beta.435"
},
"nightly": {
"version": "4.0.0-beta.434"
"version": "4.0.0-beta.436"
},
"helper": {
"version": "1.0.11"

BIN
public/svgs/ente.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View file

@ -114,7 +114,7 @@
}
}
}"
@keydown.escape.window="modalOpen = false; resetModal()" :class="{ 'z-40': modalOpen }"
@keydown.escape.window="if (modalOpen) { modalOpen = false; resetModal(); }" :class="{ 'z-40': modalOpen }"
class="relative w-auto h-auto">
@if ($customButton)
@if ($buttonFullWidth)

View file

@ -59,25 +59,25 @@
if (this.zoom === '90') {
const style = document.createElement('style');
style.textContent = `
html {
font-size: 93.75%;
}
:root {
--vh: 1vh;
}
@media (min-width: 1024px) {
html {
font-size: 87.5%;
}
}
`;
html {
font-size: 93.75%;
}
:root {
--vh: 1vh;
}
@media (min-width: 1024px) {
html {
font-size: 87.5%;
}
}
`;
document.head.appendChild(style);
}
}
}">
<div class="flex pt-6 pb-4 pl-2">
<div class="flex lg:pt-6 pt-4 pb-4 pl-2">
<div class="flex flex-col w-full">
<div class="text-2xl font-bold tracking-wide dark:text-white">Coolify</div>
<x-version />
@ -86,8 +86,8 @@
<!-- Search button that triggers global search modal -->
<button @click="$dispatch('open-global-search')" type="button" title="Search (Press / or ⌘K)"
class="flex items-center gap-1.5 px-2.5 py-1.5 bg-neutral-100 dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-200 rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-neutral-500 dark:text-neutral-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-neutral-500 dark:text-neutral-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>

View file

@ -0,0 +1,9 @@
<x-emails.layout>
Database backup for {{ $name }} @if($database_name)(db:{{ $database_name }})@endif with frequency of {{ $frequency }} succeeded locally but failed to upload to S3.
S3 Error: {{ $s3_error }}
@if($s3_storage_url)
Check S3 Configuration: {{ $s3_storage_url }}
@endif
</x-emails.layout>

View file

@ -20,9 +20,9 @@
}" x-cloak class="mx-auto" :class="pageWidth === 'full' ? '' : 'max-w-7xl'">
<div class="relative z-50 lg:hidden" :class="open ? 'block' : 'hidden'" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-black/80" x-on:click="open = false"></div>
<div class="fixed h-full flex">
<div class="fixed inset-y-0 right-0 h-full flex">
<div class="relative flex flex-1 w-full max-w-56 ">
<div class="absolute top-0 flex justify-center w-16 pt-5 left-full">
<div class="absolute top-0 flex justify-center w-16 pt-5 right-full">
<button type="button" class="-m-2.5 p-2.5" x-on:click="open = !open">
<span class="sr-only">Close sidebar</span>
<svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
@ -45,8 +45,12 @@
</div>
</div>
<div class="sticky top-0 z-40 flex items-center px-4 py-4 gap-x-6 sm:px-6 lg:hidden">
<button type="button" class="-m-2.5 p-2.5 dark:text-warning lg:hidden" x-on:click="open = !open">
<div class="sticky top-0 z-40 flex items-center justify-between px-4 py-4 gap-x-6 sm:px-6 lg:hidden">
<div class="flex items-center gap-3 flex-shrink-0">
<div class="text-xl font-bold tracking-wide dark:text-white">Coolify</div>
<livewire:switch-team />
</div>
<button type="button" class="-m-2.5 p-2.5 dark:text-warning" x-on:click="open = !open">
<span class="sr-only">Open sidebar</span>
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"

View file

@ -14,8 +14,24 @@
</div>
@endif
<section>
<h3 class="pb-2">Projects</h3>
<section class="-mt-2">
<div class="flex items-center gap-2 pb-2">
<h3>Projects</h3>
@if ($projects->count() > 0)
<x-modal-input buttonTitle="Add" title="New Project">
<x-slot:content>
<button
class="flex items-center justify-center size-4 text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300 cursor-pointer">
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
</x-slot:content>
<livewire:project.add-empty />
</x-modal-input>
@endif
</div>
@if ($projects->count() > 0)
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
@foreach ($projects as $project)
@ -65,7 +81,23 @@
</section>
<section>
<h3 class="pb-2">Servers</h3>
<div class="flex items-center gap-2 pb-2">
<h3>Servers</h3>
@if ($servers->count() > 0 && $privateKeys->count() > 0)
<x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false">
<x-slot:content>
<button
class="flex items-center justify-center size-4 text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300 cursor-pointer">
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
</x-slot:content>
<livewire:server.create />
</x-modal-input>
@endif
</div>
@if ($servers->count() > 0)
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
@foreach ($servers as $server)

View file

@ -1,6 +1,6 @@
<div wire:poll.3000ms x-data="{
expanded: @entangle('expanded')
}" class="fixed bottom-0 z-50 mb-4 left-0 lg:left-56 ml-4">
}" class="fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4">
@if ($this->deploymentCount > 0)
<div class="relative">
<!-- Indicator Button -->
@ -68,6 +68,9 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra
{{ $deployment->application_name }}
</div>
<p class="text-xs dark:text-neutral-400 text-gray-600 mt-1">
{{ $deployment->application?->environment?->project?->name }} / {{ $deployment->application?->environment?->name }}
</p>
<p class="text-xs dark:text-neutral-400 text-gray-600">
{{ $deployment->server_name }}
</p>
@if ($deployment->pull_request_id)

View file

@ -13,7 +13,7 @@
@endif
</div>
<div class="subtitle">Network endpoints to deploy your resources.</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
@forelse ($servers as $server)
@forelse ($server->destinations() as $destination)
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
Profile | Coolify
</x-slot>
<h1>Profile</h1>
<div class="subtitle ">Your user profile settings.</div>
<div class="subtitle -mt-2">Your user profile settings.</div>
<form wire:submit='submit' class="flex flex-col">
<div class="flex items-center gap-2">
<h2>General</h2>

View file

@ -166,12 +166,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@if ($application->destination->server->isSwarm())
<x-forms.input required id="application.docker_registry_image_name" label="Docker Image"
x-bind:disabled="!canUpdate" />
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag"
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag or Hash"
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" />
@else
<x-forms.input id="application.docker_registry_image_name" label="Docker Image"
x-bind:disabled="!canUpdate" />
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag"
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag or Hash"
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" />
@endif
@else

View file

@ -51,12 +51,14 @@ class="flex flex-col gap-4">
data_get($execution, 'status') === 'running',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200 dark:shadow-red-900/5' =>
data_get($execution, 'status') === 'failed',
'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200 dark:shadow-amber-900/5' =>
data_get($execution, 'status') === 'success' && data_get($execution, 's3_uploaded') === false,
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200 dark:shadow-green-900/5' =>
data_get($execution, 'status') === 'success',
data_get($execution, 'status') === 'success' && data_get($execution, 's3_uploaded') !== false,
])>
@php
$statusText = match (data_get($execution, 'status')) {
'success' => 'Success',
'success' => data_get($execution, 's3_uploaded') === false ? 'Success (S3 Warning)' : 'Success',
'running' => 'In Progress',
'failed' => 'Failed',
default => ucfirst(data_get($execution, 'status')),
@ -120,20 +122,15 @@ class="flex flex-col gap-4">
Local Storage
</span>
</span>
@if ($backup->save_s3)
@if (data_get($execution, 's3_uploaded') !== null)
<span @class([
'px-2 py-1 rounded-sm text-xs font-medium',
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => !data_get(
$execution,
's3_storage_deleted',
false),
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get(
$execution,
's3_storage_deleted',
false),
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200' => data_get($execution, 's3_uploaded') === false && !data_get($execution, 's3_storage_deleted', false),
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200' => data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false),
'bg-gray-100 text-gray-600 dark:bg-gray-800/50 dark:text-gray-400' => data_get($execution, 's3_storage_deleted', false),
])>
<span class="flex items-center gap-1">
@if (!data_get($execution, 's3_storage_deleted', false))
@if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false))
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
@ -163,9 +160,25 @@ class="flex flex-col gap-4">
<x-forms.button class="dark:hover:bg-coolgray-400"
x-on:click="download_file('{{ data_get($execution, 'id') }}')">Download</x-forms.button>
@endif
@php
$executionCheckboxes = [];
$deleteActions = [];
if (!data_get($execution, 'local_storage_deleted', false)) {
$deleteActions[] = 'This backup will be permanently deleted from local storage.';
}
if (data_get($execution, 's3_uploaded') === true && !data_get($execution, 's3_storage_deleted', false)) {
$executionCheckboxes[] = ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'];
}
if (empty($deleteActions)) {
$deleteActions[] = 'This backup execution record will be deleted.';
}
@endphp
<x-modal-confirmation title="Confirm Backup Deletion?" buttonTitle="Delete" isErrorButton
submitAction="deleteBackup({{ data_get($execution, 'id') }})" :checkboxes="$checkboxes"
:actions="['This backup will be permanently deleted from local storage.']" confirmationText="{{ data_get($execution, 'filename') }}"
submitAction="deleteBackup({{ data_get($execution, 'id') }})" :checkboxes="$executionCheckboxes"
:actions="$deleteActions" confirmationText="{{ data_get($execution, 'filename') }}"
confirmationLabel="Please confirm the execution of the actions by entering the Backup Filename below"
shortConfirmationLabel="Backup Filename" 1 />
</div>

View file

@ -11,49 +11,29 @@
@endcan
</div>
<div class="subtitle">All your projects are here.</div>
<div x-data="searchComponent()" class="-mt-1">
<x-forms.input placeholder="Search for name, description..." x-model="search" id="null" />
<div class="grid grid-cols-2 gap-4 pt-4">
<template x-if="filteredProjects.length === 0">
<div>No project found with the search term "<span x-text="search"></span>".</div>
</template>
<template x-for="project in filteredProjects" :key="project.uuid">
<div class="box group cursor-pointer" @click="$wire.navigateToProject(project.uuid)">
<div class="flex flex-col justify-center flex-1 mx-6">
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2 -mt-1" x-data="{ projects: @js($projects) }">
<template x-for="project in projects" :key="project.uuid">
<div class="box group cursor-pointer" @click="$wire.navigateToProject(project.uuid)">
<div class="flex flex-1 mx-6">
<div class="flex flex-col justify-center flex-1">
<div class="box-title" x-text="project.name"></div>
<div class="box-description">
<div x-text="project.description"></div>
</div>
</div>
<div class="flex items-center justify-center gap-2 pt-4 pb-2 mr-4 text-xs lg:py-0 lg:justify-normal"
x-show="project.canUpdate">
<a class="mx-4 font-bold hover:underline" wire:click.stop
<div class="relative z-10 flex items-center justify-center gap-4 text-xs font-bold"
x-show="project.canUpdate || project.canCreateResource">
<a class="hover:underline" wire:click.stop x-show="project.addResourceRoute"
:href="project.addResourceRoute">
+ Add Resource
</a>
<a class="hover:underline" wire:click.stop x-show="project.canUpdate"
:href="`/project/${project.uuid}/edit`">
Settings
</a>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
<script>
function searchComponent() {
return {
search: '',
get filteredProjects() {
const projects = @js($projects);
if (this.search === '') {
return projects;
}
const searchLower = this.search.toLowerCase();
return projects.filter(project => {
return (project.name?.toLowerCase().includes(searchLower) ||
project.description?.toLowerCase().includes(searchLower))
});
}
}
}
</script>
</div>

View file

@ -1,4 +1,4 @@
<div>
<div x-data x-init="$nextTick(() => { if ($refs.autofocusInput) $refs.autofocusInput.focus(); })">
<h1>Create a new Application</h1>
<div class="pb-4">You can deploy an existing Docker Image from any Registry.</div>
<form wire:submit="submit">
@ -6,6 +6,24 @@
<h2>Docker Image</h2>
<x-forms.button type="submit">Save</x-forms.button>
</div>
<x-forms.input rows="20" id="dockerImage" placeholder="nginx:latest"></x-forms.textarea>
<div class="space-y-4">
<x-forms.input id="imageName" label="Image Name" placeholder="nginx or ghcr.io/user/app"
helper="Enter the Docker image name with optional registry. Examples: nginx, ghcr.io/user/app, localhost:5000/myapp"
required autofocus />
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.input id="imageTag" label="Tag (optional)" placeholder="latest"
helper="Enter a tag like 'latest' or 'v1.2.3'. Leave empty if using SHA256." />
<div
class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 hidden md:flex items-center justify-center z-10">
<div
class="px-2 py-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-300 rounded text-xs font-bold text-neutral-500 dark:text-neutral-400">
OR
</div>
</div>
<x-forms.input id="imageSha256" label="SHA256 Digest (optional)"
placeholder="59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0"
helper="Enter only the 64-character hex digest (without 'sha256:' prefix)" />
</div>
</div>
</form>
</div>

View file

@ -1,5 +1,14 @@
<div>
<div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
@if ($isReadOnly)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
@if ($fileStorage->is_directory)
This directory is mounted as read-only and cannot be modified from the UI.
@else
This file is mounted as read-only and cannot be modified from the UI.
@endif
</div>
@endif
<div class="flex flex-col justify-center text-sm select-text">
<div class="flex gap-2 md:flex-row flex-col">
<x-forms.input label="Source Path" :value="$fileStorage->fs_path" readonly />
@ -7,58 +16,75 @@
</div>
</div>
<form wire:submit='submit' class="flex flex-col gap-2">
@can('update', $resource)
<div class="flex gap-2">
@if ($fileStorage->is_directory)
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?"
buttonTitle="Convert to file" submitAction="convertToFile" :actions="[
'All files in this directory will be permanently deleted and an empty file will be created in its place.',
]"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to file" />
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Deletion?" buttonTitle="Delete"
isErrorButton submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
'The selected directory and all its contents will be permanently deleted from the container.',
]"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@else
@if (!$fileStorage->is_binary)
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
'The selected file will be permanently deleted and an empty directory will be created in its place.',
@if (!$isReadOnly)
@can('update', $resource)
<div class="flex gap-2">
@if ($fileStorage->is_directory)
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?"
buttonTitle="Convert to file" submitAction="convertToFile" :actions="[
'All files in this directory will be permanently deleted and an empty file will be created in its place.',
]"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to directory" />
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to file" />
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Deletion?" buttonTitle="Delete"
isErrorButton submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
'The selected directory and all its contents will be permanently deleted from the container.',
]"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@else
@if (!$fileStorage->is_binary)
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
'The selected file will be permanently deleted and an empty directory will be created in its place.',
]"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" :confirmWithPassword="false"
step2ButtonText="Convert to directory" />
@endif
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from
server</x-forms.button>
<x-modal-confirmation :ignoreWire="false" title="Confirm File Deletion?" buttonTitle="Delete"
isErrorButton submitAction="delete" :checkboxes="$fileDeletionCheckboxes" :actions="['The selected file will be permanently deleted from the container.']"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@endif
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from server</x-forms.button>
<x-modal-confirmation :ignoreWire="false" title="Confirm File Deletion?" buttonTitle="Delete"
isErrorButton submitAction="delete" :checkboxes="$fileDeletionCheckboxes" :actions="['The selected file will be permanently deleted from the container.']"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@endif
</div>
@endcan
@if (!$fileStorage->is_directory)
@can('update', $resource)
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
<div class="w-96">
<x-forms.checkbox instantSave label="Is this based on the Git repository?"
id="fileStorage.is_based_on_git"></x-forms.checkbox>
</div>
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
rows="20" id="fileStorage.content"
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
@endif
@else
</div>
@endcan
@if (!$fileStorage->is_directory)
@can('update', $resource)
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
<div class="w-96">
<x-forms.checkbox instantSave label="Is this based on the Git repository?"
id="fileStorage.is_based_on_git"></x-forms.checkbox>
</div>
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
rows="20" id="fileStorage.content"
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
@endif
@else
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
<div class="w-96">
<x-forms.checkbox disabled label="Is this based on the Git repository?"
id="fileStorage.is_based_on_git"></x-forms.checkbox>
</div>
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
rows="20" id="fileStorage.content" disabled></x-forms.textarea>
@endcan
@endif
@else
{{-- Read-only view --}}
@if (!$fileStorage->is_directory)
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
<div class="w-96">
<x-forms.checkbox disabled label="Is this based on the Git repository?"
@ -68,7 +94,7 @@
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
rows="20" id="fileStorage.content" disabled></x-forms.textarea>
@endcan
@endif
@endif
</form>
</div>

View file

@ -23,7 +23,8 @@
volumeModalOpen: false,
fileModalOpen: false,
directoryModalOpen: false
}" @close-storage-modal.window="
}"
@close-storage-modal.window="
if ($event.detail === 'volume') volumeModalOpen = false;
if ($event.detail === 'file') fileModalOpen = false;
if ($event.detail === 'directory') directoryModalOpen = false;
@ -45,8 +46,7 @@
<div
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
<div class="flex flex-col gap-1">
<a class="dropdown-item"
@click="volumeModalOpen = true; dropdownOpen = false">
<a class="dropdown-item" @click="volumeModalOpen = true; dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@ -105,31 +105,41 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
</button>
</div>
<div class="relative flex items-center justify-center w-auto"
x-init="$watch('volumeModalOpen', value => { if(value) { $nextTick(() => { const input = $el.querySelector('input'); input?.focus(); }) } })">
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submitPersistentVolume'>
x-init="$watch('volumeModalOpen', value => {
if (value) {
$nextTick(() => {
const input = $el.querySelector('input');
input?.focus();
})
}
})">
<form class="flex flex-col w-full gap-2 rounded-sm"
wire:submit='submitPersistentVolume'>
<div class="flex flex-col">
<div>Docker Volumes mounted to the container.</div>
</div>
@if ($isSwarm)
<div class="text-warning">Swarm Mode detected: You need to set a shared volume
(EFS/NFS/etc) on all the worker nodes if you would like to use a persistent
<div class="text-warning">Swarm Mode detected: You need to set a shared
volume
(EFS/NFS/etc) on all the worker nodes if you would like to use a
persistent
volumes.</div>
@endif
<div class="flex flex-col gap-2">
<x-forms.input canGate="update" :canResource="$resource" placeholder="pv-name"
id="name" label="Name" required helper="Volume name." />
@if ($isSwarm)
<x-forms.input canGate="update" :canResource="$resource" placeholder="/root"
id="host_path" label="Source Path" required
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/root" id="host_path" label="Source Path" required
helper="Directory on the host system." />
@else
<x-forms.input canGate="update" :canResource="$resource" placeholder="/root"
id="host_path" label="Source Path"
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/root" id="host_path" label="Source Path"
helper="Directory on the host system." />
@endif
<x-forms.input canGate="update" :canResource="$resource" placeholder="/tmp/root"
id="mount_path" label="Destination Path" required
helper="Directory inside the container." />
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/tmp/root" id="mount_path" label="Destination Path"
required helper="Directory inside the container." />
<x-forms.button canGate="update" :canResource="$resource" type="submit">
Add
</x-forms.button>
@ -169,15 +179,24 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
</button>
</div>
<div class="relative flex items-center justify-center w-auto"
x-init="$watch('fileModalOpen', value => { if(value) { $nextTick(() => { const input = $el.querySelector('input'); input?.focus(); }) } })">
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submitFileStorage'>
x-init="$watch('fileModalOpen', value => {
if (value) {
$nextTick(() => {
const input = $el.querySelector('input');
input?.focus();
})
}
})">
<form class="flex flex-col w-full gap-2 rounded-sm"
wire:submit='submitFileStorage'>
<div class="flex flex-col">
<div>Actual file mounted from the host system to the container.</div>
</div>
<div class="flex flex-col gap-2">
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/etc/nginx/nginx.conf" id="file_storage_path"
label="Destination Path" required helper="File location inside the container" />
label="Destination Path" required
helper="File location inside the container" />
<x-forms.textarea canGate="update" :canResource="$resource" label="Content"
id="file_storage_content"></x-forms.textarea>
<x-forms.button canGate="update" :canResource="$resource" type="submit">
@ -219,18 +238,27 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
</button>
</div>
<div class="relative flex items-center justify-center w-auto"
x-init="$watch('directoryModalOpen', value => { if(value) { $nextTick(() => { const input = $el.querySelector('input'); input?.focus(); }) } })">
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submitFileStorageDirectory'>
x-init="$watch('directoryModalOpen', value => {
if (value) {
$nextTick(() => {
const input = $el.querySelector('input');
input?.focus();
})
}
})">
<form class="flex flex-col w-full gap-2 rounded-sm"
wire:submit='submitFileStorageDirectory'>
<div class="flex flex-col">
<div>Directory mounted from the host system to the container.</div>
</div>
<div class="flex flex-col gap-2">
<x-forms.input canGate="update" :canResource="$resource"
placeholder="{{ application_configuration_dir() }}/{{ $resource->uuid }}/etc/nginx"
id="file_storage_directory_source" label="Source Directory" required
helper="Directory on the host system." />
<x-forms.input canGate="update" :canResource="$resource" placeholder="/etc/nginx"
id="file_storage_directory_destination" label="Destination Directory" required
id="file_storage_directory_source" label="Source Directory"
required helper="Directory on the host system." />
<x-forms.input canGate="update" :canResource="$resource"
placeholder="/etc/nginx" id="file_storage_directory_destination"
label="Destination Directory" required
helper="Directory inside the container." />
<x-forms.button canGate="update" :canResource="$resource" type="submit">
Add
@ -270,19 +298,22 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
{{-- Tabs Navigation --}}
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200">
<button @click="activeTab = 'volumes'"
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' : 'border-b-2 border-transparent'"
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasVolumes) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Volumes ({{ $this->volumeCount }})
</button>
<button @click="activeTab = 'files'"
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' : 'border-b-2 border-transparent'"
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasFiles) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Files ({{ $this->fileCount }})
</button>
<button @click="activeTab = 'directories'"
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' : 'border-b-2 border-transparent'"
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasDirectories) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Directories ({{ $this->directoryCount }})
@ -333,19 +364,96 @@ class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark
</div>
@endif
@else
@if ($resource->persistentStorages()->get()->count() > 0)
<h3>{{ Str::headline($resource->name) }} </h3>
@endif
@if ($resource->persistentStorages()->get()->count() > 0)
<livewire:project.shared.storages.all :resource="$resource" />
@endif
@if ($fileStorage->count() > 0)
<div class="flex flex-col gap-4">
@foreach ($fileStorage->sort() as $fileStorage)
<livewire:project.service.file-storage :fileStorage="$fileStorage"
wire:key="resource-{{ $fileStorage->uuid }}" />
@endforeach
<div class="flex flex-col gap-4 py-2">
<div>
<div class="flex items-center gap-2">
<h2>{{ Str::headline($resource->name) }}</h2>
</div>
</div>
@endif
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
<div>No storage found.</div>
@endif
@php
$hasVolumes = $this->volumeCount > 0;
$hasFiles = $this->fileCount > 0;
$hasDirectories = $this->directoryCount > 0;
$defaultTab = $hasVolumes ? 'volumes' : ($hasFiles ? 'files' : 'directories');
@endphp
@if ($hasVolumes || $hasFiles || $hasDirectories)
<div x-data="{
activeTab: '{{ $defaultTab }}'
}">
{{-- Tabs Navigation --}}
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200">
<button @click="activeTab = 'volumes'"
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasVolumes) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Volumes ({{ $this->volumeCount }})
</button>
<button @click="activeTab = 'files'"
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasFiles) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Files ({{ $this->fileCount }})
</button>
<button @click="activeTab = 'directories'"
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasDirectories) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Directories ({{ $this->directoryCount }})
</button>
</div>
{{-- Tab Content --}}
<div class="pt-4">
{{-- Volumes Tab --}}
<div x-show="activeTab === 'volumes'" class="flex flex-col gap-4">
@if ($hasVolumes)
<livewire:project.shared.storages.all :resource="$resource" />
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No volumes configured.
</div>
@endif
</div>
{{-- Files Tab --}}
<div x-show="activeTab === 'files'" class="flex flex-col gap-4">
@if ($hasFiles)
@foreach ($this->files as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="file-{{ $fs->id }}" />
@endforeach
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No file mounts configured.
</div>
@endif
</div>
{{-- Directories Tab --}}
<div x-show="activeTab === 'directories'" class="flex flex-col gap-4">
@if ($hasDirectories)
@foreach ($this->directories as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="directory-{{ $fs->id }}" />
@endforeach
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No directory mounts configured.
</div>
@endif
</div>
</div>
</div>
@endif
</div>
@endif
</div>

View file

@ -1,6 +1,9 @@
<div>
<form wire:submit='submit' class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200">
@if ($isReadOnly)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
This volume is mounted as read-only and cannot be modified from the UI.
</div>
@if ($isFirst)
<div class="flex gap-2 items-end w-full md:flex-row flex-col">
@if (

View file

@ -11,7 +11,7 @@
@endcan
</div>
<div class="subtitle">All your servers are here.</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
@forelse ($servers as $server)
<a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}"
@class([

View file

@ -7,7 +7,7 @@
</div>
<div class="subtitle">Set Team / Project / Environment wide variables.</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 -mt-1">
<a class="box group" href="{{ route('shared-variables.team.index') }}">
<div class="flex flex-col justify-center mx-6">
<div class="box-title">Team wide</div>

View file

@ -11,7 +11,7 @@
@endcan
</div>
<div class="subtitle">S3 storages for backups.</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
@forelse ($s3 as $storage)
<a href="/storages/{{ $storage->uuid }}" @class(['gap-2 border cursor-pointer box group'])>
<div class="flex flex-col justify-center mx-6">

View file

@ -1,4 +1,4 @@
<x-forms.select wire:model.live="selectedTeamId" label="Current Team">
<x-forms.select wire:model.live="selectedTeamId">
<option value="default" disabled selected>Switch team</option>
@foreach (auth()->user()->teams as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>

View file

@ -11,7 +11,7 @@
@endcan
</div>
<div class="subtitle">Git sources for your applications.</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="grid gap-4 lg:grid-cols-2 -mt-1">
@forelse ($sources as $source)
@if ($source->getMorphClass() === 'App\Models\GithubApp')
<a class="flex gap-2 text-center hover:no-underline box group"

View file

@ -0,0 +1,88 @@
# documentation: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-kibana-with-docker
# slogan: Elastic + Kibana is a Free and Open Source Search, Monitoring, and Visualization Stack
# tags: elastic,kibana,elasticsearch,search,visualization,logging,monitoring,observability,analytics,stack,devops
# logo: svgs/elasticsearch.svg
# port: 5601
services:
elasticsearch:
image: 'elastic/elasticsearch:9.1.2'
container_name: elasticsearch
restart: unless-stopped
environment:
- ELASTIC_PASSWORD=${SERVICE_PASSWORD_ELASTICSEARCH}
- 'ES_JAVA_OPTS=-Xms512m -Xmx512m'
- discovery.type=single-node
- bootstrap.memory_lock=true
- xpack.security.enabled=true
- xpack.security.http.ssl.enabled=false
- xpack.security.transport.ssl.enabled=false
volumes:
- '/etc/localtime:/etc/localtime:ro'
- 'elasticsearch-data:/usr/share/elasticsearch/data'
healthcheck:
test:
- CMD-SHELL
- 'curl --user elastic:${SERVICE_PASSWORD_ELASTICSEARCH} --silent --fail http://localhost:9200/_cluster/health || exit 1'
interval: 10s
timeout: 10s
retries: 24
kibana:
image: 'kibana:9.1.2'
container_name: kibana
restart: unless-stopped
environment:
- SERVICE_URL_KIBANA_5601
- 'SERVER_NAME=${SERVICE_URL_KIBANA}'
- 'SERVER_PUBLICBASEURL=${SERVICE_URL_KIBANA}'
- 'ELASTICSEARCH_HOSTS=http://elasticsearch:9200'
- 'ELASTICSEARCH_USERNAME=kibana_system'
- 'ELASTICSEARCH_PASSWORD=${SERVICE_PASSWORD_KIBANA}'
- 'XPACK_SECURITY_ENCRYPTIONKEY=${SERVICE_PASSWORD_XPACKSECURITY}'
- 'XPACK_REPORTING_ENCRYPTIONKEY=${SERVICE_PASSWORD_XPACKREPORTING}'
- 'XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY=${SERVICE_PASSWORD_XPACKENCRYPTEDSAVEDOBJECTS}'
- 'TELEMETRY_OPTIN=${TELEMETRY_OPTIN:-false}'
volumes:
- '/etc/localtime:/etc/localtime:ro'
- 'kibana-data:/usr/share/kibana/data'
depends_on:
setup:
condition: service_completed_successfully
healthcheck:
test:
- CMD-SHELL
- "curl -s http://localhost:5601/api/status | grep -q '\"level\":\"available\"' || exit 1"
interval: 10s
timeout: 10s
retries: 120
setup:
image: 'elastic/elasticsearch:9.1.2'
container_name: kibana-setup
depends_on:
elasticsearch:
condition: service_healthy
exclude_from_hc: true
environment:
- 'ELASTIC_PASSWORD=${SERVICE_PASSWORD_ELASTICSEARCH}'
- 'KIBANA_PASSWORD=${SERVICE_PASSWORD_KIBANA}'
entrypoint:
- sh
- '-c'
- |
echo "Setting up Kibana user password..."
until curl -s -u "elastic:${ELASTIC_PASSWORD}" http://elasticsearch:9200/_cluster/health | grep -q '"status":"green\|yellow"'; do
echo "Waiting for Elasticsearch..."
sleep 2
done
echo "Setting password for kibana_system user..."
curl -s -X POST -u "elastic:${ELASTIC_PASSWORD}" \
-H "Content-Type: application/json" \
http://elasticsearch:9200/_security/user/kibana_system/_password \
-d "{\"password\":\"${KIBANA_PASSWORD}\"}" || exit 1
echo "Kibana setup completed successfully"
restart: 'no'

View file

@ -0,0 +1,129 @@
# documentation: https://help.ente.io/self-hosting/installation/compose
# slogan: Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.
# category: media
# tags: photos,gallery,backup,encryption,privacy,self-hosted,google-photos,alternative
# logo: svgs/ente-photos.svg
# port: 8080
services:
museum:
image: 'ghcr.io/ente-io/server:613c6a96390d7a624cf30b946955705d632423cc' # Released at 2025-09-14T22:16:37-07:00
environment:
- SERVICE_URL_MUSEUM_8080
- ENTE_DB_HOST=postgres
- ENTE_DB_PORT=5432
- 'ENTE_DB_NAME=${POSTGRES_DB:-ente_db}'
- 'ENTE_DB_USER=${SERVICE_USER_POSTGRES}'
- 'ENTE_DB_PASSWORD=${SERVICE_PASSWORD_POSTGRES}'
- 'ENTE_HTTP_USE_TLS=${ENTE_HTTP_USE_TLS:-false}'
- ENTE_S3_ARE_LOCAL_BUCKETS=false
- ENTE_S3_USE_PATH_STYLE_URLS=true
- 'ENTE_S3_B2_EU_CEN_KEY=${SERVICE_USER_MINIO}'
- 'ENTE_S3_B2_EU_CEN_SECRET=${SERVICE_PASSWORD_MINIO}'
- 'ENTE_S3_B2_EU_CEN_ENDPOINT=${SERVICE_FQDN_MINIO_9000}'
- ENTE_S3_B2_EU_CEN_REGION=eu-central-2
- ENTE_S3_B2_EU_CEN_BUCKET=b2-eu-cen
- 'ENTE_KEY_ENCRYPTION=${SERVICE_REALBASE64_ENCRYPTION}'
- 'ENTE_KEY_HASH=${SERVICE_REALBASE64_64_HASH}'
- 'ENTE_JWT_SECRET=${SERVICE_REALBASE64_JWT}'
- 'ENTE_INTERNAL_ADMIN=${ENTE_INTERNAL_ADMIN:-1580559962386438}'
- 'ENTE_INTERNAL_DISABLE_REGISTRATION=${ENTE_INTERNAL_DISABLE_REGISTRATION:-false}'
volumes:
- 'museum-data:/data'
- 'museum-config:/config'
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_started
healthcheck:
test:
- CMD
- wget
- '--spider'
- 'http://127.0.0.1:8080/ping'
interval: 30s
timeout: 10s
retries: 3
web:
image: 'ghcr.io/ente-io/web:ca03165f5e7f2a50105e6e40019c17ae6cdd934f' # Released at 2025-10-08T00:57:05-07:00
environment:
- SERVICE_URL_WEB_3000
- 'ENTE_API_ORIGIN=${SERVICE_URL_MUSEUM}'
healthcheck:
test:
- CMD
- curl
- '--fail'
- 'http://localhost:3000'
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
postgres:
image: 'postgres:15-alpine'
environment:
- 'POSTGRES_USER=${SERVICE_USER_POSTGRES}'
- 'POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}'
- 'POSTGRES_DB=${POSTGRES_DB:-ente_db}'
volumes:
- 'postgres-data:/var/lib/postgresql/data'
healthcheck:
test:
- CMD-SHELL
- 'pg_isready -U ${SERVICE_USER_POSTGRES} -d ${POSTGRES_DB:-ente_db}'
interval: 10s
timeout: 5s
retries: 5
minio:
image: 'quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z' # Released at 2025-09-07T16-13-09Z
command: 'server /data --console-address ":9001"'
environment:
- MINIO_SERVER_URL=$MINIO_SERVER_URL
- MINIO_BROWSER_REDIRECT_URL=$MINIO_BROWSER_REDIRECT_URL
- MINIO_ROOT_USER=$SERVICE_USER_MINIO
- MINIO_ROOT_PASSWORD=$SERVICE_PASSWORD_MINIO
volumes:
- 'minio-data:/data'
healthcheck:
test:
- CMD
- mc
- ready
- local
interval: 5s
timeout: 20s
retries: 10
minio-init:
image: 'minio/mc:RELEASE.2025-08-13T08-35-41Z' # Released at 2025-08-13T08-35-41Z
depends_on:
minio:
condition: service_started
restart: on-failure
exclude_from_hc: true
environment:
- 'MINIO_ROOT_USER=${SERVICE_USER_MINIO}'
- 'MINIO_ROOT_PASSWORD=${SERVICE_PASSWORD_MINIO}'
- 'MINIO_CORS_URLS=$SERVICE_URL_MUSEUM,$SERVICE_URL_WEB'
entrypoint: |-
/bin/sh -c "
echo \"MINIO_CORS_URLS: \$${MINIO_CORS_URLS}\";
sleep 5;
until mc alias set minio http://minio:9000 \$${MINIO_ROOT_USER} \$${MINIO_ROOT_PASSWORD}; do
echo 'Waiting for MinIO...';
sleep 2;
done;
mc admin config set minio api cors_allow_origin='$MINIO_CORS_URLS' || true;
mc mb minio/b2-eu-cen --ignore-existing;
mc mb minio/wasabi-eu-central-2-v3 --ignore-existing;
mc mb minio/scw-eu-fr-v3 --ignore-existing;
echo 'MinIO buckets and CORS configured';
"

View file

@ -10,86 +10,71 @@ services:
image: ghcr.io/ente-io/server:latest
environment:
- SERVICE_URL_MUSEUM_8080
# Database configuration
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_DB=${POSTGRES_DB:-ente_db}
- POSTGRES_USER=${SERVICE_USER_POSTGRES}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
# S3/MinIO configuration
- S3_ARE_LOCAL_BUCKETS=true
- S3_USE_PATH_STYLE_URLS=true
- S3_B2_EU_CEN_KEY=${SERVICE_USER_MINIO}
- S3_B2_EU_CEN_SECRET=${SERVICE_PASSWORD_MINIO}
- S3_B2_EU_CEN_ENDPOINT=${SERVICE_URL_MINIO_3200}
- S3_B2_EU_CEN_REGION=eu-central-2
- S3_B2_EU_CEN_BUCKET=b2-eu-cen
# Security keys
- ENCRYPTION_KEY=${SERVICE_PASSWORD_64_ENCRYPTION}
- HASH_KEY=${SERVICE_PASSWORD_64_HASH}
- JWT_SECRET=${SERVICE_PASSWORD_64_JWT}
# Admin permissions (grants first account admin access)
- ENTE_INTERNAL_ADMIN=1580559962386438
# App URLs (optional - for web interface)
- APPS_PUBLIC_ALBUMS=${APPS_PUBLIC_ALBUMS:-}
- APPS_CAST=${APPS_CAST:-}
- APPS_ACCOUNTS=${APPS_ACCOUNTS:-}
volumes:
- museum-data:/data
- museum-config:/config
- ENTE_HTTP_USE_TLS=${ENTE_HTTP_USE_TLS:-false}
- ENTE_APPS_PUBLIC_ALBUMS=${SERVICE_URL_WEB_3002}
- ENTE_APPS_CAST=${SERVICE_URL_WEB_3004}
- ENTE_APPS_ACCOUNTS=${SERVICE_URL_WEB_3001}
- ENTE_DB_HOST=${ENTE_DB_HOST:-postgres}
- ENTE_DB_PORT=${ENTE_DB_PORT:-5432}
- ENTE_DB_NAME=${ENTE_DB_NAME:-ente_db}
- ENTE_DB_USER=${SERVICE_USER_POSTGRES:-pguser}
- ENTE_DB_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- ENTE_KEY_ENCRYPTION=${SERVICE_REALBASE64_ENCRYPTION}
- ENTE_KEY_HASH=${SERVICE_REALBASE64_64_HASH}
- ENTE_JWT_SECRET=${SERVICE_REALBASE64_JWT}
- ENTE_INTERNAL_ADMIN=${ENTE_INTERNAL_ADMIN:-1580559962386438}
- ENTE_INTERNAL_DISABLE_REGISTRATION=${ENTE_INTERNAL_DISABLE_REGISTRATION:-false}
- ENTE_S3_B2_EU_CEN_ARE_LOCAL_BUCKETS=${PRIMARY_STORAGE_ARE_LOCAL_BUCKETS:-false}
- ENTE_S3_B2_EU_CEN_USE_PATH_STYLE_URLS=${PRIMARY_STORAGE_USE_PATH_STYLE_URLS:-true}
- ENTE_S3_B2_EU_CEN_KEY=${S3_STORAGE_KEY:?}
- ENTE_S3_B2_EU_CEN_SECRET=${S3_STORAGE_SECRET:?}
- ENTE_S3_B2_EU_CEN_ENDPOINT=${S3_STORAGE_ENDPOINT:?}
- ENTE_S3_B2_EU_CEN_REGION=${S3_STORAGE_REGION:-us-east-1}
- ENTE_S3_B2_EU_CEN_BUCKET=${S3_STORAGE_BUCKET:?}
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_started
volumes:
- museum-data:/data
- museum-config:/config
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/ping"]
interval: 30s
timeout: 10s
retries: 3
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/ping"]
interval: 5s
timeout: 5s
retries: 10
web:
image: ghcr.io/ente-io/web
environment:
- SERVICE_URL_WEB_3000
- ENTE_API_ORIGIN=${SERVICE_URL_MUSEUM}
- ENTE_ALBUMS_ORIGIN=${SERVICE_URL_WEB_3002}
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3000"]
interval: 5s
timeout: 5s
retries: 10
postgres:
image: postgres:15-alpine
image: postgres:15
environment:
- POSTGRES_USER=${SERVICE_USER_POSTGRES}
- POSTGRES_USER=${SERVICE_USER_POSTGRES:-pguser}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- POSTGRES_DB=${POSTGRES_DB:-ente_db}
- POSTGRES_DB=${SERVICE_DB_NAME:-ente_db}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${SERVICE_USER_POSTGRES} -d ${POSTGRES_DB:-ente_db}"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: quay.io/minio/minio:latest
environment:
- SERVICE_URL_MINIO_3200
- MINIO_ROOT_USER=${SERVICE_USER_MINIO}
- MINIO_ROOT_PASSWORD=${SERVICE_PASSWORD_MINIO}
command: server /data --address ":3200" --console-address ":3201"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 20s
timeout: 5s
retries: 10
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
environment:
- MINIO_ROOT_USER=${SERVICE_USER_MINIO}
- MINIO_ROOT_PASSWORD=${SERVICE_PASSWORD_MINIO}
entrypoint: >
/bin/sh -c "
mc alias set minio http://minio:3200 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD};
mc mb minio/b2-eu-cen --ignore-existing;
mc mb minio/wasabi-eu-central-2-v3 --ignore-existing;
mc mb minio/scw-eu-fr-v3 --ignore-existing;
echo 'MinIO buckets created successfully';
"

View file

@ -20,7 +20,7 @@ services:
- mariadb-data:/var/lib/mysql
moodle:
image: docker.io/bitnami/moodle:4.3
image: docker.io/bitnamilegacy/moodle:4.3
environment:
- SERVICE_URL_MOODLE_8080
- MOODLE_DATABASE_HOST=mariadb

View file

@ -31,7 +31,7 @@ services:
retries: 15
mongodb:
image: docker.io/bitnami/mongodb:5.0
image: docker.io/bitnamilegacy/mongodb:5.0
volumes:
- mongodb_data:/bitnami/mongodb
environment:

View file

@ -899,6 +899,28 @@
"minversion": "0.0.0",
"port": "80"
},
"elasticsearch-with-kibana": {
"documentation": "https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-kibana-with-docker?utm_source=coolify.io",
"slogan": "Elastic + Kibana is a Free and Open Source Search, Monitoring, and Visualization Stack",
"compose": "c2VydmljZXM6CiAgZWxhc3RpY3NlYXJjaDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGVsYXN0aWNzZWFyY2gKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0VTX0pBVkFfT1BUUz0tWG1zNTEybSAtWG14NTEybScKICAgICAgLSBkaXNjb3ZlcnkudHlwZT1zaW5nbGUtbm9kZQogICAgICAtIGJvb3RzdHJhcC5tZW1vcnlfbG9jaz10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuZW5hYmxlZD10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuaHR0cC5zc2wuZW5hYmxlZD1mYWxzZQogICAgICAtIHhwYWNrLnNlY3VyaXR5LnRyYW5zcG9ydC5zc2wuZW5hYmxlZD1mYWxzZQogICAgdm9sdW1lczoKICAgICAgLSAnL2V0Yy9sb2NhbHRpbWU6L2V0Yy9sb2NhbHRpbWU6cm8nCiAgICAgIC0gJ2VsYXN0aWNzZWFyY2gtZGF0YTovdXNyL3NoYXJlL2VsYXN0aWNzZWFyY2gvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtLXVzZXIgZWxhc3RpYzoke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0gLS1zaWxlbnQgLS1mYWlsIGh0dHA6Ly9sb2NhbGhvc3Q6OTIwMC9fY2x1c3Rlci9oZWFsdGggfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyNAogIGtpYmFuYToKICAgIGltYWdlOiAna2liYW5hOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYQogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0tJQkFOQV81NjAxCiAgICAgIC0gJ1NFUlZFUl9OQU1FPSR7U0VSVklDRV9VUkxfS0lCQU5BfScKICAgICAgLSAnU0VSVkVSX1BVQkxJQ0JBU0VVUkw9JHtTRVJWSUNFX1VSTF9LSUJBTkF9JwogICAgICAtICdFTEFTVElDU0VBUkNIX0hPU1RTPWh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAnCiAgICAgIC0gRUxBU1RJQ1NFQVJDSF9VU0VSTkFNRT1raWJhbmFfc3lzdGVtCiAgICAgIC0gJ0VMQVNUSUNTRUFSQ0hfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tJQkFOQX0nCiAgICAgIC0gJ1hQQUNLX1NFQ1VSSVRZX0VOQ1JZUFRJT05LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1hQQUNLU0VDVVJJVFl9JwogICAgICAtICdYUEFDS19SRVBPUlRJTkdfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tSRVBPUlRJTkd9JwogICAgICAtICdYUEFDS19FTkNSWVBURURTQVZFRE9CSkVDVFNfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tFTkNSWVBURURTQVZFRE9CSkVDVFN9JwogICAgICAtICdURUxFTUVUUllfT1BUSU49JHtURUxFTUVUUllfT1BUSU46LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy9ldGMvbG9jYWx0aW1lOi9ldGMvbG9jYWx0aW1lOnJvJwogICAgICAtICdraWJhbmEtZGF0YTovdXNyL3NoYXJlL2tpYmFuYS9kYXRhJwogICAgZGVwZW5kc19vbjoKICAgICAgc2V0dXA6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtcyBodHRwOi8vbG9jYWxob3N0OjU2MDEvYXBpL3N0YXR1cyB8IGdyZXAgLXEgJycibGV2ZWwiOiJhdmFpbGFibGUiJycgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMjAKICBzZXR1cDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYS1zZXR1cAogICAgZGVwZW5kc19vbjoKICAgICAgZWxhc3RpY3NlYXJjaDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0tJQkFOQV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0lCQU5BfScKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gImVjaG8gXCJTZXR0aW5nIHVwIEtpYmFuYSB1c2VyIHBhc3N3b3JkLi4uXCJcblxudW50aWwgY3VybCAtcyAtdSBcImVsYXN0aWM6JHtFTEFTVElDX1BBU1NXT1JEfVwiIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX2NsdXN0ZXIvaGVhbHRoIHwgZ3JlcCAtcSAnXCJzdGF0dXNcIjpcImdyZWVuXFx8eWVsbG93XCInOyBkb1xuICBlY2hvIFwiV2FpdGluZyBmb3IgRWxhc3RpY3NlYXJjaC4uLlwiXG4gIHNsZWVwIDJcbmRvbmVcblxuZWNobyBcIlNldHRpbmcgcGFzc3dvcmQgZm9yIGtpYmFuYV9zeXN0ZW0gdXNlci4uLlwiXG5jdXJsIC1zIC1YIFBPU1QgLXUgXCJlbGFzdGljOiR7RUxBU1RJQ19QQVNTV09SRH1cIiBcXFxuICAtSCBcIkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvblwiIFxcXG4gIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX3NlY3VyaXR5L3VzZXIva2liYW5hX3N5c3RlbS9fcGFzc3dvcmQgXFxcbiAgLWQgXCJ7XFxcInBhc3N3b3JkXFxcIjpcXFwiJHtLSUJBTkFfUEFTU1dPUkR9XFxcIn1cIiB8fCBleGl0IDFcblxuZWNobyBcIktpYmFuYSBzZXR1cCBjb21wbGV0ZWQgc3VjY2Vzc2Z1bGx5XCIiCiAgICByZXN0YXJ0OiAnbm8nCg==",
"tags": [
"elastic",
"kibana",
"elasticsearch",
"search",
"visualization",
"logging",
"monitoring",
"observability",
"analytics",
"stack",
"devops"
],
"category": null,
"logo": "svgs/elasticsearch.svg",
"minversion": "0.0.0",
"port": "5601"
},
"elasticsearch": {
"documentation": "https://www.elastic.co/products/elasticsearch?utm_source=coolify.io",
"slogan": "Elasticsearch is free and Open Source, Distributed, RESTful Search Engine.",
@ -948,10 +970,29 @@
"minversion": "0.0.0",
"port": "6555"
},
"ente-photos-with-s3": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSBTM19BUkVfTE9DQUxfQlVDS0VUUz10cnVlCiAgICAgIC0gUzNfVVNFX1BBVEhfU1RZTEVfVVJMUz10cnVlCiAgICAgIC0gJ1MzX0IyX0VVX0NFTl9LRVk9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdTM19CMl9FVV9DRU5fU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICAgIC0gJ1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1NFUlZJQ0VfVVJMX01JTklPXzMyMDB9JwogICAgICAtIFMzX0IyX0VVX0NFTl9SRUdJT049ZXUtY2VudHJhbC0yCiAgICAgIC0gUzNfQjJfRVVfQ0VOX0JVQ0tFVD1iMi1ldS1jZW4KICAgIHZvbHVtZXM6CiAgICAgIC0gJ211c2V1bS1kYXRhOi9kYXRhJwogICAgICAtICdtdXNldW0tY29uZmlnOi9jb25maWcnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2Vfc3RhcnRlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIG1pbmlvOgogICAgaW1hZ2U6ICdxdWF5LmlvL21pbmlvL21pbmlvOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01JTklPXzkwMDAKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWFkZHJlc3MgIjo5MDAwIiAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBtaW5pby1pbml0OgogICAgaW1hZ2U6ICdtaW5pby9tYzpsYXRlc3QnCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIHJlc3RhcnQ6ICdubycKICAgIGRlcGVuZHNfb246CiAgICAgIG1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZW50cnlwb2ludDogIi9iaW4vc2ggLWMgXCIgbWMgYWxpYXMgc2V0IG1pbmlvIGh0dHA6Ly9taW5pbzo5MDAwICQke01JTklPX1JPT1RfVVNFUn0gJCR7TUlOSU9fUk9PVF9QQVNTV09SRH07IG1jIG1iIG1pbmlvL2IyLWV1LWNlbiAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vd2FzYWJpLWV1LWNlbnRyYWwtMi12MyAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vc2N3LWV1LWZyLXYzIC0taWdub3JlLWV4aXN0aW5nOyBlY2hvICdNaW5JTyBidWNrZXRzIGNyZWF0ZWQgc3VjY2Vzc2Z1bGx5JzsgXCJcbiIK",
"tags": [
"photos",
"gallery",
"backup",
"encryption",
"privacy",
"self-hosted",
"google-photos",
"alternative"
],
"category": "media",
"logo": "svgs/ente-photos.svg",
"minversion": "0.0.0",
"port": "8080"
},
"ente-photos": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
"compose": "IyBkb2N1bWVudGF0aW9uOiBodHRwczovL2hlbHAuZW50ZS5pby9zZWxmLWhvc3RpbmcvaW5zdGFsbGF0aW9uL2NvbXBvc2UKIyBzbG9nYW46IEVudGUgUGhvdG9zIGlzIGEgZnVsbHkgb3BlbiBzb3VyY2UsIEVuZCB0byBFbmQgRW5jcnlwdGVkIGFsdGVybmF0aXZlIHRvIEdvb2dsZSBQaG90b3MgYW5kIEFwcGxlIFBob3Rvcy4KIyBjYXRlZ29yeTogbWVkaWEKIyB0YWdzOiBwaG90b3MsZ2FsbGVyeSxiYWNrdXAsZW5jcnlwdGlvbixwcml2YWN5LHNlbGYtaG9zdGVkLGdvb2dsZS1waG90b3MsYWx0ZXJuYXRpdmUKIyBsb2dvOiBzdmdzL2VudGUtcGhvdG9zLnN2ZwojIHBvcnQ6IDgwODAKCnNlcnZpY2VzOgogIG11c2V1bToKICAgIGltYWdlOiBnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTVVTRVVNXzgwODAKICAgICAgIyBEYXRhYmFzZSBjb25maWd1cmF0aW9uCiAgICAgIC0gUE9TVEdSRVNfSE9TVD1wb3N0Z3JlcwogICAgICAtIFBPU1RHUkVTX1BPUlQ9NTQzMgogICAgICAtIFBPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWVudGVfZGJ9CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30KICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9CiAgICAgICMgUzMvTWluSU8gY29uZmlndXJhdGlvbgogICAgICAtIFMzX0FSRV9MT0NBTF9CVUNLRVRTPXRydWUKICAgICAgLSBTM19VU0VfUEFUSF9TVFlMRV9VUkxTPXRydWUKICAgICAgLSBTM19CMl9FVV9DRU5fS0VZPSR7U0VSVklDRV9VU0VSX01JTklPfQogICAgICAtIFMzX0IyX0VVX0NFTl9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfQogICAgICAtIFMzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1NFUlZJQ0VfVVJMX01JTklPXzMyMDB9CiAgICAgIC0gUzNfQjJfRVVfQ0VOX1JFR0lPTj1ldS1jZW50cmFsLTIKICAgICAgLSBTM19CMl9FVV9DRU5fQlVDS0VUPWIyLWV1LWNlbgogICAgICAjIFNlY3VyaXR5IGtleXMKICAgICAgLSBFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfRU5DUllQVElPTn0KICAgICAgLSBIQVNIX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfSEFTSH0KICAgICAgLSBKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9KV1R9CiAgICAgICMgQWRtaW4gcGVybWlzc2lvbnMgKGdyYW50cyBmaXJzdCBhY2NvdW50IGFkbWluIGFjY2VzcykKICAgICAgLSBFTlRFX0lOVEVSTkFMX0FETUlOPTE1ODA1NTk5NjIzODY0MzgKICAgICAgIyBBcHAgVVJMcyAob3B0aW9uYWwgLSBmb3Igd2ViIGludGVyZmFjZSkKICAgICAgLSBBUFBTX1BVQkxJQ19BTEJVTVM9JHtBUFBTX1BVQkxJQ19BTEJVTVM6LX0KICAgICAgLSBBUFBTX0NBU1Q9JHtBUFBTX0NBU1Q6LX0KICAgICAgLSBBUFBTX0FDQ09VTlRTPSR7QVBQU19BQ0NPVU5UUzotfQogICAgdm9sdW1lczoKICAgICAgLSBtdXNldW0tZGF0YTovZGF0YQogICAgICAtIG11c2V1bS1jb25maWc6L2NvbmZpZwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiBbIkNNRCIsICJjdXJsIiwgIi1mIiwgImh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nIl0KICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwoKICBwb3N0Z3JlczoKICAgIGltYWdlOiBwb3N0Z3JlczoxNS1hbHBpbmUKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9CiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfQogICAgICAtIFBPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWVudGVfZGJ9CiAgICB2b2x1bWVzOgogICAgICAtIHBvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogWyJDTUQtU0hFTEwiLCAicGdfaXNyZWFkeSAtVSAke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30gLWQgJHtQT1NUR1JFU19EQjotZW50ZV9kYn0iXQogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKCiAgbWluaW86CiAgICBpbWFnZTogcXVheS5pby9taW5pby9taW5pbzpsYXRlc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01JTklPXzMyMDAKICAgICAgLSBNSU5JT19ST09UX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUlOSU99CiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99CiAgICBjb21tYW5kOiBzZXJ2ZXIgL2RhdGEgLS1hZGRyZXNzICI6MzIwMCIgLS1jb25zb2xlLWFkZHJlc3MgIjozMjAxIgogICAgdm9sdW1lczoKICAgICAgLSBtaW5pby1kYXRhOi9kYXRhCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogWyJDTUQiLCAibWMiLCAicmVhZHkiLCAibG9jYWwiXQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCgogIG1pbmlvLWluaXQ6CiAgICBpbWFnZTogbWluaW8vbWM6bGF0ZXN0CiAgICBkZXBlbmRzX29uOgogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfQogICAgICAtIE1JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfQogICAgZW50cnlwb2ludDogPgogICAgICAvYmluL3NoIC1jICIKICAgICAgbWMgYWxpYXMgc2V0IG1pbmlvIGh0dHA6Ly9taW5pbzozMjAwICQke01JTklPX1JPT1RfVVNFUn0gJCR7TUlOSU9fUk9PVF9QQVNTV09SRH07CiAgICAgIG1jIG1iIG1pbmlvL2IyLWV1LWNlbiAtLWlnbm9yZS1leGlzdGluZzsKICAgICAgbWMgbWIgbWluaW8vd2FzYWJpLWV1LWNlbnRyYWwtMi12MyAtLWlnbm9yZS1leGlzdGluZzsKICAgICAgbWMgbWIgbWluaW8vc2N3LWV1LWZyLXYzIC0taWdub3JlLWV4aXN0aW5nOwogICAgICBlY2hvICdNaW5JTyBidWNrZXRzIGNyZWF0ZWQgc3VjY2Vzc2Z1bGx5JzsKICAgICAgIgo=",
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"photos",
"gallery",
@ -2431,7 +2472,7 @@
"moodle": {
"documentation": "https://moodle.org?utm_source=coolify.io",
"slogan": "Moodle is the world\u2019s most customisable and trusted eLearning solution that empowers educators to improve our world.",
"compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQUxMT1dfRU1QVFlfUEFTU1dPUkQ9bm8KICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1iaXRuYW1pX21vb2RsZQogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtIE1BUklBREJfQ0hBUkFDVEVSX1NFVD11dGY4bWI0CiAgICAgIC0gTUFSSUFEQl9DT0xMQVRFPXV0ZjhtYjRfdW5pY29kZV9jaQogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogIG1vb2RsZToKICAgIGltYWdlOiAnZG9ja2VyLmlvL2JpdG5hbWkvbW9vZGxlOjQuMycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01PT0RMRV84MDgwCiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX0hPU1Q9bWFyaWFkYgogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9QT1JUX05VTUJFUj0zMzA2CiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX05BTUU9Yml0bmFtaV9tb29kbGUKICAgICAgLSBNT09ETEVfREFUQUJBU0VfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtIEFMTE9XX0VNUFRZX1BBU1NXT1JEPW5vCiAgICAgIC0gJ01PT0RMRV9VU0VSTkFNRT0ke01PT0RMRV9VU0VSTkFNRTotdXNlcn0nCiAgICAgIC0gTU9PRExFX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01PT0RMRQogICAgICAtIE1PT0RMRV9FTUFJTD11c2VyQGV4YW1wbGUuY29tCiAgICAgIC0gJ01PT0RMRV9TSVRFX05BTUU9JHtNT09ETEVfU0lURV9OQU1FOi1OZXcgU2l0ZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdtb29kbGUtZGF0YTovYml0bmFtaS9tb29kbGUnCiAgICAgIC0gJ21vb2RsZWRhdGEtZGF0YTovYml0bmFtaS9tb29kbGVkYXRhJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCg==",
"compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQUxMT1dfRU1QVFlfUEFTU1dPUkQ9bm8KICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1iaXRuYW1pX21vb2RsZQogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtIE1BUklBREJfQ0hBUkFDVEVSX1NFVD11dGY4bWI0CiAgICAgIC0gTUFSSUFEQl9DT0xMQVRFPXV0ZjhtYjRfdW5pY29kZV9jaQogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogIG1vb2RsZToKICAgIGltYWdlOiAnZG9ja2VyLmlvL2JpdG5hbWlsZWdhY3kvbW9vZGxlOjQuMycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01PT0RMRV84MDgwCiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX0hPU1Q9bWFyaWFkYgogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9QT1JUX05VTUJFUj0zMzA2CiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX05BTUU9Yml0bmFtaV9tb29kbGUKICAgICAgLSBNT09ETEVfREFUQUJBU0VfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtIEFMTE9XX0VNUFRZX1BBU1NXT1JEPW5vCiAgICAgIC0gJ01PT0RMRV9VU0VSTkFNRT0ke01PT0RMRV9VU0VSTkFNRTotdXNlcn0nCiAgICAgIC0gTU9PRExFX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01PT0RMRQogICAgICAtIE1PT0RMRV9FTUFJTD11c2VyQGV4YW1wbGUuY29tCiAgICAgIC0gJ01PT0RMRV9TSVRFX05BTUU9JHtNT09ETEVfU0lURV9OQU1FOi1OZXcgU2l0ZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdtb29kbGUtZGF0YTovYml0bmFtaS9tb29kbGUnCiAgICAgIC0gJ21vb2RsZWRhdGEtZGF0YTovYml0bmFtaS9tb29kbGVkYXRhJwogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCg==",
"tags": [
"moodle",
"elearning",
@ -3408,7 +3449,7 @@
"rocketchat": {
"documentation": "https://github.com/RocketChat/Rocket.Chat?utm_source=coolify.io",
"slogan": "Self-hosted, secure and highly customizable open-source communication platform for organizations with sophisticated security and privacy concerns.",
"compose": "c2VydmljZXM6CiAgcm9ja2V0Y2hhdDoKICAgIGltYWdlOiAncmVnaXN0cnkucm9ja2V0LmNoYXQvcm9ja2V0Y2hhdC9yb2NrZXQuY2hhdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9ST0NLRVRDSEFUXzMwMDAKICAgICAgLSAnTU9OR09fVVJMPW1vbmdvZGI6Ly8ke01PTkdPREJfQURWRVJUSVNFRF9IT1NUTkFNRTotbW9uZ29kYn06JHtNT05HT0RCX0lOSVRJQUxfUFJJTUFSWV9QT1JUX05VTUJFUjotMjcwMTd9LyR7TU9OR09EQl9EQVRBQkFTRTotcm9ja2V0Y2hhdH0/cmVwbGljYVNldD0ke01PTkdPREJfUkVQTElDQV9TRVRfTkFNRTotcnMwfScKICAgICAgLSAnTU9OR09fT1BMT0dfVVJMPW1vbmdvZGI6Ly8ke01PTkdPREJfQURWRVJUSVNFRF9IT1NUTkFNRTotbW9uZ29kYn06JHtNT05HT0RCX0lOSVRJQUxfUFJJTUFSWV9QT1JUX05VTUJFUjotMjcwMTd9L2xvY2FsP3JlcGxpY2FTZXQ9JHtNT05HT0RCX1JFUExJQ0FfU0VUX05BTUU6LXJzMH0nCiAgICAgIC0gUk9PVF9VUkw9JFNFUlZJQ0VfVVJMX1JPQ0tFVENIQVQKICAgICAgLSBERVBMT1lfTUVUSE9EPWRvY2tlcgogICAgICAtIFJFR19UT0tFTj0kUkVHX1RPS0VOCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nb2RiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy0tZXZhbCcKICAgICAgICAtICJjb25zdCBodHRwID0gcmVxdWlyZSgnaHR0cCcpOyBjb25zdCBvcHRpb25zID0geyBob3N0OiAnMC4wLjAuMCcsIHBvcnQ6IDMwMDAsIHRpbWVvdXQ6IDIwMDAsIHBhdGg6ICcvaGVhbHRoJyB9OyBjb25zdCBoZWFsdGhDaGVjayA9IGh0dHAucmVxdWVzdChvcHRpb25zLCAocmVzKSA9PiB7IGNvbnNvbGUubG9nKCdIRUFMVEhDSEVDSyBTVEFUVVM6JywgcmVzLnN0YXR1c0NvZGUpOyBpZiAocmVzLnN0YXR1c0NvZGUgPT0gMjAwKSB7IHByb2Nlc3MuZXhpdCgwKTsgfSBlbHNlIHsgcHJvY2Vzcy5leGl0KDEpOyB9IH0pOyBoZWFsdGhDaGVjay5vbignZXJyb3InLCBmdW5jdGlvbiAoZXJyKSB7IGNvbnNvbGUuZXJyb3IoJ0VSUk9SJyk7IHByb2Nlc3MuZXhpdCgxKTsgfSk7IGhlYWx0aENoZWNrLmVuZCgpOyIKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIG1vbmdvZGI6CiAgICBpbWFnZTogJ2RvY2tlci5pby9iaXRuYW1pL21vbmdvZGI6NS4wJwogICAgdm9sdW1lczoKICAgICAgLSAnbW9uZ29kYl9kYXRhOi9iaXRuYW1pL21vbmdvZGInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNT05HT0RCX1JFUExJQ0FfU0VUX01PREU9cHJpbWFyeQogICAgICAtICdNT05HT0RCX1JFUExJQ0FfU0VUX05BTUU9JHtNT05HT0RCX1JFUExJQ0FfU0VUX05BTUU6LXJzMH0nCiAgICAgIC0gJ01PTkdPREJfUE9SVF9OVU1CRVI9JHtNT05HT0RCX1BPUlRfTlVNQkVSOi0yNzAxN30nCiAgICAgIC0gJ01PTkdPREJfSU5JVElBTF9QUklNQVJZX0hPU1Q9JHtNT05HT0RCX0lOSVRJQUxfUFJJTUFSWV9IT1NUOi1tb25nb2RifScKICAgICAgLSAnTU9OR09EQl9JTklUSUFMX1BSSU1BUllfUE9SVF9OVU1CRVI9JHtNT05HT0RCX0lOSVRJQUxfUFJJTUFSWV9QT1JUX05VTUJFUjotMjcwMTd9JwogICAgICAtICdNT05HT0RCX0FEVkVSVElTRURfSE9TVE5BTUU9JHtNT05HT0RCX0FEVkVSVElTRURfSE9TVE5BTUU6LW1vbmdvZGJ9JwogICAgICAtICdNT05HT0RCX0VOQUJMRV9KT1VSTkFMPSR7TU9OR09EQl9FTkFCTEVfSk9VUk5BTDotdHJ1ZX0nCiAgICAgIC0gJ0FMTE9XX0VNUFRZX1BBU1NXT1JEPSR7QUxMT1dfRU1QVFlfUEFTU1dPUkQ6LXllc30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogImVjaG8gJ2RiLnN0YXRzKCkub2snIHwgbW9uZ28gbG9jYWxob3N0OjI3MDE3L3Rlc3QgLS1xdWlldCIKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=",
"compose": "c2VydmljZXM6CiAgcm9ja2V0Y2hhdDoKICAgIGltYWdlOiAncmVnaXN0cnkucm9ja2V0LmNoYXQvcm9ja2V0Y2hhdC9yb2NrZXQuY2hhdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9ST0NLRVRDSEFUXzMwMDAKICAgICAgLSAnTU9OR09fVVJMPW1vbmdvZGI6Ly8ke01PTkdPREJfQURWRVJUSVNFRF9IT1NUTkFNRTotbW9uZ29kYn06JHtNT05HT0RCX0lOSVRJQUxfUFJJTUFSWV9QT1JUX05VTUJFUjotMjcwMTd9LyR7TU9OR09EQl9EQVRBQkFTRTotcm9ja2V0Y2hhdH0/cmVwbGljYVNldD0ke01PTkdPREJfUkVQTElDQV9TRVRfTkFNRTotcnMwfScKICAgICAgLSAnTU9OR09fT1BMT0dfVVJMPW1vbmdvZGI6Ly8ke01PTkdPREJfQURWRVJUSVNFRF9IT1NUTkFNRTotbW9uZ29kYn06JHtNT05HT0RCX0lOSVRJQUxfUFJJTUFSWV9QT1JUX05VTUJFUjotMjcwMTd9L2xvY2FsP3JlcGxpY2FTZXQ9JHtNT05HT0RCX1JFUExJQ0FfU0VUX05BTUU6LXJzMH0nCiAgICAgIC0gUk9PVF9VUkw9JFNFUlZJQ0VfVVJMX1JPQ0tFVENIQVQKICAgICAgLSBERVBMT1lfTUVUSE9EPWRvY2tlcgogICAgICAtIFJFR19UT0tFTj0kUkVHX1RPS0VOCiAgICBkZXBlbmRzX29uOgogICAgICBtb25nb2RiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy0tZXZhbCcKICAgICAgICAtICJjb25zdCBodHRwID0gcmVxdWlyZSgnaHR0cCcpOyBjb25zdCBvcHRpb25zID0geyBob3N0OiAnMC4wLjAuMCcsIHBvcnQ6IDMwMDAsIHRpbWVvdXQ6IDIwMDAsIHBhdGg6ICcvaGVhbHRoJyB9OyBjb25zdCBoZWFsdGhDaGVjayA9IGh0dHAucmVxdWVzdChvcHRpb25zLCAocmVzKSA9PiB7IGNvbnNvbGUubG9nKCdIRUFMVEhDSEVDSyBTVEFUVVM6JywgcmVzLnN0YXR1c0NvZGUpOyBpZiAocmVzLnN0YXR1c0NvZGUgPT0gMjAwKSB7IHByb2Nlc3MuZXhpdCgwKTsgfSBlbHNlIHsgcHJvY2Vzcy5leGl0KDEpOyB9IH0pOyBoZWFsdGhDaGVjay5vbignZXJyb3InLCBmdW5jdGlvbiAoZXJyKSB7IGNvbnNvbGUuZXJyb3IoJ0VSUk9SJyk7IHByb2Nlc3MuZXhpdCgxKTsgfSk7IGhlYWx0aENoZWNrLmVuZCgpOyIKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIG1vbmdvZGI6CiAgICBpbWFnZTogJ2RvY2tlci5pby9iaXRuYW1pbGVnYWN5L21vbmdvZGI6NS4wJwogICAgdm9sdW1lczoKICAgICAgLSAnbW9uZ29kYl9kYXRhOi9iaXRuYW1pL21vbmdvZGInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNT05HT0RCX1JFUExJQ0FfU0VUX01PREU9cHJpbWFyeQogICAgICAtICdNT05HT0RCX1JFUExJQ0FfU0VUX05BTUU9JHtNT05HT0RCX1JFUExJQ0FfU0VUX05BTUU6LXJzMH0nCiAgICAgIC0gJ01PTkdPREJfUE9SVF9OVU1CRVI9JHtNT05HT0RCX1BPUlRfTlVNQkVSOi0yNzAxN30nCiAgICAgIC0gJ01PTkdPREJfSU5JVElBTF9QUklNQVJZX0hPU1Q9JHtNT05HT0RCX0lOSVRJQUxfUFJJTUFSWV9IT1NUOi1tb25nb2RifScKICAgICAgLSAnTU9OR09EQl9JTklUSUFMX1BSSU1BUllfUE9SVF9OVU1CRVI9JHtNT05HT0RCX0lOSVRJQUxfUFJJTUFSWV9QT1JUX05VTUJFUjotMjcwMTd9JwogICAgICAtICdNT05HT0RCX0FEVkVSVElTRURfSE9TVE5BTUU9JHtNT05HT0RCX0FEVkVSVElTRURfSE9TVE5BTUU6LW1vbmdvZGJ9JwogICAgICAtICdNT05HT0RCX0VOQUJMRV9KT1VSTkFMPSR7TU9OR09EQl9FTkFCTEVfSk9VUk5BTDotdHJ1ZX0nCiAgICAgIC0gJ0FMTE9XX0VNUFRZX1BBU1NXT1JEPSR7QUxMT1dfRU1QVFlfUEFTU1dPUkQ6LXllc30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogImVjaG8gJ2RiLnN0YXRzKCkub2snIHwgbW9uZ28gbG9jYWxob3N0OjI3MDE3L3Rlc3QgLS1xdWlldCIKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=",
"tags": [
"rocketchat",
"chat",

View file

@ -899,6 +899,28 @@
"minversion": "0.0.0",
"port": "80"
},
"elasticsearch-with-kibana": {
"documentation": "https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-kibana-with-docker?utm_source=coolify.io",
"slogan": "Elastic + Kibana is a Free and Open Source Search, Monitoring, and Visualization Stack",
"compose": "c2VydmljZXM6CiAgZWxhc3RpY3NlYXJjaDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGVsYXN0aWNzZWFyY2gKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0VTX0pBVkFfT1BUUz0tWG1zNTEybSAtWG14NTEybScKICAgICAgLSBkaXNjb3ZlcnkudHlwZT1zaW5nbGUtbm9kZQogICAgICAtIGJvb3RzdHJhcC5tZW1vcnlfbG9jaz10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuZW5hYmxlZD10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuaHR0cC5zc2wuZW5hYmxlZD1mYWxzZQogICAgICAtIHhwYWNrLnNlY3VyaXR5LnRyYW5zcG9ydC5zc2wuZW5hYmxlZD1mYWxzZQogICAgdm9sdW1lczoKICAgICAgLSAnL2V0Yy9sb2NhbHRpbWU6L2V0Yy9sb2NhbHRpbWU6cm8nCiAgICAgIC0gJ2VsYXN0aWNzZWFyY2gtZGF0YTovdXNyL3NoYXJlL2VsYXN0aWNzZWFyY2gvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtLXVzZXIgZWxhc3RpYzoke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0gLS1zaWxlbnQgLS1mYWlsIGh0dHA6Ly9sb2NhbGhvc3Q6OTIwMC9fY2x1c3Rlci9oZWFsdGggfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyNAogIGtpYmFuYToKICAgIGltYWdlOiAna2liYW5hOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYQogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9LSUJBTkFfNTYwMQogICAgICAtICdTRVJWRVJfTkFNRT0ke1NFUlZJQ0VfRlFETl9LSUJBTkF9JwogICAgICAtICdTRVJWRVJfUFVCTElDQkFTRVVSTD0ke1NFUlZJQ0VfRlFETl9LSUJBTkF9JwogICAgICAtICdFTEFTVElDU0VBUkNIX0hPU1RTPWh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAnCiAgICAgIC0gRUxBU1RJQ1NFQVJDSF9VU0VSTkFNRT1raWJhbmFfc3lzdGVtCiAgICAgIC0gJ0VMQVNUSUNTRUFSQ0hfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tJQkFOQX0nCiAgICAgIC0gJ1hQQUNLX1NFQ1VSSVRZX0VOQ1JZUFRJT05LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1hQQUNLU0VDVVJJVFl9JwogICAgICAtICdYUEFDS19SRVBPUlRJTkdfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tSRVBPUlRJTkd9JwogICAgICAtICdYUEFDS19FTkNSWVBURURTQVZFRE9CSkVDVFNfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tFTkNSWVBURURTQVZFRE9CSkVDVFN9JwogICAgICAtICdURUxFTUVUUllfT1BUSU49JHtURUxFTUVUUllfT1BUSU46LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy9ldGMvbG9jYWx0aW1lOi9ldGMvbG9jYWx0aW1lOnJvJwogICAgICAtICdraWJhbmEtZGF0YTovdXNyL3NoYXJlL2tpYmFuYS9kYXRhJwogICAgZGVwZW5kc19vbjoKICAgICAgc2V0dXA6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtcyBodHRwOi8vbG9jYWxob3N0OjU2MDEvYXBpL3N0YXR1cyB8IGdyZXAgLXEgJycibGV2ZWwiOiJhdmFpbGFibGUiJycgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMjAKICBzZXR1cDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYS1zZXR1cAogICAgZGVwZW5kc19vbjoKICAgICAgZWxhc3RpY3NlYXJjaDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0tJQkFOQV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0lCQU5BfScKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gImVjaG8gXCJTZXR0aW5nIHVwIEtpYmFuYSB1c2VyIHBhc3N3b3JkLi4uXCJcblxudW50aWwgY3VybCAtcyAtdSBcImVsYXN0aWM6JHtFTEFTVElDX1BBU1NXT1JEfVwiIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX2NsdXN0ZXIvaGVhbHRoIHwgZ3JlcCAtcSAnXCJzdGF0dXNcIjpcImdyZWVuXFx8eWVsbG93XCInOyBkb1xuICBlY2hvIFwiV2FpdGluZyBmb3IgRWxhc3RpY3NlYXJjaC4uLlwiXG4gIHNsZWVwIDJcbmRvbmVcblxuZWNobyBcIlNldHRpbmcgcGFzc3dvcmQgZm9yIGtpYmFuYV9zeXN0ZW0gdXNlci4uLlwiXG5jdXJsIC1zIC1YIFBPU1QgLXUgXCJlbGFzdGljOiR7RUxBU1RJQ19QQVNTV09SRH1cIiBcXFxuICAtSCBcIkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvblwiIFxcXG4gIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX3NlY3VyaXR5L3VzZXIva2liYW5hX3N5c3RlbS9fcGFzc3dvcmQgXFxcbiAgLWQgXCJ7XFxcInBhc3N3b3JkXFxcIjpcXFwiJHtLSUJBTkFfUEFTU1dPUkR9XFxcIn1cIiB8fCBleGl0IDFcblxuZWNobyBcIktpYmFuYSBzZXR1cCBjb21wbGV0ZWQgc3VjY2Vzc2Z1bGx5XCIiCiAgICByZXN0YXJ0OiAnbm8nCg==",
"tags": [
"elastic",
"kibana",
"elasticsearch",
"search",
"visualization",
"logging",
"monitoring",
"observability",
"analytics",
"stack",
"devops"
],
"category": null,
"logo": "svgs/elasticsearch.svg",
"minversion": "0.0.0",
"port": "5601"
},
"elasticsearch": {
"documentation": "https://www.elastic.co/products/elasticsearch?utm_source=coolify.io",
"slogan": "Elasticsearch is free and Open Source, Distributed, RESTful Search Engine.",
@ -948,10 +970,29 @@
"minversion": "0.0.0",
"port": "6555"
},
"ente-photos-with-s3": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gUzNfQVJFX0xPQ0FMX0JVQ0tFVFM9dHJ1ZQogICAgICAtIFMzX1VTRV9QQVRIX1NUWUxFX1VSTFM9dHJ1ZQogICAgICAtICdTM19CMl9FVV9DRU5fS0VZPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtICdTM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTRVJWSUNFX0ZRRE5fTUlOSU9fMzIwMH0nCiAgICAgIC0gUzNfQjJfRVVfQ0VOX1JFR0lPTj1ldS1jZW50cmFsLTIKICAgICAgLSBTM19CMl9FVV9DRU5fQlVDS0VUPWIyLWV1LWNlbgogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xTy0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwL3BpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHdlYjoKICAgIGltYWdlOiBnaGNyLmlvL2VudGUtaW8vd2ViCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9GUUROX01VU0VVTX0nCiAgICAgIC0gJ0VOVEVfQUxCVU1TX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9XRUJfMzAwMn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy0tZmFpbCcKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNS1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgbWluaW86CiAgICBpbWFnZTogJ3F1YXkuaW8vbWluaW8vbWluaW86bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX01JTklPXzkwMDAKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWFkZHJlc3MgIjo5MDAwIiAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBtaW5pby1pbml0OgogICAgaW1hZ2U6ICdtaW5pby9tYzpsYXRlc3QnCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIHJlc3RhcnQ6ICdubycKICAgIGRlcGVuZHNfb246CiAgICAgIG1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZW50cnlwb2ludDogIi9iaW4vc2ggLWMgXCIgbWMgYWxpYXMgc2V0IG1pbmlvIGh0dHA6Ly9taW5pbzo5MDAwICQke01JTklPX1JPT1RfVVNFUn0gJCR7TUlOSU9fUk9PVF9QQVNTV09SRH07IG1jIG1iIG1pbmlvL2IyLWV1LWNlbiAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vd2FzYWJpLWV1LWNlbnRyYWwtMi12MyAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vc2N3LWV1LWZyLXYzIC0taWdub3JlLWV4aXN0aW5nOyBlY2hvICdNaW5JTyBidWNrZXRzIGNyZWF0ZWQgc3VjY2Vzc2Z1bGx5JzsgXCJcbiIK",
"tags": [
"photos",
"gallery",
"backup",
"encryption",
"privacy",
"self-hosted",
"google-photos",
"alternative"
],
"category": "media",
"logo": "svgs/ente-photos.svg",
"minversion": "0.0.0",
"port": "8080"
},
"ente-photos": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
"compose": "IyBkb2N1bWVudGF0aW9uOiBodHRwczovL2hlbHAuZW50ZS5pby9zZWxmLWhvc3RpbmcvaW5zdGFsbGF0aW9uL2NvbXBvc2UKIyBzbG9nYW46IEVudGUgUGhvdG9zIGlzIGEgZnVsbHkgb3BlbiBzb3VyY2UsIEVuZCB0byBFbmQgRW5jcnlwdGVkIGFsdGVybmF0aXZlIHRvIEdvb2dsZSBQaG90b3MgYW5kIEFwcGxlIFBob3Rvcy4KIyBjYXRlZ29yeTogbWVkaWEKIyB0YWdzOiBwaG90b3MsZ2FsbGVyeSxiYWNrdXAsZW5jcnlwdGlvbixwcml2YWN5LHNlbGYtaG9zdGVkLGdvb2dsZS1waG90b3MsYWx0ZXJuYXRpdmUKIyBsb2dvOiBzdmdzL2VudGUtcGhvdG9zLnN2ZwojIHBvcnQ6IDgwODAKCnNlcnZpY2VzOgogIG11c2V1bToKICAgIGltYWdlOiBnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTVVTRVVNXzgwODAKICAgICAgIyBEYXRhYmFzZSBjb25maWd1cmF0aW9uCiAgICAgIC0gUE9TVEdSRVNfSE9TVD1wb3N0Z3JlcwogICAgICAtIFBPU1RHUkVTX1BPUlQ9NTQzMgogICAgICAtIFBPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWVudGVfZGJ9CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30KICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9CiAgICAgICMgUzMvTWluSU8gY29uZmlndXJhdGlvbgogICAgICAtIFMzX0FSRV9MT0NBTF9CVUNLRVRTPXRydWUKICAgICAgLSBTM19VU0VfUEFUSF9TVFlMRV9VUkxTPXRydWUKICAgICAgLSBTM19CMl9FVV9DRU5fS0VZPSR7U0VSVklDRV9VU0VSX01JTklPfQogICAgICAtIFMzX0IyX0VVX0NFTl9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfQogICAgICAtIFMzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1NFUlZJQ0VfVVJMX01JTklPXzMyMDB9CiAgICAgIC0gUzNfQjJfRVVfQ0VOX1JFR0lPTj1ldS1jZW50cmFsLTIKICAgICAgLSBTM19CMl9FVV9DRU5fQlVDS0VUPWIyLWV1LWNlbgogICAgICAjIFNlY3VyaXR5IGtleXMKICAgICAgLSBFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfRU5DUllQVElPTn0KICAgICAgLSBIQVNIX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfSEFTSH0KICAgICAgLSBKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9KV1R9CiAgICAgICMgQWRtaW4gcGVybWlzc2lvbnMgKGdyYW50cyBmaXJzdCBhY2NvdW50IGFkbWluIGFjY2VzcykKICAgICAgLSBFTlRFX0lOVEVSTkFMX0FETUlOPTE1ODA1NTk5NjIzODY0MzgKICAgICAgIyBBcHAgVVJMcyAob3B0aW9uYWwgLSBmb3Igd2ViIGludGVyZmFjZSkKICAgICAgLSBBUFBTX1BVQkxJQ19BTEJVTVM9JHtBUFBTX1BVQkxJQ19BTEJVTVM6LX0KICAgICAgLSBBUFBTX0NBU1Q9JHtBUFBTX0NBU1Q6LX0KICAgICAgLSBBUFBTX0FDQ09VTlRTPSR7QVBQU19BQ0NPVU5UUzotfQogICAgdm9sdW1lczoKICAgICAgLSBtdXNldW0tZGF0YTovZGF0YQogICAgICAtIG11c2V1bS1jb25maWc6L2NvbmZpZwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiBbIkNNRCIsICJjdXJsIiwgIi1mIiwgImh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nIl0KICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwoKICBwb3N0Z3JlczoKICAgIGltYWdlOiBwb3N0Z3JlczoxNS1hbHBpbmUKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9CiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfQogICAgICAtIFBPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWVudGVfZGJ9CiAgICB2b2x1bWVzOgogICAgICAtIHBvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogWyJDTUQtU0hFTEwiLCAicGdfaXNyZWFkeSAtVSAke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30gLWQgJHtQT1NUR1JFU19EQjotZW50ZV9kYn0iXQogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKCiAgbWluaW86CiAgICBpbWFnZTogcXVheS5pby9taW5pby9taW5pbzpsYXRlc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01JTklPXzMyMDAKICAgICAgLSBNSU5JT19ST09UX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUlOSU99CiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99CiAgICBjb21tYW5kOiBzZXJ2ZXIgL2RhdGEgLS1hZGRyZXNzICI6MzIwMCIgLS1jb25zb2xlLWFkZHJlc3MgIjozMjAxIgogICAgdm9sdW1lczoKICAgICAgLSBtaW5pby1kYXRhOi9kYXRhCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogWyJDTUQiLCAibWMiLCAicmVhZHkiLCAibG9jYWwiXQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCgogIG1pbmlvLWluaXQ6CiAgICBpbWFnZTogbWluaW8vbWM6bGF0ZXN0CiAgICBkZXBlbmRzX29uOgogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfQogICAgICAtIE1JTklPX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfQogICAgZW50cnlwb2ludDogPgogICAgICAvYmluL3NoIC1jICIKICAgICAgbWMgYWxpYXMgc2V0IG1pbmlvIGh0dHA6Ly9taW5pbzozMjAwICQke01JTklPX1JPT1RfVVNFUn0gJCR7TUlOSU9fUk9PVF9QQVNTV09SRH07CiAgICAgIG1jIG1iIG1pbmlvL2IyLWV1LWNlbiAtLWlnbm9yZS1leGlzdGluZzsKICAgICAgbWMgbWIgbWluaW8vd2FzYWJpLWV1LWNlbnRyYWwtMi12MyAtLWlnbm9yZS1leGlzdGluZzsKICAgICAgbWMgbWIgbWluaW8vc2N3LWV1LWZyLXYzIC0taWdub3JlLWV4aXN0aW5nOwogICAgICBlY2hvICdNaW5JTyBidWNrZXRzIGNyZWF0ZWQgc3VjY2Vzc2Z1bGx5JzsKICAgICAgIgo=",
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0FSRV9MT0NBTF9CVUNLRVRTPSR7UFJJTUFSWV9TVE9SQUdFX0FSRV9MT0NBTF9CVUNLRVRTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1VTRV9QQVRIX1NUWUxFX1VSTFM9JHtQUklNQVJZX1NUT1JBR0VfVVNFX1BBVEhfU1RZTEVfVVJMUzotdHJ1ZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0tFWT0ke1MzX1NUT1JBR0VfS0VZOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9TRUNSRVQ9JHtTM19TVE9SQUdFX1NFQ1JFVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTM19TVE9SQUdFX0VORFBPSU5UOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9SRUdJT049JHtTM19TVE9SQUdFX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQlVDS0VUPSR7UzNfU1RPUkFHRV9CVUNLRVQ6P30nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODAvcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdoY3IuaW8vZW50ZS1pby93ZWIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9XRUJfMzAwMAogICAgICAtICdFTlRFX0FQSV9PUklHSU49JHtTRVJWSUNFX0ZRRE5fTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLS1mYWlsJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7U0VSVklDRV9EQl9OQU1FOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=",
"tags": [
"photos",
"gallery",
@ -2431,7 +2472,7 @@
"moodle": {
"documentation": "https://moodle.org?utm_source=coolify.io",
"slogan": "Moodle is the world\u2019s most customisable and trusted eLearning solution that empowers educators to improve our world.",
"compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQUxMT1dfRU1QVFlfUEFTU1dPUkQ9bm8KICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1iaXRuYW1pX21vb2RsZQogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtIE1BUklBREJfQ0hBUkFDVEVSX1NFVD11dGY4bWI0CiAgICAgIC0gTUFSSUFEQl9DT0xMQVRFPXV0ZjhtYjRfdW5pY29kZV9jaQogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogIG1vb2RsZToKICAgIGltYWdlOiAnZG9ja2VyLmlvL2JpdG5hbWkvbW9vZGxlOjQuMycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NT09ETEVfODA4MAogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9IT1NUPW1hcmlhZGIKICAgICAgLSBNT09ETEVfREFUQUJBU0VfUE9SVF9OVU1CRVI9MzMwNgogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9OQU1FPWJpdG5hbWlfbW9vZGxlCiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSBBTExPV19FTVBUWV9QQVNTV09SRD1ubwogICAgICAtICdNT09ETEVfVVNFUk5BTUU9JHtNT09ETEVfVVNFUk5BTUU6LXVzZXJ9JwogICAgICAtIE1PT0RMRV9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NT09ETEUKICAgICAgLSBNT09ETEVfRU1BSUw9dXNlckBleGFtcGxlLmNvbQogICAgICAtICdNT09ETEVfU0lURV9OQU1FPSR7TU9PRExFX1NJVEVfTkFNRTotTmV3IFNpdGV9JwogICAgdm9sdW1lczoKICAgICAgLSAnbW9vZGxlLWRhdGE6L2JpdG5hbWkvbW9vZGxlJwogICAgICAtICdtb29kbGVkYXRhLWRhdGE6L2JpdG5hbWkvbW9vZGxlZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbWFyaWFkYgo=",
"compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQUxMT1dfRU1QVFlfUEFTU1dPUkQ9bm8KICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1iaXRuYW1pX21vb2RsZQogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtIE1BUklBREJfQ0hBUkFDVEVSX1NFVD11dGY4bWI0CiAgICAgIC0gTUFSSUFEQl9DT0xMQVRFPXV0ZjhtYjRfdW5pY29kZV9jaQogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogIG1vb2RsZToKICAgIGltYWdlOiAnZG9ja2VyLmlvL2JpdG5hbWlsZWdhY3kvbW9vZGxlOjQuMycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NT09ETEVfODA4MAogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9IT1NUPW1hcmlhZGIKICAgICAgLSBNT09ETEVfREFUQUJBU0VfUE9SVF9OVU1CRVI9MzMwNgogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9OQU1FPWJpdG5hbWlfbW9vZGxlCiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSBBTExPV19FTVBUWV9QQVNTV09SRD1ubwogICAgICAtICdNT09ETEVfVVNFUk5BTUU9JHtNT09ETEVfVVNFUk5BTUU6LXVzZXJ9JwogICAgICAtIE1PT0RMRV9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NT09ETEUKICAgICAgLSBNT09ETEVfRU1BSUw9dXNlckBleGFtcGxlLmNvbQogICAgICAtICdNT09ETEVfU0lURV9OQU1FPSR7TU9PRExFX1NJVEVfTkFNRTotTmV3IFNpdGV9JwogICAgdm9sdW1lczoKICAgICAgLSAnbW9vZGxlLWRhdGE6L2JpdG5hbWkvbW9vZGxlJwogICAgICAtICdtb29kbGVkYXRhLWRhdGE6L2JpdG5hbWkvbW9vZGxlZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbWFyaWFkYgo=",
"tags": [
"moodle",
"elearning",
@ -3408,7 +3449,7 @@
"rocketchat": {
"documentation": "https://github.com/RocketChat/Rocket.Chat?utm_source=coolify.io",
"slogan": "Self-hosted, secure and highly customizable open-source communication platform for organizations with sophisticated security and privacy concerns.",
"compose": "c2VydmljZXM6CiAgcm9ja2V0Y2hhdDoKICAgIGltYWdlOiAncmVnaXN0cnkucm9ja2V0LmNoYXQvcm9ja2V0Y2hhdC9yb2NrZXQuY2hhdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fUk9DS0VUQ0hBVF8zMDAwCiAgICAgIC0gJ01PTkdPX1VSTD1tb25nb2RiOi8vJHtNT05HT0RCX0FEVkVSVElTRURfSE9TVE5BTUU6LW1vbmdvZGJ9OiR7TU9OR09EQl9JTklUSUFMX1BSSU1BUllfUE9SVF9OVU1CRVI6LTI3MDE3fS8ke01PTkdPREJfREFUQUJBU0U6LXJvY2tldGNoYXR9P3JlcGxpY2FTZXQ9JHtNT05HT0RCX1JFUExJQ0FfU0VUX05BTUU6LXJzMH0nCiAgICAgIC0gJ01PTkdPX09QTE9HX1VSTD1tb25nb2RiOi8vJHtNT05HT0RCX0FEVkVSVElTRURfSE9TVE5BTUU6LW1vbmdvZGJ9OiR7TU9OR09EQl9JTklUSUFMX1BSSU1BUllfUE9SVF9OVU1CRVI6LTI3MDE3fS9sb2NhbD9yZXBsaWNhU2V0PSR7TU9OR09EQl9SRVBMSUNBX1NFVF9OQU1FOi1yczB9JwogICAgICAtIFJPT1RfVVJMPSRTRVJWSUNFX0ZRRE5fUk9DS0VUQ0hBVAogICAgICAtIERFUExPWV9NRVRIT0Q9ZG9ja2VyCiAgICAgIC0gUkVHX1RPS0VOPSRSRUdfVE9LRU4KICAgIGRlcGVuZHNfb246CiAgICAgIG1vbmdvZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLS1ldmFsJwogICAgICAgIC0gImNvbnN0IGh0dHAgPSByZXF1aXJlKCdodHRwJyk7IGNvbnN0IG9wdGlvbnMgPSB7IGhvc3Q6ICcwLjAuMC4wJywgcG9ydDogMzAwMCwgdGltZW91dDogMjAwMCwgcGF0aDogJy9oZWFsdGgnIH07IGNvbnN0IGhlYWx0aENoZWNrID0gaHR0cC5yZXF1ZXN0KG9wdGlvbnMsIChyZXMpID0+IHsgY29uc29sZS5sb2coJ0hFQUxUSENIRUNLIFNUQVRVUzonLCByZXMuc3RhdHVzQ29kZSk7IGlmIChyZXMuc3RhdHVzQ29kZSA9PSAyMDApIHsgcHJvY2Vzcy5leGl0KDApOyB9IGVsc2UgeyBwcm9jZXNzLmV4aXQoMSk7IH0gfSk7IGhlYWx0aENoZWNrLm9uKCdlcnJvcicsIGZ1bmN0aW9uIChlcnIpIHsgY29uc29sZS5lcnJvcignRVJST1InKTsgcHJvY2Vzcy5leGl0KDEpOyB9KTsgaGVhbHRoQ2hlY2suZW5kKCk7IgogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbW9uZ29kYjoKICAgIGltYWdlOiAnZG9ja2VyLmlvL2JpdG5hbWkvbW9uZ29kYjo1LjAnCiAgICB2b2x1bWVzOgogICAgICAtICdtb25nb2RiX2RhdGE6L2JpdG5hbWkvbW9uZ29kYicKICAgIGVudmlyb25tZW50OgogICAgICAtIE1PTkdPREJfUkVQTElDQV9TRVRfTU9ERT1wcmltYXJ5CiAgICAgIC0gJ01PTkdPREJfUkVQTElDQV9TRVRfTkFNRT0ke01PTkdPREJfUkVQTElDQV9TRVRfTkFNRTotcnMwfScKICAgICAgLSAnTU9OR09EQl9QT1JUX05VTUJFUj0ke01PTkdPREJfUE9SVF9OVU1CRVI6LTI3MDE3fScKICAgICAgLSAnTU9OR09EQl9JTklUSUFMX1BSSU1BUllfSE9TVD0ke01PTkdPREJfSU5JVElBTF9QUklNQVJZX0hPU1Q6LW1vbmdvZGJ9JwogICAgICAtICdNT05HT0RCX0lOSVRJQUxfUFJJTUFSWV9QT1JUX05VTUJFUj0ke01PTkdPREJfSU5JVElBTF9QUklNQVJZX1BPUlRfTlVNQkVSOi0yNzAxN30nCiAgICAgIC0gJ01PTkdPREJfQURWRVJUSVNFRF9IT1NUTkFNRT0ke01PTkdPREJfQURWRVJUSVNFRF9IT1NUTkFNRTotbW9uZ29kYn0nCiAgICAgIC0gJ01PTkdPREJfRU5BQkxFX0pPVVJOQUw9JHtNT05HT0RCX0VOQUJMRV9KT1VSTkFMOi10cnVlfScKICAgICAgLSAnQUxMT1dfRU1QVFlfUEFTU1dPUkQ9JHtBTExPV19FTVBUWV9QQVNTV09SRDoteWVzfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAiZWNobyAnZGIuc3RhdHMoKS5vaycgfCBtb25nbyBsb2NhbGhvc3Q6MjcwMTcvdGVzdCAtLXF1aWV0IgogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==",
"compose": "c2VydmljZXM6CiAgcm9ja2V0Y2hhdDoKICAgIGltYWdlOiAncmVnaXN0cnkucm9ja2V0LmNoYXQvcm9ja2V0Y2hhdC9yb2NrZXQuY2hhdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fUk9DS0VUQ0hBVF8zMDAwCiAgICAgIC0gJ01PTkdPX1VSTD1tb25nb2RiOi8vJHtNT05HT0RCX0FEVkVSVElTRURfSE9TVE5BTUU6LW1vbmdvZGJ9OiR7TU9OR09EQl9JTklUSUFMX1BSSU1BUllfUE9SVF9OVU1CRVI6LTI3MDE3fS8ke01PTkdPREJfREFUQUJBU0U6LXJvY2tldGNoYXR9P3JlcGxpY2FTZXQ9JHtNT05HT0RCX1JFUExJQ0FfU0VUX05BTUU6LXJzMH0nCiAgICAgIC0gJ01PTkdPX09QTE9HX1VSTD1tb25nb2RiOi8vJHtNT05HT0RCX0FEVkVSVElTRURfSE9TVE5BTUU6LW1vbmdvZGJ9OiR7TU9OR09EQl9JTklUSUFMX1BSSU1BUllfUE9SVF9OVU1CRVI6LTI3MDE3fS9sb2NhbD9yZXBsaWNhU2V0PSR7TU9OR09EQl9SRVBMSUNBX1NFVF9OQU1FOi1yczB9JwogICAgICAtIFJPT1RfVVJMPSRTRVJWSUNFX0ZRRE5fUk9DS0VUQ0hBVAogICAgICAtIERFUExPWV9NRVRIT0Q9ZG9ja2VyCiAgICAgIC0gUkVHX1RPS0VOPSRSRUdfVE9LRU4KICAgIGRlcGVuZHNfb246CiAgICAgIG1vbmdvZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLS1ldmFsJwogICAgICAgIC0gImNvbnN0IGh0dHAgPSByZXF1aXJlKCdodHRwJyk7IGNvbnN0IG9wdGlvbnMgPSB7IGhvc3Q6ICcwLjAuMC4wJywgcG9ydDogMzAwMCwgdGltZW91dDogMjAwMCwgcGF0aDogJy9oZWFsdGgnIH07IGNvbnN0IGhlYWx0aENoZWNrID0gaHR0cC5yZXF1ZXN0KG9wdGlvbnMsIChyZXMpID0+IHsgY29uc29sZS5sb2coJ0hFQUxUSENIRUNLIFNUQVRVUzonLCByZXMuc3RhdHVzQ29kZSk7IGlmIChyZXMuc3RhdHVzQ29kZSA9PSAyMDApIHsgcHJvY2Vzcy5leGl0KDApOyB9IGVsc2UgeyBwcm9jZXNzLmV4aXQoMSk7IH0gfSk7IGhlYWx0aENoZWNrLm9uKCdlcnJvcicsIGZ1bmN0aW9uIChlcnIpIHsgY29uc29sZS5lcnJvcignRVJST1InKTsgcHJvY2Vzcy5leGl0KDEpOyB9KTsgaGVhbHRoQ2hlY2suZW5kKCk7IgogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbW9uZ29kYjoKICAgIGltYWdlOiAnZG9ja2VyLmlvL2JpdG5hbWlsZWdhY3kvbW9uZ29kYjo1LjAnCiAgICB2b2x1bWVzOgogICAgICAtICdtb25nb2RiX2RhdGE6L2JpdG5hbWkvbW9uZ29kYicKICAgIGVudmlyb25tZW50OgogICAgICAtIE1PTkdPREJfUkVQTElDQV9TRVRfTU9ERT1wcmltYXJ5CiAgICAgIC0gJ01PTkdPREJfUkVQTElDQV9TRVRfTkFNRT0ke01PTkdPREJfUkVQTElDQV9TRVRfTkFNRTotcnMwfScKICAgICAgLSAnTU9OR09EQl9QT1JUX05VTUJFUj0ke01PTkdPREJfUE9SVF9OVU1CRVI6LTI3MDE3fScKICAgICAgLSAnTU9OR09EQl9JTklUSUFMX1BSSU1BUllfSE9TVD0ke01PTkdPREJfSU5JVElBTF9QUklNQVJZX0hPU1Q6LW1vbmdvZGJ9JwogICAgICAtICdNT05HT0RCX0lOSVRJQUxfUFJJTUFSWV9QT1JUX05VTUJFUj0ke01PTkdPREJfSU5JVElBTF9QUklNQVJZX1BPUlRfTlVNQkVSOi0yNzAxN30nCiAgICAgIC0gJ01PTkdPREJfQURWRVJUSVNFRF9IT1NUTkFNRT0ke01PTkdPREJfQURWRVJUSVNFRF9IT1NUTkFNRTotbW9uZ29kYn0nCiAgICAgIC0gJ01PTkdPREJfRU5BQkxFX0pPVVJOQUw9JHtNT05HT0RCX0VOQUJMRV9KT1VSTkFMOi10cnVlfScKICAgICAgLSAnQUxMT1dfRU1QVFlfUEFTU1dPUkQ9JHtBTExPV19FTVBUWV9QQVNTV09SRDoteWVzfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAiZWNobyAnZGIuc3RhdHMoKS5vaycgfCBtb25nbyBsb2NhbGhvc3Q6MjcwMTcvdGVzdCAtLXF1aWV0IgogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==",
"tags": [
"rocketchat",
"chat",

View file

@ -0,0 +1,37 @@
<?php
use App\Models\ScheduledDatabaseBackupExecution;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
test('scheduled_database_backup_executions table has s3_uploaded column', function () {
expect(Schema::hasColumn('scheduled_database_backup_executions', 's3_uploaded'))->toBeTrue();
});
test('s3_uploaded column is nullable', function () {
$columns = Schema::getColumns('scheduled_database_backup_executions');
$s3UploadedColumn = collect($columns)->firstWhere('name', 's3_uploaded');
expect($s3UploadedColumn)->not->toBeNull();
expect($s3UploadedColumn['nullable'])->toBeTrue();
});
test('scheduled database backup execution model casts s3_uploaded correctly', function () {
$model = new ScheduledDatabaseBackupExecution;
$casts = $model->getCasts();
expect($casts)->toHaveKey('s3_uploaded');
expect($casts['s3_uploaded'])->toBe('boolean');
});
test('scheduled database backup execution model casts storage deletion fields correctly', function () {
$model = new ScheduledDatabaseBackupExecution;
$casts = $model->getCasts();
expect($casts)->toHaveKey('local_storage_deleted');
expect($casts['local_storage_deleted'])->toBe('boolean');
expect($casts)->toHaveKey('s3_storage_deleted');
expect($casts['s3_storage_deleted'])->toBe('boolean');
});

View file

@ -1,94 +1,109 @@
<?php
namespace Tests\Unit;
use App\Services\DockerImageParser;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
class DockerImageParserTest extends TestCase
{
private DockerImageParser $parser;
it('parses regular image with tag', function () {
$parser = new DockerImageParser;
$parser->parse('nginx:latest');
protected function setUp(): void
{
parent::setUp();
$this->parser = new DockerImageParser;
expect($parser->getImageName())->toBe('nginx')
->and($parser->getTag())->toBe('latest')
->and($parser->isImageHash())->toBeFalse()
->and($parser->toString())->toBe('nginx:latest');
});
it('parses image with sha256 hash using colon format', function () {
$parser = new DockerImageParser;
$hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0';
$parser->parse("ghcr.io/benjaminehowe/rail-disruptions:{$hash}");
expect($parser->getFullImageNameWithoutTag())->toBe('ghcr.io/benjaminehowe/rail-disruptions')
->and($parser->getTag())->toBe($hash)
->and($parser->isImageHash())->toBeTrue()
->and($parser->toString())->toBe("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}")
->and($parser->getFullImageNameWithHash())->toBe("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}");
});
it('parses image with sha256 hash using at sign format', function () {
$parser = new DockerImageParser;
$hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0';
$parser->parse("nginx@sha256:{$hash}");
expect($parser->getImageName())->toBe('nginx')
->and($parser->getTag())->toBe($hash)
->and($parser->isImageHash())->toBeTrue()
->and($parser->toString())->toBe("nginx@sha256:{$hash}")
->and($parser->getFullImageNameWithHash())->toBe("nginx@sha256:{$hash}");
});
it('parses registry image with hash', function () {
$parser = new DockerImageParser;
$hash = 'abc123def456789abcdef123456789abcdef123456789abcdef123456789abc1';
$parser->parse("docker.io/library/nginx:{$hash}");
expect($parser->getFullImageNameWithoutTag())->toBe('docker.io/library/nginx')
->and($parser->getTag())->toBe($hash)
->and($parser->isImageHash())->toBeTrue()
->and($parser->toString())->toBe("docker.io/library/nginx@sha256:{$hash}");
});
it('parses image without tag defaults to latest', function () {
$parser = new DockerImageParser;
$parser->parse('nginx');
expect($parser->getImageName())->toBe('nginx')
->and($parser->getTag())->toBe('latest')
->and($parser->isImageHash())->toBeFalse()
->and($parser->toString())->toBe('nginx:latest');
});
it('parses registry with port', function () {
$parser = new DockerImageParser;
$parser->parse('registry.example.com:5000/myapp:latest');
expect($parser->getFullImageNameWithoutTag())->toBe('registry.example.com:5000/myapp')
->and($parser->getTag())->toBe('latest')
->and($parser->isImageHash())->toBeFalse();
});
it('parses registry with port and hash', function () {
$parser = new DockerImageParser;
$hash = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
$parser->parse("registry.example.com:5000/myapp:{$hash}");
expect($parser->getFullImageNameWithoutTag())->toBe('registry.example.com:5000/myapp')
->and($parser->getTag())->toBe($hash)
->and($parser->isImageHash())->toBeTrue()
->and($parser->toString())->toBe("registry.example.com:5000/myapp@sha256:{$hash}");
});
it('identifies valid sha256 hashes', function () {
$validHashes = [
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0',
'1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
];
foreach ($validHashes as $hash) {
$parser = new DockerImageParser;
$parser->parse("image:{$hash}");
expect($parser->isImageHash())->toBeTrue("Hash {$hash} should be recognized as valid SHA256");
}
});
#[Test]
public function it_parses_simple_image_name()
{
$this->parser->parse('nginx');
it('identifies invalid sha256 hashes', function () {
$invalidHashes = [
'latest',
'v1.2.3',
'abc123', // too short
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf', // too short
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf00', // too long
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cfg0', // invalid char
];
$this->assertEquals('', $this->parser->getRegistryUrl());
$this->assertEquals('nginx', $this->parser->getImageName());
$this->assertEquals('latest', $this->parser->getTag());
foreach ($invalidHashes as $hash) {
$parser = new DockerImageParser;
$parser->parse("image:{$hash}");
expect($parser->isImageHash())->toBeFalse("Hash {$hash} should not be recognized as valid SHA256");
}
#[Test]
public function it_parses_image_with_tag()
{
$this->parser->parse('nginx:1.19');
$this->assertEquals('', $this->parser->getRegistryUrl());
$this->assertEquals('nginx', $this->parser->getImageName());
$this->assertEquals('1.19', $this->parser->getTag());
}
#[Test]
public function it_parses_image_with_organization()
{
$this->parser->parse('coollabs/coolify:latest');
$this->assertEquals('', $this->parser->getRegistryUrl());
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
$this->assertEquals('latest', $this->parser->getTag());
}
#[Test]
public function it_parses_image_with_registry_url()
{
$this->parser->parse('ghcr.io/coollabs/coolify:v4');
$this->assertEquals('ghcr.io', $this->parser->getRegistryUrl());
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
$this->assertEquals('v4', $this->parser->getTag());
}
#[Test]
public function it_parses_image_with_port_in_registry()
{
$this->parser->parse('localhost:5000/my-app:dev');
$this->assertEquals('localhost:5000', $this->parser->getRegistryUrl());
$this->assertEquals('my-app', $this->parser->getImageName());
$this->assertEquals('dev', $this->parser->getTag());
}
#[Test]
public function it_parses_image_without_tag()
{
$this->parser->parse('ghcr.io/coollabs/coolify');
$this->assertEquals('ghcr.io', $this->parser->getRegistryUrl());
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
$this->assertEquals('latest', $this->parser->getTag());
}
#[Test]
public function it_converts_back_to_string()
{
$originalString = 'ghcr.io/coollabs/coolify:v4';
$this->parser->parse($originalString);
$this->assertEquals($originalString, $this->parser->toString());
}
#[Test]
public function it_converts_to_string_with_default_tag()
{
$this->parser->parse('nginx');
$this->assertEquals('nginx:latest', $this->parser->toString());
}
}
});

View file

@ -0,0 +1,137 @@
<?php
use App\Enums\ProxyTypes;
use Symfony\Component\Yaml\Yaml;
it('extracts custom proxy commands from existing traefik configuration', function () {
// Create a sample config with custom trustedIPs commands
$existingConfig = [
'services' => [
'traefik' => [
'command' => [
'--ping=true',
'--api.dashboard=true',
'--entrypoints.http.address=:80',
'--entrypoints.https.address=:443',
'--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22',
'--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22',
'--providers.docker=true',
'--providers.docker.exposedbydefault=false',
],
],
],
];
$yamlConfig = Yaml::dump($existingConfig);
// Mock a server with Traefik proxy type
$server = Mockery::mock('App\Models\Server');
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value);
$customCommands = extractCustomProxyCommands($server, $yamlConfig);
expect($customCommands)
->toBeArray()
->toHaveCount(2)
->toContain('--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22')
->toContain('--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22');
});
it('returns empty array when only default commands exist', function () {
// Config with only default commands
$existingConfig = [
'services' => [
'traefik' => [
'command' => [
'--ping=true',
'--api.dashboard=true',
'--entrypoints.http.address=:80',
'--entrypoints.https.address=:443',
'--providers.docker=true',
'--providers.docker.exposedbydefault=false',
],
],
],
];
$yamlConfig = Yaml::dump($existingConfig);
$server = Mockery::mock('App\Models\Server');
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value);
$customCommands = extractCustomProxyCommands($server, $yamlConfig);
expect($customCommands)->toBeArray()->toBeEmpty();
});
it('handles invalid yaml gracefully', function () {
$invalidYaml = 'this is not: valid: yaml::: content';
$server = Mockery::mock('App\Models\Server');
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value);
$customCommands = extractCustomProxyCommands($server, $invalidYaml);
expect($customCommands)->toBeArray()->toBeEmpty();
});
it('returns empty array for caddy proxy type', function () {
$existingConfig = [
'services' => [
'caddy' => [
'environment' => ['SOME_VAR=value'],
],
],
];
$yamlConfig = Yaml::dump($existingConfig);
$server = Mockery::mock('App\Models\Server');
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::CADDY->value);
$customCommands = extractCustomProxyCommands($server, $yamlConfig);
expect($customCommands)->toBeArray()->toBeEmpty();
});
it('returns empty array when config is empty', function () {
$server = Mockery::mock('App\Models\Server');
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value);
$customCommands = extractCustomProxyCommands($server, '');
expect($customCommands)->toBeArray()->toBeEmpty();
});
it('correctly identifies multiple custom command types', function () {
$existingConfig = [
'services' => [
'traefik' => [
'command' => [
'--ping=true',
'--api.dashboard=true',
'--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20',
'--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20',
'--entrypoints.http.forwardedHeaders.insecure=true',
'--metrics.prometheus=true',
'--providers.docker=true',
],
],
],
];
$yamlConfig = Yaml::dump($existingConfig);
$server = Mockery::mock('App\Models\Server');
$server->shouldReceive('proxyType')->andReturn(ProxyTypes::TRAEFIK->value);
$customCommands = extractCustomProxyCommands($server, $yamlConfig);
expect($customCommands)
->toBeArray()
->toHaveCount(4)
->toContain('--entrypoints.http.forwardedHeaders.trustedIPs=173.245.48.0/20')
->toContain('--entrypoints.https.forwardedHeaders.trustedIPs=173.245.48.0/20')
->toContain('--entrypoints.http.forwardedHeaders.insecure=true')
->toContain('--metrics.prometheus=true');
});

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.434"
"version": "4.0.0-beta.435"
},
"nightly": {
"version": "4.0.0-beta.435"
"version": "4.0.0-beta.436"
},
"helper": {
"version": "1.0.11"