diff --git a/.ai/design-system.md b/.ai/design-system.md
deleted file mode 100644
index d22adf3c6..000000000
--- a/.ai/design-system.md
+++ /dev/null
@@ -1,1666 +0,0 @@
-# Coolify Design System
-
-> **Purpose**: AI/LLM-consumable reference for replicating Coolify's visual design in new applications. Contains design tokens, component styles, and interactive states — with both Tailwind CSS classes and plain CSS equivalents.
-
----
-
-## 1. Design Tokens
-
-### 1.1 Colors
-
-#### Brand / Accent
-
-| Token | Hex | Usage |
-|---|---|---|
-| `coollabs` | `#6b16ed` | Primary accent (light mode) |
-| `coollabs-50` | `#f5f0ff` | Highlighted button bg (light) |
-| `coollabs-100` | `#7317ff` | Highlighted button hover (dark) |
-| `coollabs-200` | `#5a12c7` | Highlighted button text (light) |
-| `coollabs-300` | `#4a0fa3` | Deepest brand shade |
-| `warning` / `warning-400` | `#fcd452` | Primary accent (dark mode) |
-
-#### Warning Scale (used for dark-mode accent + callouts)
-
-| Token | Hex |
-|---|---|
-| `warning-50` | `#fefce8` |
-| `warning-100` | `#fef9c3` |
-| `warning-200` | `#fef08a` |
-| `warning-300` | `#fde047` |
-| `warning-400` | `#fcd452` |
-| `warning-500` | `#facc15` |
-| `warning-600` | `#ca8a04` |
-| `warning-700` | `#a16207` |
-| `warning-800` | `#854d0e` |
-| `warning-900` | `#713f12` |
-
-#### Neutral Grays (dark mode backgrounds)
-
-| Token | Hex | Usage |
-|---|---|---|
-| `base` | `#101010` | Page background (dark) |
-| `coolgray-100` | `#181818` | Component background (dark) |
-| `coolgray-200` | `#202020` | Elevated surface / borders (dark) |
-| `coolgray-300` | `#242424` | Input border shadow / hover (dark) |
-| `coolgray-400` | `#282828` | Tooltip background (dark) |
-| `coolgray-500` | `#323232` | Subtle hover overlays (dark) |
-
-#### Semantic
-
-| Token | Hex | Usage |
-|---|---|---|
-| `success` | `#22C55E` | Running status, success alerts |
-| `error` | `#dc2626` | Stopped status, danger actions, error alerts |
-
-#### Light Mode Defaults
-
-| Element | Color |
-|---|---|
-| Page background | `gray-50` (`#f9fafb`) |
-| Component background | `white` (`#ffffff`) |
-| Borders | `neutral-200` (`#e5e5e5`) |
-| Primary text | `black` (`#000000`) |
-| Muted text | `neutral-500` (`#737373`) |
-| Placeholder text | `neutral-300` (`#d4d4d4`) |
-
-### 1.2 Typography
-
-**Font family**: Inter, sans-serif (weights 100–900, woff2, `font-display: swap`)
-
-#### Heading Hierarchy
-
-> **CRITICAL**: All headings and titles (h1–h4, card titles, modal titles) MUST be `white` (`#fff`) in dark mode. The default body text color is `neutral-400` (`#a3a3a3`) — headings must override this to white or they will be nearly invisible on dark backgrounds.
-
-| Element | Tailwind | Plain CSS (light) | Plain CSS (dark) |
-|---|---|---|---|
-| `h1` | `text-3xl font-bold dark:text-white` | `font-size: 1.875rem; font-weight: 700; color: #000;` | `color: #fff;` |
-| `h2` | `text-xl font-bold dark:text-white` | `font-size: 1.25rem; font-weight: 700; color: #000;` | `color: #fff;` |
-| `h3` | `text-lg font-bold dark:text-white` | `font-size: 1.125rem; font-weight: 700; color: #000;` | `color: #fff;` |
-| `h4` | `text-base font-bold dark:text-white` | `font-size: 1rem; font-weight: 700; color: #000;` | `color: #fff;` |
-
-#### Body Text
-
-| Context | Tailwind | Plain CSS |
-|---|---|---|
-| Body default | `text-sm antialiased` | `font-size: 0.875rem; line-height: 1.25rem; -webkit-font-smoothing: antialiased;` |
-| Labels | `text-sm font-medium` | `font-size: 0.875rem; font-weight: 500;` |
-| Badge/status text | `text-xs font-bold` | `font-size: 0.75rem; line-height: 1rem; font-weight: 700;` |
-| Box description | `text-xs font-bold text-neutral-500` | `font-size: 0.75rem; font-weight: 700; color: #737373;` |
-
-### 1.3 Spacing Patterns
-
-| Context | Value | CSS |
-|---|---|---|
-| Component internal padding | `p-2` | `padding: 0.5rem;` |
-| Callout padding | `p-4` | `padding: 1rem;` |
-| Input vertical padding | `py-1.5` | `padding-top: 0.375rem; padding-bottom: 0.375rem;` |
-| Button height | `h-8` | `height: 2rem;` |
-| Button horizontal padding | `px-2` | `padding-left: 0.5rem; padding-right: 0.5rem;` |
-| Button gap | `gap-2` | `gap: 0.5rem;` |
-| Menu item padding | `px-2 py-1` | `padding: 0.25rem 0.5rem;` |
-| Menu item gap | `gap-3` | `gap: 0.75rem;` |
-| Section margin | `mb-12` | `margin-bottom: 3rem;` |
-| Card min-height | `min-h-[4rem]` | `min-height: 4rem;` |
-
-### 1.4 Border Radius
-
-| Context | Tailwind | Plain CSS |
-|---|---|---|
-| Default (inputs, buttons, cards, modals) | `rounded-sm` | `border-radius: 0.125rem;` |
-| Callouts | `rounded-lg` | `border-radius: 0.5rem;` |
-| Badges | `rounded-full` | `border-radius: 9999px;` |
-| Cards (coolbox variant) | `rounded` | `border-radius: 0.25rem;` |
-
-### 1.5 Shadows
-
-#### Input / Select Box-Shadow System
-
-Coolify uses **inset box-shadows instead of borders** for inputs and selects. This enables a unique "dirty indicator" — a colored left-edge bar.
-
-```css
-/* Default state */
-box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5;
-
-/* Default state (dark) */
-box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424;
-
-/* Focus state (light) — purple left bar */
-box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
-
-/* Focus state (dark) — yellow left bar */
-box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
-
-/* Dirty (modified) state — same as focus */
-box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; /* light */
-box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; /* dark */
-
-/* Disabled / Readonly */
-box-shadow: none;
-```
-
-#### Input-Sticky Variant (thinner border)
-
-```css
-/* Uses 1px border instead of 2px */
-box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #e5e5e5;
-```
-
-### 1.6 Focus Ring System
-
-All interactive elements (buttons, links, checkboxes) share this focus pattern:
-
-**Tailwind:**
-```
-focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base
-```
-
-**Plain CSS:**
-```css
-:focus-visible {
- outline: none;
- box-shadow: 0 0 0 2px #101010, 0 0 0 4px #6b16ed; /* light */
-}
-
-/* dark mode */
-.dark :focus-visible {
- box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452;
-}
-```
-
-> **Note**: Inputs use the inset box-shadow system (section 1.5) instead of the ring system.
-
----
-
-## 2. Dark Mode Strategy
-
-- **Toggle method**: Class-based — `.dark` class on `` element
-- **CSS variant**: `@custom-variant dark (&:where(.dark, .dark *));`
-- **Default border override**: All elements default to `border-color: var(--color-coolgray-200)` (`#202020`) instead of `currentcolor`
-
-### Accent Color Swap
-
-| Context | Light | Dark |
-|---|---|---|
-| Primary accent | `coollabs` (`#6b16ed`) | `warning` (`#fcd452`) |
-| Focus ring | `ring-coollabs` | `ring-warning` |
-| Input focus bar | `#6b16ed` (purple) | `#fcd452` (yellow) |
-| Active nav text | `text-black` | `text-warning` |
-| Helper/highlight text | `text-coollabs` | `text-warning` |
-| Loading spinner | `text-coollabs` | `text-warning` |
-| Scrollbar thumb | `coollabs-100` | `coollabs-100` |
-
-### Background Hierarchy (dark)
-
-```
-#101010 (base) — page background
- └─ #181818 (coolgray-100) — cards, inputs, components
- └─ #202020 (coolgray-200) — elevated surfaces, borders, nav active
- └─ #242424 (coolgray-300) — input borders (via box-shadow), button borders
- └─ #282828 (coolgray-400) — tooltips, hover states
- └─ #323232 (coolgray-500) — subtle overlays
-```
-
-### Background Hierarchy (light)
-
-```
-#f9fafb (gray-50) — page background
- └─ #ffffff (white) — cards, inputs, components
- └─ #e5e5e5 (neutral-200) — borders
- └─ #f5f5f5 (neutral-100) — hover backgrounds
- └─ #d4d4d4 (neutral-300) — deeper hover, nav active
-```
-
----
-
-## 3. Component Catalog
-
-### 3.1 Button
-
-#### Default
-
-**Tailwind:**
-```
-flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm
-border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100
-dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200
-dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit
-dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent
-disabled:bg-transparent disabled:text-neutral-300
-focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs
-dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base
-```
-
-**Plain CSS:**
-```css
-.button {
- display: flex;
- gap: 0.5rem;
- justify-content: center;
- align-items: center;
- padding: 0 0.5rem;
- height: 2rem;
- font-size: 0.875rem;
- font-weight: 500;
- text-transform: none;
- color: #000;
- background: #fff;
- border: 2px solid #e5e5e5;
- border-radius: 0.125rem;
- outline: 0;
- cursor: pointer;
- min-width: fit-content;
-}
-.button:hover { background: #f5f5f5; }
-
-/* Dark */
-.dark .button {
- background: #181818;
- color: #fff;
- border-color: #242424;
-}
-.dark .button:hover {
- background: #202020;
- color: #fff;
-}
-
-/* Disabled */
-.button:disabled {
- cursor: not-allowed;
- border-color: transparent;
- background: transparent;
- color: #d4d4d4;
-}
-.dark .button:disabled { color: #525252; }
-```
-
-#### Highlighted (Primary Action)
-
-**Tailwind** (via `isHighlighted` attribute):
-```
-text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20
-border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white
-dark:hover:bg-coollabs-100 dark:hover:text-white
-```
-
-**Plain CSS:**
-```css
-.button-highlighted {
- color: #5a12c7;
- background: #f5f0ff;
- border-color: #6b16ed;
-}
-.button-highlighted:hover {
- background: #6b16ed;
- color: #fff;
-}
-.dark .button-highlighted {
- color: #fff;
- background: rgba(107, 22, 237, 0.2);
- border-color: #7317ff;
-}
-.dark .button-highlighted:hover {
- background: #7317ff;
- color: #fff;
-}
-```
-
-#### Error / Danger
-
-**Tailwind** (via `isError` attribute):
-```
-text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30
-border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white
-dark:hover:bg-red-800 dark:hover:text-white
-```
-
-**Plain CSS:**
-```css
-.button-error {
- color: #991b1b;
- background: #fef2f2;
- border-color: #fca5a5;
-}
-.button-error:hover {
- background: #fca5a5;
- color: #fff;
-}
-.dark .button-error {
- color: #fca5a5;
- background: rgba(127, 29, 29, 0.3);
- border-color: #991b1b;
-}
-.dark .button-error:hover {
- background: #991b1b;
- color: #fff;
-}
-```
-
-#### Loading Indicator
-
-Buttons automatically show a spinner (SVG with `animate-spin`) next to their content during async operations. The spinner uses the accent color (`text-coollabs` / `text-warning`).
-
----
-
-### 3.2 Input
-
-**Tailwind:**
-```
-block py-1.5 w-full text-sm text-black rounded-sm border-0
-dark:bg-coolgray-100 dark:text-white
-disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40
-dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40
-placeholder:text-neutral-300 dark:placeholder:text-neutral-700
-read-only:text-neutral-500 read-only:bg-neutral-200
-focus-visible:outline-none
-```
-
-**Plain CSS:**
-```css
-.input {
- display: block;
- padding: 0.375rem 0.5rem;
- width: 100%;
- font-size: 0.875rem;
- color: #000;
- background: #fff;
- border: 0;
- border-radius: 0.125rem;
- box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5;
-}
-.input:focus-visible {
- outline: none;
- box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
-}
-.input::placeholder { color: #d4d4d4; }
-.input:disabled { background: #e5e5e5; color: #737373; box-shadow: none; }
-.input:read-only { color: #737373; background: #e5e5e5; box-shadow: none; }
-.input[type="password"] { padding-right: 2.4rem; }
-
-/* Dark */
-.dark .input {
- background: #181818;
- color: #fff;
- box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424;
-}
-.dark .input:focus-visible {
- box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
-}
-.dark .input::placeholder { color: #404040; }
-.dark .input:disabled { background: rgba(24, 24, 24, 0.4); box-shadow: none; }
-.dark .input:read-only { color: #737373; background: rgba(24, 24, 24, 0.4); box-shadow: none; }
-```
-
-#### Dirty (Modified) State
-
-When an input value has been changed but not saved, a 4px colored left bar appears via box-shadow — same colors as focus state. This provides a visual indicator that the field has unsaved changes.
-
----
-
-### 3.3 Select
-
-Same base styles as Input, plus a custom dropdown arrow SVG:
-
-**Tailwind:**
-```
-w-full block py-1.5 text-sm text-black rounded-sm border-0
-dark:bg-coolgray-100 dark:text-white
-disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40
-focus-visible:outline-none
-```
-
-**Additional plain CSS for the dropdown arrow:**
-```css
-.select {
- /* ...same as .input base... */
- background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23000000'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e");
- background-position: right 0.5rem center;
- background-repeat: no-repeat;
- background-size: 1rem 1rem;
- padding-right: 2.5rem;
- appearance: none;
-}
-
-/* Dark mode: white stroke arrow */
-.dark .select {
- background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e");
-}
-```
-
----
-
-### 3.4 Checkbox
-
-**Tailwind:**
-```
-dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer
-dark:disabled:bg-base dark:disabled:cursor-not-allowed
-focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs
-dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base
-```
-
-**Container:**
-```
-flex flex-row items-center gap-4 pr-2 py-1 form-control min-w-fit
-dark:hover:bg-coolgray-100 cursor-pointer
-```
-
-**Plain CSS:**
-```css
-.checkbox {
- border-color: #404040;
- color: #282828;
- background: #181818;
- border-radius: 0.125rem;
- cursor: pointer;
-}
-.checkbox:focus-visible {
- outline: none;
- box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452;
-}
-
-.checkbox-container {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: 1rem;
- padding: 0.25rem 0.5rem 0.25rem 0;
- min-width: fit-content;
- cursor: pointer;
-}
-.dark .checkbox-container:hover { background: #181818; }
-```
-
----
-
-### 3.5 Textarea
-
-Uses `font-mono` for monospace text. Supports tab key insertion (2 spaces).
-
-**Important**: Large/multiline textareas should NOT use the inset box-shadow left-border system from `.input`. Use a simple border instead:
-
-**Tailwind:**
-```
-block w-full text-sm text-black rounded-sm border border-neutral-200
-dark:bg-coolgray-100 dark:text-white dark:border-coolgray-300
-font-mono focus-visible:outline-none focus-visible:ring-2
-focus-visible:ring-coollabs dark:focus-visible:ring-warning
-focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base
-```
-
-**Plain CSS:**
-```css
-.textarea {
- display: block;
- width: 100%;
- font-size: 0.875rem;
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
- color: #000;
- background: #fff;
- border: 1px solid #e5e5e5;
- border-radius: 0.125rem;
-}
-.textarea:focus-visible {
- outline: none;
- box-shadow: 0 0 0 2px #fff, 0 0 0 4px #6b16ed;
-}
-.dark .textarea {
- background: #181818;
- color: #fff;
- border-color: #242424;
-}
-.dark .textarea:focus-visible {
- box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452;
-}
-```
-
-> **Note**: The 4px inset left-border (dirty/focus indicator) is only for single-line inputs and selects, not textareas.
-
----
-
-### 3.6 Box / Card
-
-#### Standard Box
-
-**Tailwind:**
-```
-relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem]
-dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black
-border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100
-dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm
-```
-
-**Plain CSS:**
-```css
-.box {
- position: relative;
- display: flex;
- flex-direction: column;
- padding: 0.5rem;
- min-height: 4rem;
- background: #fff;
- border: 1px solid #e5e5e5;
- border-radius: 0.125rem;
- box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
- color: #000;
- cursor: pointer;
- transition: background-color 150ms, color 150ms;
- text-decoration: none;
-}
-.box:hover { background: #f5f5f5; color: #000; }
-
-.dark .box {
- background: #181818;
- border-color: #242424;
- color: #fff;
-}
-.dark .box:hover {
- background: #7317ff;
- color: #fff;
-}
-
-/* IMPORTANT: child text must also turn white/black on hover,
- since description text (#737373) is invisible on purple bg */
-.box:hover .box-title { color: #000; }
-.box:hover .box-description { color: #000; }
-.dark .box:hover .box-title { color: #fff; }
-.dark .box:hover .box-description { color: #fff; }
-
-/* Desktop: row layout */
-@media (min-width: 1024px) {
- .box { flex-direction: row; }
-}
-```
-
-#### Coolbox (Ring Hover)
-
-**Tailwind:**
-```
-relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded
-border border-neutral-200 dark:border-coolgray-400 hover:ring-2
-dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem]
-```
-
-**Plain CSS:**
-```css
-.coolbox {
- position: relative;
- display: flex;
- padding: 0.5rem;
- min-height: 4rem;
- background: #fff;
- border: 1px solid #e5e5e5;
- border-radius: 0.25rem;
- cursor: pointer;
- transition: all 150ms;
-}
-.coolbox:hover { box-shadow: 0 0 0 2px #6b16ed; }
-
-.dark .coolbox {
- background: #181818;
- border-color: #282828;
-}
-.dark .coolbox:hover { box-shadow: 0 0 0 2px #fcd452; }
-```
-
-#### Box Text
-
-> **IMPORTANT — Dark mode titles**: Card/box titles MUST be `#fff` (white) in dark mode, not the default body text color (`#a3a3a3` / neutral-400). A black or grey title is nearly invisible on dark backgrounds (`#181818`). This applies to all heading-level text inside cards.
-
-```css
-.box-title {
- font-weight: 700;
- color: #000; /* light mode: black */
-}
-.dark .box-title {
- color: #fff; /* dark mode: MUST be white, not grey */
-}
-
-.box-description {
- font-size: 0.75rem;
- font-weight: 700;
- color: #737373;
-}
-/* On hover: description must become visible against colored bg */
-.box:hover .box-description { color: #000; }
-.dark .box:hover .box-description { color: #fff; }
-```
-
----
-
-### 3.7 Badge / Status Indicator
-
-**Tailwind:**
-```
-inline-block w-3 h-3 text-xs font-bold rounded-full leading-none
-border border-neutral-200 dark:border-black
-```
-
-**Variants**: `badge-success` (`bg-success`), `badge-warning` (`bg-warning`), `badge-error` (`bg-error`)
-
-**Plain CSS:**
-```css
-.badge {
- display: inline-block;
- width: 0.75rem;
- height: 0.75rem;
- border-radius: 9999px;
- border: 1px solid #e5e5e5;
-}
-.dark .badge { border-color: #000; }
-
-.badge-success { background: #22C55E; }
-.badge-warning { background: #fcd452; }
-.badge-error { background: #dc2626; }
-```
-
-#### Status Text Pattern
-
-Status indicators combine a badge dot with text:
-
-```html
-
-```
-
-| Status | Badge Class | Text Color |
-|---|---|---|
-| Running | `badge-success` | `text-success` (`#22C55E`) |
-| Stopped | `badge-error` | `text-error` (`#dc2626`) |
-| Degraded | `badge-warning` | `dark:text-warning` (`#fcd452`) |
-| Restarting | `badge-warning` | `dark:text-warning` (`#fcd452`) |
-
----
-
-### 3.8 Dropdown
-
-**Container Tailwind:**
-```
-p-1 mt-1 bg-white border rounded-sm shadow-sm
-dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300
-```
-
-**Item Tailwind:**
-```
-flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs
-transition-colors cursor-pointer select-none dark:text-white
-hover:bg-neutral-100 dark:hover:bg-coollabs
-outline-none focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs
-```
-
-**Plain CSS:**
-```css
-.dropdown {
- padding: 0.25rem;
- margin-top: 0.25rem;
- background: #fff;
- border: 1px solid #d4d4d4;
- border-radius: 0.125rem;
- box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
-}
-.dark .dropdown {
- background: #202020;
- border-color: #242424;
-}
-
-.dropdown-item {
- display: flex;
- position: relative;
- gap: 0.5rem;
- justify-content: flex-start;
- align-items: center;
- padding: 0.25rem 1rem 0.25rem 0.5rem;
- width: 100%;
- font-size: 0.75rem;
- cursor: pointer;
- user-select: none;
- transition: background-color 150ms;
-}
-.dropdown-item:hover { background: #f5f5f5; }
-.dark .dropdown-item { color: #fff; }
-.dark .dropdown-item:hover { background: #6b16ed; }
-```
-
----
-
-### 3.9 Sidebar / Navigation
-
-#### Sidebar Container + Page Layout
-
-The navbar is a **fixed left sidebar** (14rem / 224px wide on desktop), with main content offset to the right.
-
-**Tailwind (sidebar wrapper — desktop):**
-```
-hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col min-w-0
-```
-
-**Tailwind (sidebar inner — scrollable):**
-```
-flex flex-col overflow-y-auto grow gap-y-5 scrollbar min-w-0
-```
-
-**Tailwind (nav element):**
-```
-flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base
-```
-
-**Tailwind (main content area):**
-```
-lg:pl-56
-```
-
-**Tailwind (main content padding):**
-```
-p-4 sm:px-6 lg:px-8 lg:py-6
-```
-
-**Tailwind (mobile top bar — shown on small screens, hidden on lg+):**
-```
-sticky top-0 z-40 flex items-center justify-between px-4 py-4 gap-x-6 sm:px-6 lg:hidden
-bg-white/95 dark:bg-base/95 backdrop-blur-sm border-b border-neutral-300/50 dark:border-coolgray-200/50
-```
-
-**Tailwind (mobile hamburger icon):**
-```
--m-2.5 p-2.5 dark:text-warning
-```
-
-**Plain CSS:**
-```css
-/* Sidebar — desktop only */
-.sidebar {
- display: none;
-}
-@media (min-width: 1024px) {
- .sidebar {
- display: flex;
- flex-direction: column;
- position: fixed;
- top: 0;
- bottom: 0;
- left: 0;
- z-index: 50;
- width: 14rem; /* 224px */
- min-width: 0;
- }
-}
-
-.sidebar-inner {
- display: flex;
- flex-direction: column;
- flex-grow: 1;
- overflow-y: auto;
- gap: 1.25rem;
- min-width: 0;
-}
-
-/* Nav element */
-.sidebar-nav {
- display: flex;
- flex-direction: column;
- flex: 1;
- padding: 0 0.5rem;
- background: #fff;
- border-right: 1px solid #d4d4d4;
-}
-.dark .sidebar-nav {
- background: #101010;
- border-right-color: #202020;
-}
-
-/* Main content offset */
-@media (min-width: 1024px) {
- .main-content { padding-left: 14rem; }
-}
-
-.main-content-inner {
- padding: 1rem;
-}
-@media (min-width: 640px) {
- .main-content-inner { padding: 1rem 1.5rem; }
-}
-@media (min-width: 1024px) {
- .main-content-inner { padding: 1.5rem 2rem; }
-}
-
-/* Mobile top bar — visible below lg breakpoint */
-.mobile-topbar {
- position: sticky;
- top: 0;
- z-index: 40;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 1rem;
- gap: 1.5rem;
- background: rgba(255, 255, 255, 0.95);
- backdrop-filter: blur(12px);
- border-bottom: 1px solid rgba(212, 212, 212, 0.5);
-}
-.dark .mobile-topbar {
- background: rgba(16, 16, 16, 0.95);
- border-bottom-color: rgba(32, 32, 32, 0.5);
-}
-@media (min-width: 1024px) {
- .mobile-topbar { display: none; }
-}
-
-/* Mobile sidebar overlay (shown when hamburger is tapped) */
-.sidebar-mobile {
- position: relative;
- display: flex;
- flex: 1;
- width: 100%;
- max-width: 14rem;
- min-width: 0;
-}
-.sidebar-mobile-scroll {
- display: flex;
- flex-direction: column;
- padding-bottom: 0.5rem;
- overflow-y: auto;
- min-width: 14rem;
- gap: 1.25rem;
- min-width: 0;
-}
-.dark .sidebar-mobile-scroll { background: #181818; }
-```
-
-#### Sidebar Header (Logo + Search)
-
-**Tailwind:**
-```
-flex lg:pt-6 pt-4 pb-4 pl-2
-```
-
-**Logo:**
-```
-text-2xl font-bold tracking-wide dark:text-white hover:opacity-80 transition-opacity
-```
-
-**Search button:**
-```
-flex items-center gap-1.5 px-2.5 py-1.5
-bg-neutral-100 dark:bg-coolgray-100
-border border-neutral-300 dark:border-coolgray-200
-rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors
-```
-
-**Search kbd hint:**
-```
-px-1 py-0.5 text-xs font-semibold
-text-neutral-500 dark:text-neutral-400
-bg-neutral-200 dark:bg-coolgray-200 rounded
-```
-
-**Plain CSS:**
-```css
-.sidebar-header {
- display: flex;
- padding: 1rem 0 1rem 0.5rem;
-}
-@media (min-width: 1024px) {
- .sidebar-header { padding-top: 1.5rem; }
-}
-
-.sidebar-logo {
- font-size: 1.5rem;
- font-weight: 700;
- letter-spacing: 0.025em;
- color: #000;
- text-decoration: none;
-}
-.dark .sidebar-logo { color: #fff; }
-.sidebar-logo:hover { opacity: 0.8; }
-
-.sidebar-search-btn {
- display: flex;
- align-items: center;
- gap: 0.375rem;
- padding: 0.375rem 0.625rem;
- background: #f5f5f5;
- border: 1px solid #d4d4d4;
- border-radius: 0.375rem;
- cursor: pointer;
- transition: background-color 150ms;
-}
-.sidebar-search-btn:hover { background: #e5e5e5; }
-.dark .sidebar-search-btn {
- background: #181818;
- border-color: #202020;
-}
-.dark .sidebar-search-btn:hover { background: #202020; }
-
-.sidebar-search-kbd {
- padding: 0.125rem 0.25rem;
- font-size: 0.75rem;
- font-weight: 600;
- color: #737373;
- background: #e5e5e5;
- border-radius: 0.25rem;
-}
-.dark .sidebar-search-kbd {
- color: #a3a3a3;
- background: #202020;
-}
-```
-
-#### Menu Item List
-
-**Tailwind (list container):**
-```
-flex flex-col flex-1 gap-y-7
-```
-
-**Tailwind (inner list):**
-```
-flex flex-col h-full space-y-1.5
-```
-
-**Plain CSS:**
-```css
-.menu-list {
- display: flex;
- flex-direction: column;
- flex: 1;
- gap: 1.75rem;
- list-style: none;
- padding: 0;
- margin: 0;
-}
-
-.menu-list-inner {
- display: flex;
- flex-direction: column;
- height: 100%;
- gap: 0.375rem;
- list-style: none;
- padding: 0;
- margin: 0;
-}
-```
-
-#### Menu Item
-
-**Tailwind:**
-```
-flex gap-3 items-center px-2 py-1 w-full text-sm
-dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0
-```
-
-#### Menu Item Active
-
-**Tailwind:**
-```
-text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden
-```
-
-#### Menu Item Icon / Label
-
-```
-/* Icon */ flex-shrink-0 w-6 h-6 dark:hover:text-white
-/* Label */ min-w-0 flex-1 truncate
-```
-
-**Plain CSS:**
-```css
-.menu-item {
- display: flex;
- gap: 0.75rem;
- align-items: center;
- padding: 0.25rem 0.5rem;
- width: 100%;
- font-size: 0.875rem;
- border-radius: 0.125rem;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.menu-item:hover { background: #d4d4d4; }
-.dark .menu-item:hover { background: #181818; color: #fff; }
-
-.menu-item-active {
- color: #000;
- background: #e5e5e5;
- border-radius: 0.125rem;
-}
-.dark .menu-item-active {
- background: #202020;
- color: #fcd452;
-}
-
-.menu-item-icon {
- flex-shrink: 0;
- width: 1.5rem;
- height: 1.5rem;
-}
-
-.menu-item-label {
- min-width: 0;
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-```
-
-#### Sub-Menu Item
-
-```css
-.sub-menu-item {
- /* Same as menu-item but with gap: 0.5rem and icon size 1rem */
- display: flex;
- gap: 0.5rem;
- align-items: center;
- padding: 0.25rem 0.5rem;
- width: 100%;
- font-size: 0.875rem;
- border-radius: 0.125rem;
-}
-.sub-menu-item-icon { flex-shrink: 0; width: 1rem; height: 1rem; }
-```
-
----
-
-### 3.10 Callout / Alert
-
-Four types: `warning`, `danger`, `info`, `success`.
-
-**Structure:**
-```html
-
-```
-
-**Base Tailwind:**
-```
-relative p-4 border rounded-lg
-```
-
-**Type Colors:**
-
-| Type | Background | Border | Title Text | Body Text |
-|---|---|---|---|---|
-| **warning** | `bg-warning-50 dark:bg-warning-900/30` | `border-warning-300 dark:border-warning-800` | `text-warning-800 dark:text-warning-300` | `text-warning-700 dark:text-warning-200` |
-| **danger** | `bg-red-50 dark:bg-red-900/30` | `border-red-300 dark:border-red-800` | `text-red-800 dark:text-red-300` | `text-red-700 dark:text-red-200` |
-| **info** | `bg-blue-50 dark:bg-blue-900/30` | `border-blue-300 dark:border-blue-800` | `text-blue-800 dark:text-blue-300` | `text-blue-700 dark:text-blue-200` |
-| **success** | `bg-green-50 dark:bg-green-900/30` | `border-green-300 dark:border-green-800` | `text-green-800 dark:text-green-300` | `text-green-700 dark:text-green-200` |
-
-**Plain CSS (warning example):**
-```css
-.callout {
- position: relative;
- padding: 1rem;
- border: 1px solid;
- border-radius: 0.5rem;
-}
-
-.callout-warning {
- background: #fefce8;
- border-color: #fde047;
-}
-.dark .callout-warning {
- background: rgba(113, 63, 18, 0.3);
- border-color: #854d0e;
-}
-
-.callout-title {
- font-size: 1rem;
- font-weight: 700;
-}
-.callout-warning .callout-title { color: #854d0e; }
-.dark .callout-warning .callout-title { color: #fde047; }
-
-.callout-text {
- margin-top: 0.5rem;
- font-size: 0.875rem;
-}
-.callout-warning .callout-text { color: #a16207; }
-.dark .callout-warning .callout-text { color: #fef08a; }
-```
-
-**Icon colors per type:**
-- Warning: `text-warning-600 dark:text-warning-400` (`#ca8a04` / `#fcd452`)
-- Danger: `text-red-600 dark:text-red-400` (`#dc2626` / `#f87171`)
-- Info: `text-blue-600 dark:text-blue-400` (`#2563eb` / `#60a5fa`)
-- Success: `text-green-600 dark:text-green-400` (`#16a34a` / `#4ade80`)
-
----
-
-### 3.11 Toast / Notification
-
-**Container Tailwind:**
-```
-relative flex flex-col items-start
-shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)]
-w-full transition-all duration-100 ease-out
-dark:bg-coolgray-100 bg-white
-dark:border dark:border-coolgray-200
-rounded-sm sm:max-w-xs
-```
-
-**Plain CSS:**
-```css
-.toast {
- position: relative;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- width: 100%;
- max-width: 20rem;
- background: #fff;
- border-radius: 0.125rem;
- box-shadow: 0 5px 15px -3px rgba(0, 0, 0, 0.08);
- transition: all 100ms ease-out;
-}
-.dark .toast {
- background: #181818;
- border: 1px solid #202020;
-}
-```
-
-**Icon colors per toast type:**
-
-| Type | Color | Hex |
-|---|---|---|
-| Success | `text-green-500` | `#22c55e` |
-| Info | `text-blue-500` | `#3b82f6` |
-| Warning | `text-orange-400` | `#fb923c` |
-| Danger | `text-red-500` | `#ef4444` |
-
-**Behavior**: Stacks up to 4 toasts, auto-dismisses after 4 seconds, positioned bottom-right.
-
----
-
-### 3.12 Modal
-
-**Tailwind (dialog-based):**
-```
-rounded-sm modal-box max-h-[calc(100vh-5rem)] flex flex-col
-```
-
-**Modal Input variant container:**
-```
-relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl
-border rounded-sm drop-shadow-sm
-bg-white border-neutral-200
-dark:bg-base dark:border-coolgray-300
-flex flex-col
-```
-
-**Modal Confirmation container:**
-```
-relative w-full border rounded-sm
-min-w-full lg:min-w-[36rem] max-w-[48rem]
-max-h-[calc(100vh-2rem)]
-bg-neutral-100 border-neutral-400
-dark:bg-base dark:border-coolgray-300
-flex flex-col
-```
-
-**Plain CSS:**
-```css
-.modal-box {
- border-radius: 0.125rem;
- max-height: calc(100vh - 5rem);
- display: flex;
- flex-direction: column;
-}
-
-.modal-input {
- position: relative;
- width: 100%;
- border: 1px solid #e5e5e5;
- border-radius: 0.125rem;
- filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05));
- background: #fff;
- display: flex;
- flex-direction: column;
-}
-.dark .modal-input {
- background: #101010;
- border-color: #242424;
-}
-
-/* Desktop sizing */
-@media (min-width: 1024px) {
- .modal-input {
- width: auto;
- min-width: 42rem;
- max-width: 56rem;
- }
-}
-```
-
-**Modal header:**
-```css
-.modal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 1.5rem;
- flex-shrink: 0;
-}
-.modal-header h3 {
- font-size: 1.5rem;
- font-weight: 700;
-}
-```
-
-**Close button:**
-```css
-.modal-close {
- width: 2rem;
- height: 2rem;
- border-radius: 9999px;
- color: #fff;
-}
-.modal-close:hover { background: #242424; }
-```
-
----
-
-### 3.13 Slide-Over Panel
-
-**Tailwind:**
-```
-fixed inset-y-0 right-0 flex max-w-full pl-10
-```
-
-**Inner panel:**
-```
-max-w-xl w-screen
-flex flex-col h-full py-6
-border-l shadow-lg
-bg-neutral-50 dark:bg-base
-dark:border-neutral-800 border-neutral-200
-```
-
-**Plain CSS:**
-```css
-.slide-over {
- position: fixed;
- top: 0;
- bottom: 0;
- right: 0;
- display: flex;
- max-width: 100%;
- padding-left: 2.5rem;
-}
-
-.slide-over-panel {
- max-width: 36rem;
- width: 100vw;
- display: flex;
- flex-direction: column;
- height: 100%;
- padding: 1.5rem 0;
- border-left: 1px solid #e5e5e5;
- box-shadow: -10px 0 15px -3px rgba(0, 0, 0, 0.1);
- background: #fafafa;
-}
-.dark .slide-over-panel {
- background: #101010;
- border-color: #262626;
-}
-```
-
----
-
-### 3.14 Tag
-
-**Tailwind:**
-```
-px-2 py-1 cursor-pointer text-xs font-bold text-neutral-500
-dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200
-```
-
-**Plain CSS:**
-```css
-.tag {
- padding: 0.25rem 0.5rem;
- font-size: 0.75rem;
- font-weight: 700;
- color: #737373;
- background: #f5f5f5;
- cursor: pointer;
-}
-.tag:hover { background: #e5e5e5; }
-.dark .tag { background: #181818; }
-.dark .tag:hover { background: #242424; }
-```
-
----
-
-### 3.15 Loading Spinner
-
-**Tailwind:**
-```
-w-4 h-4 text-coollabs dark:text-warning animate-spin
-```
-
-**Plain CSS + SVG:**
-```css
-.loading-spinner {
- width: 1rem;
- height: 1rem;
- color: #6b16ed;
- animation: spin 1s linear infinite;
-}
-.dark .loading-spinner { color: #fcd452; }
-
-@keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-```
-
-**SVG structure:**
-```html
-
-```
-
----
-
-### 3.16 Helper / Tooltip
-
-**Tailwind (trigger icon):**
-```
-cursor-pointer text-coollabs dark:text-warning
-```
-
-**Tailwind (popup):**
-```
-hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block
-dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200
-dark:text-neutral-300 max-w-sm whitespace-normal break-words
-```
-
-**Plain CSS:**
-```css
-.helper-icon {
- cursor: pointer;
- color: #6b16ed;
-}
-.dark .helper-icon { color: #fcd452; }
-
-.helper-popup {
- display: none;
- position: absolute;
- z-index: 40;
- font-size: 0.75rem;
- border-radius: 0.125rem;
- color: #404040;
- background: #e5e5e5;
- max-width: 24rem;
- white-space: normal;
- word-break: break-word;
- padding: 1rem;
-}
-.dark .helper-popup {
- background: #282828;
- color: #d4d4d4;
- border: 1px solid #323232;
-}
-
-/* Show on parent hover */
-.helper:hover .helper-popup { display: block; }
-```
-
----
-
-### 3.17 Highlighted Text
-
-**Tailwind:**
-```
-inline-block font-bold text-coollabs dark:text-warning
-```
-
-**Plain CSS:**
-```css
-.text-highlight {
- display: inline-block;
- font-weight: 700;
- color: #6b16ed;
-}
-.dark .text-highlight { color: #fcd452; }
-```
-
----
-
-### 3.18 Scrollbar
-
-**Tailwind:**
-```
-scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200
-dark:scrollbar-track-coolgray-200 scrollbar-thin
-```
-
-**Plain CSS:**
-```css
-::-webkit-scrollbar { width: 6px; height: 6px; }
-::-webkit-scrollbar-track { background: #e5e5e5; }
-::-webkit-scrollbar-thumb { background: #7317ff; }
-.dark ::-webkit-scrollbar-track { background: #202020; }
-```
-
----
-
-### 3.19 Table
-
-**Plain CSS:**
-```css
-table { min-width: 100%; border-collapse: separate; }
-table, tbody { border-bottom: 1px solid #d4d4d4; }
-.dark table, .dark tbody { border-color: #202020; }
-
-thead { text-transform: uppercase; }
-
-tr { color: #000; }
-tr:hover { background: #e5e5e5; }
-.dark tr { color: #a3a3a3; }
-.dark tr:hover { background: #000; }
-
-th {
- padding: 0.875rem 0.75rem;
- text-align: left;
- color: #000;
-}
-.dark th { color: #fff; }
-th:first-child { padding-left: 1.5rem; }
-
-td { padding: 1rem 0.75rem; white-space: nowrap; }
-td:first-child { padding-left: 1.5rem; font-weight: 700; }
-```
-
----
-
-### 3.20 Keyboard Shortcut Indicator
-
-**Tailwind:**
-```
-px-2 text-xs rounded-sm border border-dashed border-neutral-700 dark:text-warning
-```
-
-**Plain CSS:**
-```css
-.kbd {
- padding: 0 0.5rem;
- font-size: 0.75rem;
- border-radius: 0.125rem;
- border: 1px dashed #404040;
-}
-.dark .kbd { color: #fcd452; }
-```
-
----
-
-## 4. Base Element Styles
-
-These global styles are applied to all HTML elements:
-
-```css
-/* Page */
-html, body {
- width: 100%;
- min-height: 100%;
- background: #f9fafb;
- font-family: Inter, sans-serif;
-}
-.dark html, .dark body {
- background: #101010;
- color: #a3a3a3;
-}
-
-body {
- min-height: 100vh;
- font-size: 0.875rem;
- -webkit-font-smoothing: antialiased;
- overflow-x: hidden;
-}
-
-/* Links */
-a:hover { color: #000; }
-.dark a:hover { color: #fff; }
-
-/* Labels */
-.dark label { color: #a3a3a3; }
-
-/* Sections */
-section { margin-bottom: 3rem; }
-
-/* Default border color override */
-*, ::after, ::before, ::backdrop {
- border-color: #202020; /* coolgray-200 */
-}
-
-/* Select options */
-.dark option {
- color: #fff;
- background: #181818;
-}
-```
-
----
-
-## 5. Interactive State Reference
-
-### Focus
-
-| Element Type | Mechanism | Light | Dark |
-|---|---|---|---|
-| Buttons, links, checkboxes | `ring-2` offset | Purple `#6b16ed` | Yellow `#fcd452` |
-| Inputs, selects, textareas | Inset box-shadow (4px left bar) | Purple `#6b16ed` | Yellow `#fcd452` |
-| Dropdown items | Background change | `bg-neutral-100` | `bg-coollabs` (`#6b16ed`) |
-
-### Hover
-
-| Element | Light | Dark |
-|---|---|---|
-| Button (default) | `bg-neutral-100` | `bg-coolgray-200` |
-| Button (highlighted) | `bg-coollabs` (`#6b16ed`) | `bg-coollabs-100` (`#7317ff`) |
-| Button (error) | `bg-red-300` | `bg-red-800` |
-| Box card | `bg-neutral-100` + all child text `#000` | `bg-coollabs-100` (`#7317ff`) + all child text `#fff` |
-| Coolbox card | Ring: `ring-coollabs` | Ring: `ring-warning` |
-| Menu item | `bg-neutral-300` | `bg-coolgray-100` |
-| Dropdown item | `bg-neutral-100` | `bg-coollabs` |
-| Table row | `bg-neutral-200` | `bg-black` |
-| Link | `text-black` | `text-white` |
-| Checkbox container | — | `bg-coolgray-100` |
-
-### Disabled
-
-```css
-/* Universal disabled pattern */
-:disabled {
- cursor: not-allowed;
- color: #d4d4d4; /* neutral-300 */
- background: transparent;
- border-color: transparent;
-}
-.dark :disabled {
- color: #525252; /* neutral-600 */
-}
-
-/* Input-specific */
-.input:disabled {
- background: #e5e5e5; /* neutral-200 */
- color: #737373; /* neutral-500 */
- box-shadow: none;
-}
-.dark .input:disabled {
- background: rgba(24, 24, 24, 0.4);
- box-shadow: none;
-}
-```
-
-### Readonly
-
-```css
-.input:read-only {
- color: #737373;
- background: #e5e5e5;
- box-shadow: none;
-}
-.dark .input:read-only {
- color: #737373;
- background: rgba(24, 24, 24, 0.4);
- box-shadow: none;
-}
-```
-
----
-
-## 6. CSS Custom Properties (Theme Tokens)
-
-For use in any CSS framework or plain CSS:
-
-```css
-:root {
- /* Font */
- --font-sans: Inter, sans-serif;
-
- /* Brand */
- --color-base: #101010;
- --color-coollabs: #6b16ed;
- --color-coollabs-50: #f5f0ff;
- --color-coollabs-100: #7317ff;
- --color-coollabs-200: #5a12c7;
- --color-coollabs-300: #4a0fa3;
-
- /* Neutral grays (dark backgrounds) */
- --color-coolgray-100: #181818;
- --color-coolgray-200: #202020;
- --color-coolgray-300: #242424;
- --color-coolgray-400: #282828;
- --color-coolgray-500: #323232;
-
- /* Warning / dark accent */
- --color-warning: #fcd452;
- --color-warning-50: #fefce8;
- --color-warning-100: #fef9c3;
- --color-warning-200: #fef08a;
- --color-warning-300: #fde047;
- --color-warning-400: #fcd452;
- --color-warning-500: #facc15;
- --color-warning-600: #ca8a04;
- --color-warning-700: #a16207;
- --color-warning-800: #854d0e;
- --color-warning-900: #713f12;
-
- /* Semantic */
- --color-success: #22C55E;
- --color-error: #dc2626;
-}
-```
diff --git a/AGENTS.md b/AGENTS.md
index 3fff0074e..2c403efe8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,3 +1,7 @@
+## Design Reference
+
+For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo.
+
=== foundation rules ===
diff --git a/CLAUDE.md b/CLAUDE.md
index bb65da405..188889954 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,6 +6,10 @@ ## Project Overview
Coolify is an open-source, self-hostable PaaS (alternative to Heroku/Netlify/Vercel). It manages servers, applications, databases, and services via SSH. Built with Laravel 12 (using Laravel 10 file structure), Livewire 3, and Tailwind CSS v4.
+## Design Reference
+
+For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo.
+
## Development Environment
Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio.
diff --git a/README.md b/README.md
index 9a5feff4e..28fc33d6f 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
-*
+* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers Infrastructure for people who care about privacy and control
### Big Sponsors
@@ -69,6 +69,7 @@ ### Big Sponsors
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
+* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers
* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
@@ -87,6 +88,7 @@ ### Big Sponsors
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
+* [LumaDock](https://lumadock.com/vps-hosting/coolify?utm_source=coolify&utm_medium=sponsorship&utm_campaign=coolify_oss_sponsor_2026&utm_content=github_readme) - Fast and reliable virtual server hosting
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions
@@ -151,6 +153,10 @@ ### Small Sponsors
+
+
+
+
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php
index 98cce088b..33558c746 100644
--- a/app/Actions/Server/CleanupDocker.php
+++ b/app/Actions/Server/CleanupDocker.php
@@ -51,6 +51,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
+ 'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true',
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php
index 071f3ec46..289ab9ebe 100644
--- a/app/Actions/Server/StartSentinel.php
+++ b/app/Actions/Server/StartSentinel.php
@@ -4,7 +4,6 @@
use App\Events\SentinelRestarted;
use App\Models\Server;
-use App\Models\ServerSetting;
use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel
@@ -23,10 +22,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
- $token = data_get($server, 'settings.sentinel_token');
- if (! ServerSetting::isValidSentinelToken($token)) {
- throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
- }
+ $token = $server->settings->ensureValidSentinelToken();
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
diff --git a/app/Enums/BuildPackTypes.php b/app/Enums/BuildPackTypes.php
index cb51db6d6..eee898823 100644
--- a/app/Enums/BuildPackTypes.php
+++ b/app/Enums/BuildPackTypes.php
@@ -8,4 +8,5 @@ enum BuildPackTypes: string
case STATIC = 'static';
case DOCKERFILE = 'dockerfile';
case DOCKERCOMPOSE = 'dockercompose';
+ case RAILPACK = 'railpack';
}
diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php
index 71de48bcd..58f21c793 100644
--- a/app/Exceptions/Handler.php
+++ b/app/Exceptions/Handler.php
@@ -4,8 +4,10 @@
use App\Models\InstanceSettings;
use App\Models\User;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
+use Psr\Log\LogLevel;
use RuntimeException;
use Sentry\Laravel\Integration;
use Sentry\State\Scope;
@@ -16,7 +18,7 @@ class Handler extends ExceptionHandler
/**
* A list of exception types with their corresponding custom log levels.
*
- * @var array, \Psr\Log\LogLevel::*>
+ * @var array, LogLevel::*>
*/
protected $levels = [
//
@@ -25,7 +27,7 @@ class Handler extends ExceptionHandler
/**
* A list of the exception types that are not reported.
*
- * @var array>
+ * @var array>
*/
protected $dontReport = [
ProcessException::class,
@@ -49,6 +51,13 @@ class Handler extends ExceptionHandler
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) {
+ if ($request->is('api/*')) {
+ auditLog('api.auth.unauthenticated', [
+ 'reason' => $exception->getMessage(),
+ 'guards' => $exception->guards(),
+ ], 'warning');
+ }
+
return response()->json(['message' => $exception->getMessage()], 401);
}
@@ -61,8 +70,15 @@ protected function unauthenticated($request, AuthenticationException $exception)
public function render($request, Throwable $e)
{
// Handle authorization exceptions for API routes
- if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
+ if ($e instanceof AuthorizationException) {
if ($request->is('api/*') || $request->expectsJson()) {
+ if ($request->is('api/*')) {
+ auditLog('api.auth.policy_denied', [
+ 'reason' => $e->getMessage(),
+ 'route' => $request->route()?->getName() ?? $request->path(),
+ ], 'warning');
+ }
+
// Get the custom message from the policy if available
$message = $e->getMessage();
diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php
index aa9d06996..4629df571 100644
--- a/app/Helpers/SshMultiplexingHelper.php
+++ b/app/Helpers/SshMultiplexingHelper.php
@@ -71,7 +71,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
- $connectionTimeout = config('constants.ssh.connection_timeout');
+ $connectionTimeout = self::getConnectionTimeout($server);
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
@@ -140,7 +140,7 @@ public static function generateScpCommand(Server $server, string $source, string
$scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
- $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
+ $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
if ($server->isIpv6()) {
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
} else {
@@ -184,7 +184,7 @@ public static function generateSshCommand(Server $server, string $command, bool
$ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
}
- $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));
+ $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
$delimiter = Hash::make($command);
$delimiter = base64_encode($delimiter);
@@ -243,6 +243,15 @@ private static function validateSshKey(PrivateKey $privateKey): void
}
}
+ public static function getConnectionTimeout(Server $server): int
+ {
+ $timeout = data_get($server, 'settings.connection_timeout');
+
+ return is_numeric($timeout) && (int) $timeout > 0
+ ? (int) $timeout
+ : (int) config('constants.ssh.connection_timeout');
+ }
+
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
{
$options = "-i {$sshKeyLocation} "
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index eb2e7fc53..074269fa0 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -5,7 +5,6 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Actions\Application\LoadComposeFile;
use App\Actions\Application\StopApplication;
-use App\Actions\Service\StartService;
use App\Enums\BuildPackTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
@@ -18,7 +17,6 @@
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
-use App\Models\Service;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
@@ -155,7 +153,7 @@ public function applications(Request $request)
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'],
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@@ -324,7 +322,7 @@ public function create_public_application(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -490,7 +488,7 @@ public function create_private_gh_app_application(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -652,7 +650,7 @@ public function create_private_deploy_key_application(Request $request)
'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['dockerfile'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@@ -899,105 +897,6 @@ public function create_dockerimage_application(Request $request)
return $this->create_application($request, 'dockerimage');
}
- /**
- * @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is an unstable duplicate of POST /api/v1/services.
- */
- #[OA\Post(
- summary: 'Create (Docker Compose)',
- description: 'Deprecated: Use POST /api/v1/services instead.',
- path: '/applications/dockercompose',
- operationId: 'create-dockercompose-application',
- deprecated: true,
- security: [
- ['bearerAuth' => []],
- ],
- tags: ['Applications'],
- requestBody: new OA\RequestBody(
- description: 'Application object that needs to be created.',
- required: true,
- content: [
- new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
- properties: [
- 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
- 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
- 'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
- 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
- 'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
- 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID if the server has more than one destinations.'],
- 'name' => ['type' => 'string', 'description' => 'The application name.'],
- 'description' => ['type' => 'string', 'description' => 'The application description.'],
- 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
- 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
- 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
- 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
- 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
- ],
- )
- ),
- ]
- ),
- responses: [
- new OA\Response(
- response: 201,
- description: 'Application created successfully.',
- content: new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- properties: [
- 'uuid' => ['type' => 'string'],
- ]
- )
- )
- ),
- new OA\Response(
- response: 401,
- ref: '#/components/responses/401',
- ),
- new OA\Response(
- response: 400,
- ref: '#/components/responses/400',
- ),
- new OA\Response(
- response: 409,
- description: 'Domain conflicts detected.',
- content: [
- new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- properties: [
- 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
- 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
- 'conflicts' => [
- 'type' => 'array',
- 'items' => new OA\Schema(
- type: 'object',
- properties: [
- 'domain' => ['type' => 'string', 'example' => 'example.com'],
- 'resource_name' => ['type' => 'string', 'example' => 'My Application'],
- 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
- 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
- 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
- ]
- ),
- ],
- ]
- )
- ),
- ]
- ),
- ]
- )]
- public function create_dockercompose_application(Request $request)
- {
- return $this->create_application($request, 'dockercompose');
- }
-
private function create_application(Request $request, $type)
{
$teamId = getTeamIdFromToken();
@@ -1080,6 +979,9 @@ private function create_application(Request $request, $type)
],
], 422);
}
+ $request->merge([
+ 'custom_nginx_configuration' => $customNginxConfiguration,
+ ]);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
@@ -1309,6 +1211,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1539,6 +1450,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1739,6 +1659,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1846,6 +1775,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1956,93 +1894,19 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
- } elseif ($type === 'dockercompose') {
- $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled'];
-
- $extraFields = array_diff(array_keys($request->all()), $allowedFields);
- if ($validator->fails() || ! empty($extraFields)) {
- $errors = $validator->errors();
- if (! empty($extraFields)) {
- foreach ($extraFields as $field) {
- $errors->add($field, 'This field is not allowed.');
- }
- }
-
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => $errors,
- ], 422);
- }
- if (! $request->has('name')) {
- $request->offsetSet('name', 'service'.new Cuid2);
- }
- $validationRules = [
- 'docker_compose_raw' => 'string|required',
- ];
- $validationRules = array_merge(sharedDataApplications(), $validationRules);
- $validator = customApiValidator($request->all(), $validationRules);
-
- if ($validator->fails()) {
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => $validator->errors(),
- ], 422);
- }
- $return = $this->validateDataApplications($request, $server);
- if ($return instanceof JsonResponse) {
- return $return;
- }
- if (! isBase64Encoded($request->docker_compose_raw)) {
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => [
- 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
- ],
- ], 422);
- }
- $dockerComposeRaw = base64_decode($request->docker_compose_raw);
- if (mb_detect_encoding($dockerComposeRaw, 'UTF-8', true) === false) {
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => [
- 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
- ],
- ], 422);
- }
- $dockerCompose = base64_decode($request->docker_compose_raw);
- $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
-
- $service = new Service;
- removeUnnecessaryFieldsFromRequest($request);
- $service->fill($request->only($allowedFields));
-
- $service->docker_compose_raw = $dockerComposeRaw;
- $service->environment_id = $environment->id;
- $service->server_id = $server->id;
- $service->destination_id = $destination->id;
- $service->destination_type = $destination->getMorphClass();
- if (isset($isContainerLabelEscapeEnabled)) {
- $service->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
- }
- $service->save();
-
- $service->parse(isNew: true);
-
- // Apply service-specific application prerequisites
- applyServiceApplicationPrerequisites($service);
-
- if ($instantDeploy) {
- StartService::dispatch($service);
- }
-
- return response()->json(serializeApiResponse([
- 'uuid' => data_get($service, 'uuid'),
- 'domains' => data_get($service, 'domains'),
- ]))->setStatusCode(201);
}
return response()->json(['message' => 'Invalid type.'], 400);
@@ -2297,6 +2161,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.application.deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
return response()->json([
'message' => 'Application deletion request queued.',
]);
@@ -2339,7 +2209,7 @@ public function delete_by_uuid(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -2530,7 +2400,7 @@ public function update_by_uuid(Request $request)
}
}
}
- if ($request->has('custom_nginx_configuration')) {
+ if ($request->has('custom_nginx_configuration') && ! is_null($request->custom_nginx_configuration)) {
if (! isBase64Encoded($request->custom_nginx_configuration)) {
return response()->json([
'message' => 'Validation failed.',
@@ -2548,6 +2418,9 @@ public function update_by_uuid(Request $request)
],
], 422);
}
+ $request->merge([
+ 'custom_nginx_configuration' => $customNginxConfiguration,
+ ]);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) {
@@ -2796,6 +2669,13 @@ public function update_by_uuid(Request $request)
}
$application->save();
+ auditLog('api.application.updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
@@ -3048,6 +2928,14 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.application.env_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@@ -3081,6 +2969,14 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.application.env_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@@ -3307,6 +3203,12 @@ public function create_bulk_envs(Request $request)
$returnedEnvs->push($this->removeSensitiveData($env));
}
+ auditLog('api.application.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_count' => $returnedEnvs->count(),
+ ]);
+
return response()->json($returnedEnvs)->setStatusCode(201);
}
@@ -3446,6 +3348,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id,
]);
+ auditLog('api.application.env_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@@ -3471,6 +3381,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id,
]);
+ auditLog('api.application.env_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@@ -3562,8 +3480,17 @@ public function delete_env_by_uuid(Request $request)
'message' => 'Environment variable not found.',
], 404);
}
+ $envKey = $found_env->key;
+ $envUuid = $found_env->uuid;
$found_env->forceDelete();
+ auditLog('api.application.env_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json([
'message' => 'Environment variable deleted.',
]);
@@ -3675,6 +3602,15 @@ public function action_deploy(Request $request)
);
}
+ auditLog('api.application.deployed', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'force_rebuild' => $force,
+ 'instant_deploy' => $instant_deploy,
+ ]);
+
return response()->json(
[
'message' => 'Deployment request queued.',
@@ -3763,6 +3699,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopApplication::dispatch($application, false, $dockerCleanup);
+ auditLog('api.application.stopped', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Application stopping request queued.',
@@ -3853,6 +3796,13 @@ public function action_restart(Request $request)
], 200);
}
+ auditLog('api.application.restarted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ ]);
+
return response()->json(
[
'message' => 'Restart request queued.',
@@ -4221,6 +4171,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
+ auditLog('api.application.storage_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -4399,6 +4358,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ auditLog('api.application.storage_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -4472,8 +4440,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.application.storage_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
@@ -4543,6 +4521,12 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
$preview->delete();
CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
+ auditLog('api.application.preview_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'pull_request_id' => $pullRequestId,
+ ]);
+
return response()->json(['message' => 'Preview deletion request queued.']);
}
}
diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php
index 5be82a31c..d652f2ba1 100644
--- a/app/Http/Controllers/Api/CloudProviderTokensController.php
+++ b/app/Http/Controllers/Api/CloudProviderTokensController.php
@@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -244,7 +245,7 @@ public function store(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -286,6 +287,13 @@ public function store(Request $request)
'name' => $body['name'],
]);
+ auditLog('api.cloud_token.created', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $cloudProviderToken->uuid,
+ 'cloud_token_name' => $cloudProviderToken->name,
+ 'provider' => $cloudProviderToken->provider,
+ ]);
+
return response()->json([
'uuid' => $cloudProviderToken->uuid,
])->setStatusCode(201);
@@ -355,7 +363,7 @@ public function update(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -389,6 +397,14 @@ public function update(Request $request)
$token->update(array_intersect_key($body, array_flip($allowedFields)));
+ auditLog('api.cloud_token.updated', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $token->uuid,
+ 'cloud_token_name' => $token->name,
+ 'provider' => $token->provider,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
+ ]);
+
return response()->json([
'uuid' => $token->uuid,
]);
@@ -464,8 +480,18 @@ public function destroy(Request $request)
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
}
+ $tokenUuid = $token->uuid;
+ $tokenName = $token->name;
+ $tokenProvider = $token->provider;
$token->delete();
+ auditLog('api.cloud_token.deleted', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $tokenUuid,
+ 'cloud_token_name' => $tokenName,
+ 'provider' => $tokenProvider,
+ ]);
+
return response()->json(['message' => 'Cloud provider token deleted.']);
}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index c05af152f..dc9b6f5b5 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -596,6 +596,14 @@ public function update_by_uuid(Request $request)
StopDatabaseProxy::dispatch($database);
}
+ auditLog('api.database.updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'message' => 'Database updated.',
]);
@@ -639,10 +647,10 @@ public function update_by_uuid(Request $request)
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
- 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
+ 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
- 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -703,10 +711,10 @@ public function create_backup(Request $request)
'databases_to_backup' => 'string|nullable',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
- 'database_backup_retention_max_storage_locally' => 'integer|min:0',
+ 'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
- 'database_backup_retention_max_storage_s3' => 'integer|min:0',
+ 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
@@ -826,6 +834,15 @@ public function create_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig));
}
+ auditLog('api.database.backup_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $backupConfig->uuid,
+ 'frequency' => $backupConfig->frequency,
+ 'save_s3' => (bool) $backupConfig->save_s3,
+ 'backup_now' => (bool) $request->backup_now,
+ ]);
+
return response()->json([
'uuid' => $backupConfig->uuid,
'message' => 'Backup configuration created successfully.',
@@ -878,10 +895,10 @@ public function create_backup(Request $request)
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
- 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
+ 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
- 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -933,10 +950,10 @@ public function update_backup(Request $request)
'frequency' => 'string',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
- 'database_backup_retention_max_storage_locally' => 'integer|min:0',
+ 'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
- 'database_backup_retention_max_storage_s3' => 'integer|min:0',
+ 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
if ($validator->fails()) {
@@ -1045,6 +1062,14 @@ public function update_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig));
}
+ auditLog('api.database.backup_updated', [
+ 'team_id' => $teamId,
+ 'backup_uuid' => $backupConfig->uuid,
+ 'database_id' => $backupConfig->database_id,
+ 'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))),
+ 'backup_now' => (bool) $request->backup_now,
+ ]);
+
return response()->json([
'message' => 'Database backup configuration updated',
]);
@@ -1779,6 +1804,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
@@ -1838,6 +1873,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
@@ -1897,6 +1942,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
@@ -1953,6 +2008,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
@@ -2039,6 +2104,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
@@ -2075,6 +2150,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
@@ -2133,6 +2218,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
}
@@ -2217,6 +2312,13 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.database.deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json([
'message' => 'Database deletion request queued.',
]);
@@ -2329,6 +2431,14 @@ public function delete_backup_by_uuid(Request $request)
$backup->delete();
DB::commit();
+ auditLog('api.database.backup_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $request->scheduled_backup_uuid,
+ 'delete_s3' => $deleteS3,
+ 'executions_deleted' => $executions->count(),
+ ]);
+
return response()->json([
'message' => 'Backup configuration and all executions deleted.',
]);
@@ -2451,6 +2561,14 @@ public function delete_execution_by_uuid(Request $request)
$execution->delete();
+ auditLog('api.database.backup_execution_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $request->scheduled_backup_uuid,
+ 'execution_uuid' => $request->execution_uuid,
+ 'delete_s3' => $deleteS3,
+ ]);
+
return response()->json([
'message' => 'Backup execution deleted.',
]);
@@ -2633,6 +2751,13 @@ public function action_deploy(Request $request)
}
StartDatabase::dispatch($database);
+ auditLog('api.database.started', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json(
[
'message' => 'Database starting request queued.',
@@ -2724,6 +2849,14 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopDatabase::dispatch($database, $dockerCleanup);
+ auditLog('api.database.stopped', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Database stopping request queued.',
@@ -2801,6 +2934,13 @@ public function action_restart(Request $request)
RestartDatabase::dispatch($database);
+ auditLog('api.database.restarted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json(
[
'message' => 'Database restarting request queued.',
@@ -3017,6 +3157,13 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.database.env_updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3145,6 +3292,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveEnvData($env));
}
+ auditLog('api.database.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_count' => $updatedEnvs->count(),
+ ]);
+
return response()->json($updatedEnvs)->setStatusCode(201);
}
@@ -3266,6 +3419,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null,
]);
+ auditLog('api.database.env_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3351,8 +3511,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404);
}
+ $envKey = $env->key;
+ $envUuid = $env->uuid;
$env->forceDelete();
+ auditLog('api.database.env_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json(['message' => 'Environment variable deleted.']);
}
@@ -3599,6 +3768,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ auditLog('api.database.storage_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -3797,6 +3975,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
+ auditLog('api.database.storage_updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -3870,8 +4057,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.database.storage_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
}
diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php
index 6ff06c10a..c93731d68 100644
--- a/app/Http/Controllers/Api/DeployController.php
+++ b/app/Http/Controllers/Api/DeployController.php
@@ -281,6 +281,14 @@ public function cancel_deployment(Request $request)
}
}
+ auditLog('api.deployment.cancelled', [
+ 'team_id' => $teamId,
+ 'deployment_uuid' => $deployment->deployment_uuid,
+ 'application_id' => $application?->id,
+ 'application_uuid' => $application?->uuid,
+ 'server_id' => $deployment->server_id,
+ ]);
+
return response()->json([
'message' => 'Deployment cancelled successfully.',
'deployment_uuid' => $deployment->deployment_uuid,
@@ -518,6 +526,14 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$message = $result['message'];
} else {
$message = "Application {$resource->name} deployment queued.";
+ auditLog('api.deployment.triggered', [
+ 'resource_type' => 'application',
+ 'application_uuid' => $resource->uuid,
+ 'application_name' => $resource->name,
+ 'deployment_uuid' => $deployment_uuid?->toString(),
+ 'force_rebuild' => $force,
+ 'pull_request_id' => $pr,
+ ]);
}
break;
case Service::class:
@@ -529,6 +545,10 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
}
StartService::run($resource);
$message = "Service {$resource->name} started. It could take a while, be patient.";
+ auditLog('api.service.deployed', [
+ 'service_uuid' => $resource->uuid,
+ 'service_name' => $resource->name,
+ ]);
break;
default:
// Database resource - check authorization
@@ -543,6 +563,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$resource->save();
$message = "Database {$resource->name} started.";
+ auditLog('api.database.started', [
+ 'database_uuid' => $resource->uuid,
+ 'database_name' => $resource->name,
+ 'database_type' => $resource->getMorphClass(),
+ ]);
break;
}
diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php
index 9a2cf2b9f..651969b97 100644
--- a/app/Http/Controllers/Api/GithubController.php
+++ b/app/Http/Controllers/Api/GithubController.php
@@ -271,6 +271,12 @@ public function create_github_app(Request $request)
$githubApp = GithubApp::create($payload);
+ auditLog('api.github_app.created', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $githubApp->uuid,
+ 'github_app_name' => $githubApp->name,
+ ]);
+
return response()->json($githubApp, 201);
} catch (\Throwable $e) {
return handleError($e);
@@ -650,6 +656,13 @@ public function update_github_app(Request $request, $github_app_id)
// Update the GitHub app
$githubApp->update($payload);
+ auditLog('api.github_app.updated', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $githubApp->uuid,
+ 'github_app_name' => $githubApp->name,
+ 'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
+ ]);
+
return response()->json([
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
@@ -734,8 +747,16 @@ public function delete_github_app($github_app_id)
], 409);
}
+ $deletedUuid = $githubApp->uuid;
+ $deletedName = $githubApp->name;
$githubApp->delete();
+ auditLog('api.github_app.deleted', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $deletedUuid,
+ 'github_app_name' => $deletedName,
+ ]);
+
return response()->json([
'message' => 'GitHub app deleted successfully',
]);
diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php
index 092c48594..2f35ba576 100644
--- a/app/Http/Controllers/Api/HetznerController.php
+++ b/app/Http/Controllers/Api/HetznerController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
+use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller;
@@ -12,6 +13,7 @@
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -550,7 +552,7 @@ public function createServer(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -717,9 +719,17 @@ public function createServer(Request $request)
// Validate server if requested
if ($request->instant_validate) {
- \App\Actions\Server\ValidateServer::dispatch($server);
+ ValidateServer::dispatch($server);
}
+ auditLog('api.hetzner_server.created', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'hetzner_server_id' => $hetznerServer['id'],
+ 'ip' => $ipAddress,
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
'hetzner_server_id' => $hetznerServer['id'],
diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php
index 49468b597..f17a4e46b 100644
--- a/app/Http/Controllers/Api/OtherController.php
+++ b/app/Http/Controllers/Api/OtherController.php
@@ -85,11 +85,15 @@ public function enable_api(Request $request)
return invalidTokenResponse();
}
if ($teamId !== '0') {
+ auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
+
return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => true]);
+ auditLog('api.instance.enabled', ['team_id' => $teamId]);
+
return response()->json(['message' => 'API enabled.'], 200);
}
@@ -137,14 +141,130 @@ public function disable_api(Request $request)
return invalidTokenResponse();
}
if ($teamId !== '0') {
+ auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
+
return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => false]);
+ auditLog('api.instance.disabled', ['team_id' => $teamId]);
+
return response()->json(['message' => 'API disabled.'], 200);
}
+ #[OA\Post(
+ summary: 'Enable MCP Server',
+ description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
+ path: '/mcp/enable',
+ operationId: 'enable-mcp',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'MCP server enabled.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
+ ]
+ )),
+ new OA\Response(
+ response: 403,
+ description: 'You are not allowed to enable the MCP server.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
+ ]
+ )),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ ]
+ )]
+ public function enable_mcp(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ if ($teamId !== '0') {
+ auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
+
+ return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
+ }
+ $settings = instanceSettings();
+ $settings->update(['is_mcp_server_enabled' => true]);
+
+ auditLog('api.mcp.enabled', ['team_id' => $teamId]);
+
+ return response()->json(['message' => 'MCP server enabled.'], 200);
+ }
+
+ #[OA\Post(
+ summary: 'Disable MCP Server',
+ description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
+ path: '/mcp/disable',
+ operationId: 'disable-mcp',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'MCP server disabled.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
+ ]
+ )),
+ new OA\Response(
+ response: 403,
+ description: 'You are not allowed to disable the MCP server.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
+ ]
+ )),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ ]
+ )]
+ public function disable_mcp(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ if ($teamId !== '0') {
+ auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
+
+ return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
+ }
+ $settings = instanceSettings();
+ $settings->update(['is_mcp_server_enabled' => false]);
+
+ auditLog('api.mcp.disabled', ['team_id' => $teamId]);
+
+ return response()->json(['message' => 'MCP server disabled.'], 200);
+ }
+
public function feedback(Request $request)
{
$data = $request->validate([
diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php
index ec2e300ff..0e5f6e93b 100644
--- a/app/Http/Controllers/Api/ProjectController.php
+++ b/app/Http/Controllers/Api/ProjectController.php
@@ -264,6 +264,12 @@ public function create_project(Request $request)
'team_id' => $teamId,
]);
+ auditLog('api.project.created', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'project_name' => $project->name,
+ ]);
+
return response()->json([
'uuid' => $project->uuid,
])->setStatusCode(201);
@@ -382,6 +388,13 @@ public function update_project(Request $request)
$project->update($request->only($allowedFields));
+ auditLog('api.project.updated', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'project_name' => $project->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $project->uuid,
'name' => $project->name,
@@ -460,8 +473,16 @@ public function delete_project(Request $request)
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
+ $projectUuid = $project->uuid;
+ $projectName = $project->name;
$project->delete();
+ auditLog('api.project.deleted', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $projectUuid,
+ 'project_name' => $projectName,
+ ]);
+
return response()->json(['message' => 'Project deleted.']);
}
@@ -641,6 +662,13 @@ public function create_environment(Request $request)
'name' => $request->name,
]);
+ auditLog('api.project.environment_created', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $environment->uuid,
+ 'environment_name' => $environment->name,
+ ]);
+
return response()->json([
'uuid' => $environment->uuid,
])->setStatusCode(201);
@@ -723,8 +751,17 @@ public function delete_environment(Request $request)
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
}
+ $envUuid = $environment->uuid;
+ $envName = $environment->name;
$environment->delete();
+ auditLog('api.project.environment_deleted', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $envUuid,
+ 'environment_name' => $envName,
+ ]);
+
return response()->json(['message' => 'Environment deleted.']);
}
}
diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php
index 6245dc2ec..d7b109918 100644
--- a/app/Http/Controllers/Api/ScheduledTasksController.php
+++ b/app/Http/Controllers/Api/ScheduledTasksController.php
@@ -6,6 +6,7 @@
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -33,7 +34,7 @@ private function resolveService(Request $request, int $teamId): ?Service
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
- private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function listTasks(Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@@ -44,12 +45,12 @@ private function listTasks(Application|Service $resource): \Illuminate\Http\Json
return response()->json($tasks);
}
- private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function createTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -105,15 +106,23 @@ private function createTask(Request $request, Application|Service $resource): \I
$task->save();
+ auditLog('api.scheduled_task.created', [
+ 'team_id' => $teamId,
+ 'task_uuid' => $task->uuid,
+ 'task_name' => $task->name,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ ]);
+
return response()->json($this->removeSensitiveData($task), 201);
}
- private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function updateTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -161,22 +170,43 @@ private function updateTask(Request $request, Application|Service $resource): \I
$task->update($request->only($allowedFields));
+ auditLog('api.scheduled_task.updated', [
+ 'team_id' => getTeamIdFromToken(),
+ 'task_uuid' => $task->uuid,
+ 'task_name' => $task->name,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json($this->removeSensitiveData($task), 200);
}
- private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function deleteTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
- $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
- if (! $deleted) {
+ $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
+ if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
+ $taskUuid = $task->uuid;
+ $taskName = $task->name;
+ $task->delete();
+
+ auditLog('api.scheduled_task.deleted', [
+ 'team_id' => getTeamIdFromToken(),
+ 'task_uuid' => $taskUuid,
+ 'task_name' => $taskName,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ ]);
+
return response()->json(['message' => 'Scheduled task deleted.']);
}
- private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function getExecutions(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@@ -238,7 +268,7 @@ private function getExecutions(Request $request, Application|Service $resource):
),
]
)]
- public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -317,7 +347,7 @@ public function scheduled_tasks_by_application_uuid(Request $request): \Illumina
),
]
)]
- public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -404,7 +434,7 @@ public function create_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -474,7 +504,7 @@ public function update_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -542,7 +572,7 @@ public function delete_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function executions_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -601,7 +631,7 @@ public function executions_by_application_uuid(Request $request): \Illuminate\Ht
),
]
)]
- public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -680,7 +710,7 @@ public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\H
),
]
)]
- public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -767,7 +797,7 @@ public function create_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -837,7 +867,7 @@ public function update_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -905,7 +935,7 @@ public function delete_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function executions_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php
index 2c62928c2..e59c40866 100644
--- a/app/Http/Controllers/Api/SecurityController.php
+++ b/app/Http/Controllers/Api/SecurityController.php
@@ -232,6 +232,13 @@ public function create_key(Request $request)
'private_key' => $request->private_key,
]);
+ auditLog('api.private_key.created', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $key->uuid,
+ 'private_key_name' => $key->name,
+ 'fingerprint' => $fingerPrint,
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => $key->uuid,
]))->setStatusCode(201);
@@ -333,6 +340,13 @@ public function update_key(Request $request)
}
$foundKey->update($request->only($allowedFields));
+ auditLog('api.private_key.updated', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $foundKey->uuid,
+ 'private_key_name' => $foundKey->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => $foundKey->uuid,
]))->setStatusCode(201);
@@ -415,8 +429,16 @@ public function delete_key(Request $request)
], 422);
}
+ $keyUuid = $key->uuid;
+ $keyName = $key->name;
$key->forceDelete();
+ auditLog('api.private_key.deleted', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $keyUuid,
+ 'private_key_name' => $keyName,
+ ]);
+
return response()->json([
'message' => 'Private Key deleted.',
]);
diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index c13c6665c..6c3b2da00 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -13,6 +13,7 @@
use App\Models\Project;
use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Stringable;
@@ -477,7 +478,7 @@ public function create_server(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -564,6 +565,14 @@ public function create_server(Request $request)
ValidateServer::dispatch($server);
}
+ auditLog('api.server.created', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'ip' => $server->ip,
+ 'is_build_server' => (bool) $request->is_build_server,
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@@ -603,6 +612,7 @@ public function create_server(Request $request)
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
+ 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
],
),
),
@@ -639,7 +649,7 @@ public function create_server(Request $request)
)]
public function update_server(Request $request)
{
- $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency'];
+ $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -647,7 +657,7 @@ public function update_server(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -665,6 +675,7 @@ public function update_server(Request $request)
'deployment_queue_limit' => 'integer|min:1',
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string',
+ 'connection_timeout' => 'integer|min:1|max:300',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -709,7 +720,7 @@ public function update_server(Request $request)
], 422);
}
- $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
+ $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
if (! empty($advancedSettings)) {
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
}
@@ -718,6 +729,13 @@ public function update_server(Request $request)
ValidateServer::dispatch($server);
}
+ auditLog('api.server.updated', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@@ -807,6 +825,9 @@ public function delete_server(Request $request)
}
}
+ $deletedUuid = $server->uuid;
+ $deletedName = $server->name;
+ $deletedIp = $server->ip;
$server->delete();
DeleteServer::dispatch(
$server->id,
@@ -816,6 +837,14 @@ public function delete_server(Request $request)
$server->team_id
);
+ auditLog('api.server.deleted', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $deletedUuid,
+ 'server_name' => $deletedName,
+ 'ip' => $deletedIp,
+ 'force' => $force,
+ ]);
+
return response()->json(['message' => 'Server deleted.']);
}
@@ -881,6 +910,12 @@ public function validate_server(Request $request)
}
ValidateServer::dispatch($server);
+ auditLog('api.server.validated', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ ]);
+
return response()->json(['message' => 'Validation started.'], 201);
}
}
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 20560635e..11a23d46c 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -486,6 +486,14 @@ public function create_service(Request $request)
StartService::dispatch($service);
}
+ auditLog('api.service.created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'service_type' => $oneClickServiceName ?? null,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -650,6 +658,14 @@ public function create_service(Request $request)
StartService::dispatch($service);
}
+ auditLog('api.service.created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'service_type' => 'docker_compose',
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -792,6 +808,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.service.deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ ]);
+
return response()->json([
'message' => 'Service deletion request queued.',
]);
@@ -1046,6 +1068,13 @@ public function update_by_uuid(Request $request)
StartService::dispatch($service);
}
+ auditLog('api.service.updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -1255,6 +1284,13 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.service.env_updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@@ -1384,6 +1420,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveData($env));
}
+ auditLog('api.service.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_count' => $updatedEnvs->count(),
+ ]);
+
return response()->json($updatedEnvs)->setStatusCode(201);
}
@@ -1506,6 +1548,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null,
]);
+ auditLog('api.service.env_created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@@ -1591,8 +1640,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404);
}
+ $envKey = $env->key;
+ $envUuid = $env->uuid;
$env->forceDelete();
+ auditLog('api.service.env_deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json(['message' => 'Environment variable deleted.']);
}
@@ -1668,6 +1726,12 @@ public function action_deploy(Request $request)
}
StartService::dispatch($service);
+ auditLog('api.service.deployed', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ ]);
+
return response()->json(
[
'message' => 'Service starting request queued.',
@@ -1759,6 +1823,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopService::dispatch($service, false, $dockerCleanup);
+ auditLog('api.service.stopped', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Service stopping request queued.',
@@ -1846,6 +1917,13 @@ public function action_restart(Request $request)
$pullLatest = $request->boolean('latest');
RestartService::dispatch($service, $pullLatest);
+ auditLog('api.service.restarted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'pull_latest' => $pullLatest,
+ ]);
+
return response()->json(
[
'message' => 'Service restarting request queued.',
@@ -2126,6 +2204,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ auditLog('api.service.storage_created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -2354,6 +2441,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
+ auditLog('api.service.storage_updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -2454,8 +2550,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.service.storage_deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
}
diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php
index 3a3f18c9c..4038fe63e 100644
--- a/app/Http/Controllers/OauthController.php
+++ b/app/Http/Controllers/OauthController.php
@@ -19,7 +19,12 @@ public function callback(string $provider)
{
try {
$oauthUser = get_socialite_provider($provider)->user();
- $user = User::whereEmail($oauthUser->email)->first();
+ $email = trim((string) $oauthUser->email);
+ if ($email === '') {
+ abort(403, 'OAuth provider did not return an email address');
+ }
+ $email = strtolower($email);
+ $user = User::whereEmail($email)->first();
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
@@ -28,7 +33,7 @@ public function callback(string $provider)
$user = User::create([
'name' => $oauthUser->name,
- 'email' => $oauthUser->email,
+ 'email' => $email,
]);
}
Auth::login($user);
diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php
index 96fbd7193..6c3dda402 100644
--- a/app/Http/Controllers/UploadController.php
+++ b/app/Http/Controllers/UploadController.php
@@ -29,6 +29,7 @@ class UploadController extends BaseController
'archive.gz',
'bz2',
'xz',
+ 'dmp',
];
public function upload(Request $request)
diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php
index ffa71b55a..ee7f25431 100644
--- a/app/Http/Controllers/Webhook/Bitbucket.php
+++ b/app/Http/Controllers/Webhook/Bitbucket.php
@@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -12,6 +13,8 @@
class Bitbucket extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -31,6 +34,16 @@ public function manual(Request $request)
$branch = data_get($payload, 'push.changes.0.new.name');
$full_name = data_get($payload, 'repository.full_name');
$commit = data_get($payload, 'push.changes.0.new.target.hash');
+ // Bitbucket webhooks ship up to 5 commits per change. Larger pushes
+ // are evaluated only on the visible 5.
+ $skip_deploy_commits = self::shouldSkipDeploy(
+ collect(data_get($payload, 'push.changes', []))
+ ->flatMap(fn ($change) => data_get($change, 'commits', []))
+ ->pluck('message')
+ ->filter()
+ ->values()
+ ->all()
+ );
if (! $branch) {
return response([
@@ -45,6 +58,8 @@ public function manual(Request $request)
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'pullrequest.id');
$pull_request_html_url = data_get($payload, 'pullrequest.links.html.href');
+ $pull_request_title = data_get($payload, 'pullrequest.title');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash');
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
@@ -58,6 +73,12 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
if (empty($webhook_secret)) {
+ auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -70,6 +91,12 @@ public function manual(Request $request)
$parts = explode('=', $x_bitbucket_token, 2);
if (count($parts) !== 2 || $parts[0] !== 'sha256') {
+ auditLogWebhookFailure('bitbucket', 'malformed_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -81,6 +108,12 @@ public function manual(Request $request)
$hash = $parts[1];
$payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
+ auditLogWebhookFailure('bitbucket', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -101,6 +134,17 @@ public function manual(Request $request)
}
if ($x_bitbucket_event === 'repo:push') {
if ($application->isDeployable()) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -118,6 +162,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'bitbucket',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => $commit,
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -134,6 +187,15 @@ public function manual(Request $request)
}
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php
new file mode 100644
index 000000000..69695e99b
--- /dev/null
+++ b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php
@@ -0,0 +1,55 @@
+ $messages
+ */
+ public static function shouldSkipDeploy(array $messages): bool
+ {
+ $messages = array_values(array_filter($messages, fn ($m) => filled($m)));
+
+ if (empty($messages)) {
+ return false;
+ }
+
+ foreach ($messages as $message) {
+ $lower = strtolower((string) $message);
+ if (! str_contains($lower, '[skip cd]') && ! str_contains($lower, '[skip ci]')) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns true if at least one non-empty message contains [skip cd] or
+ * [skip ci]. Used for PR/MR title + latest-commit signals where any one
+ * marker should trigger the skip.
+ *
+ * @param array $messages
+ */
+ public static function shouldSkipDeployAny(array $messages): bool
+ {
+ foreach ($messages as $message) {
+ if (! filled($message)) {
+ continue;
+ }
+ $lower = strtolower((string) $message);
+ if (str_contains($lower, '[skip cd]') || str_contains($lower, '[skip ci]')) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php
index 62adf5410..64807d694 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -13,6 +14,8 @@
class Gitea extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -40,12 +43,15 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitea_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
}
@@ -68,6 +74,12 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
if (empty($webhook_secret)) {
+ auditLogWebhookFailure('gitea', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitea_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -78,6 +90,12 @@ public function manual(Request $request)
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
+ auditLogWebhookFailure('gitea', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitea_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -100,6 +118,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -117,6 +146,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'gitea',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@@ -149,6 +187,15 @@ public function manual(Request $request)
if ($x_gitea_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index 4158016d0..b0e11f60c 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
@@ -16,6 +17,8 @@
class Github extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -43,12 +46,14 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
@@ -82,6 +87,12 @@ public function manual(Request $request)
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (empty($webhook_secret)) {
+ auditLogWebhookFailure('github', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'mode' => 'manual',
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -92,6 +103,12 @@ public function manual(Request $request)
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
+ auditLogWebhookFailure('github', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'mode' => 'manual',
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -114,6 +131,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -131,6 +159,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'github',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $result['deployment_uuid'],
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -180,6 +217,7 @@ public function manual(Request $request)
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
+ pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
@@ -224,6 +262,13 @@ public function normal(Request $request)
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
+ auditLogWebhookFailure('github', 'invalid_signature', [
+ 'mode' => 'app',
+ 'github_app_id' => $github_app->id,
+ 'github_app_name' => $github_app->name,
+ 'installation_target_id' => $x_github_hook_installation_target_id,
+ ]);
+
return response('Invalid signature.');
}
}
@@ -246,12 +291,14 @@ public function normal(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$id = data_get($payload, 'repository.id');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
@@ -300,6 +347,17 @@ public function normal(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -311,6 +369,17 @@ public function normal(Request $request)
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
+ if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'github',
+ 'mode' => 'app',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $result['deployment_uuid'],
+ 'commit' => data_get($payload, 'after'),
+ 'github_app_id' => $github_app->id,
+ ]);
+ }
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
@@ -360,6 +429,7 @@ public function normal(Request $request)
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
+ pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php
index 4453a0e7a..205bede8f 100644
--- a/app/Http/Controllers/Webhook/Gitlab.php
+++ b/app/Http/Controllers/Webhook/Gitlab.php
@@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -13,6 +14,8 @@
class Gitlab extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -32,6 +35,9 @@ public function manual(Request $request)
}
if (empty($x_gitlab_token)) {
+ auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
+ 'event' => $x_gitlab_event,
+ ]);
$return_payloads->push([
'status' => 'failed',
'message' => 'Invalid signature.',
@@ -58,6 +64,7 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitlab_event === 'merge_request') {
$action = data_get($payload, 'object_attributes.action');
@@ -66,6 +73,9 @@ public function manual(Request $request)
$full_name = data_get($payload, 'project.path_with_namespace');
$pull_request_id = data_get($payload, 'object_attributes.iid');
$pull_request_html_url = data_get($payload, 'object_attributes.url');
+ $pull_request_title = data_get($payload, 'object_attributes.title');
+ $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
if (! $branch) {
$return_payloads->push([
'status' => 'failed',
@@ -101,6 +111,12 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
if (empty($webhook_secret)) {
+ auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitlab_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -110,6 +126,12 @@ public function manual(Request $request)
continue;
}
if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
+ auditLogWebhookFailure('gitlab', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitlab_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -132,6 +154,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -150,6 +183,15 @@ public function manual(Request $request)
'application_name' => $application->name,
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'gitlab',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@@ -182,6 +224,15 @@ public function manual(Request $request)
if ($x_gitlab_event === 'merge_request') {
if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php
index d59adf0ca..41e70b2ce 100644
--- a/app/Http/Controllers/Webhook/Stripe.php
+++ b/app/Http/Controllers/Webhook/Stripe.php
@@ -6,6 +6,8 @@
use App\Jobs\StripeProcessJob;
use Exception;
use Illuminate\Http\Request;
+use Stripe\Exception\SignatureVerificationException;
+use Stripe\Webhook;
class Stripe extends Controller
{
@@ -14,7 +16,7 @@ public function events(Request $request)
try {
$webhookSecret = config('subscription.stripe_webhook_secret');
$signature = $request->header('Stripe-Signature');
- $event = \Stripe\Webhook::constructEvent(
+ $event = Webhook::constructEvent(
$request->getContent(),
$signature,
$webhookSecret
@@ -22,6 +24,12 @@ public function events(Request $request)
StripeProcessJob::dispatch($event);
return response('Webhook received. Cool cool cool cool cool.', 200);
+ } catch (SignatureVerificationException $e) {
+ auditLogWebhookFailure('stripe', 'invalid_signature', [
+ 'error' => $e->getMessage(),
+ ]);
+
+ return response($e->getMessage(), 400);
} catch (Exception $e) {
return response($e->getMessage(), 400);
}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 515d40c62..a584bc111 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -2,7 +2,40 @@
namespace App\Http;
+use App\Http\Middleware\ApiAbility;
+use App\Http\Middleware\ApiSensitiveData;
+use App\Http\Middleware\Authenticate;
+use App\Http\Middleware\CanAccessTerminal;
+use App\Http\Middleware\CanCreateResources;
+use App\Http\Middleware\CanUpdateResource;
+use App\Http\Middleware\CheckForcePasswordReset;
+use App\Http\Middleware\DecideWhatToDoWithUser;
+use App\Http\Middleware\EncryptCookies;
+use App\Http\Middleware\EnsureMcpEnabled;
+use App\Http\Middleware\PreventRequestsDuringMaintenance;
+use App\Http\Middleware\RedirectIfAuthenticated;
+use App\Http\Middleware\TrimStrings;
+use App\Http\Middleware\TrustHosts;
+use App\Http\Middleware\TrustProxies;
+use App\Http\Middleware\ValidateSignature;
+use App\Http\Middleware\VerifyCsrfToken;
+use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
+use Illuminate\Auth\Middleware\Authorize;
+use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
+use Illuminate\Auth\Middleware\RequirePassword;
+use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
+use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
+use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
+use Illuminate\Http\Middleware\HandleCors;
+use Illuminate\Http\Middleware\SetCacheHeaders;
+use Illuminate\Routing\Middleware\SubstituteBindings;
+use Illuminate\Routing\Middleware\ThrottleRequests;
+use Illuminate\Session\Middleware\AuthenticateSession;
+use Illuminate\Session\Middleware\StartSession;
+use Illuminate\View\Middleware\ShareErrorsFromSession;
+use Laravel\Sanctum\Http\Middleware\CheckAbilities;
+use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class Kernel extends HttpKernel
{
@@ -14,13 +47,13 @@ class Kernel extends HttpKernel
* @var array
*/
protected $middleware = [
- \App\Http\Middleware\TrustHosts::class,
- \App\Http\Middleware\TrustProxies::class,
- \Illuminate\Http\Middleware\HandleCors::class,
- \App\Http\Middleware\PreventRequestsDuringMaintenance::class,
- \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
- \App\Http\Middleware\TrimStrings::class,
- \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
+ TrustHosts::class,
+ TrustProxies::class,
+ HandleCors::class,
+ PreventRequestsDuringMaintenance::class,
+ ValidatePostSize::class,
+ TrimStrings::class,
+ ConvertEmptyStringsToNull::class,
];
@@ -31,21 +64,21 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
- \App\Http\Middleware\EncryptCookies::class,
- \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
- \Illuminate\Session\Middleware\StartSession::class,
- \Illuminate\View\Middleware\ShareErrorsFromSession::class,
- \App\Http\Middleware\VerifyCsrfToken::class,
- \Illuminate\Routing\Middleware\SubstituteBindings::class,
- \App\Http\Middleware\CheckForcePasswordReset::class,
- \App\Http\Middleware\DecideWhatToDoWithUser::class,
+ EncryptCookies::class,
+ AddQueuedCookiesToResponse::class,
+ StartSession::class,
+ ShareErrorsFromSession::class,
+ VerifyCsrfToken::class,
+ SubstituteBindings::class,
+ CheckForcePasswordReset::class,
+ DecideWhatToDoWithUser::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
- \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
- \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ ThrottleRequests::class.':api',
+ SubstituteBindings::class,
],
];
@@ -57,22 +90,23 @@ class Kernel extends HttpKernel
* @var array
*/
protected $middlewareAliases = [
- 'auth' => \App\Http\Middleware\Authenticate::class,
- 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
- 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
- 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
- 'can' => \Illuminate\Auth\Middleware\Authorize::class,
- 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
- 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
- 'signed' => \App\Http\Middleware\ValidateSignature::class,
- 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
- 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
- 'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
- 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
- 'api.ability' => \App\Http\Middleware\ApiAbility::class,
- 'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class,
- 'can.create.resources' => \App\Http\Middleware\CanCreateResources::class,
- 'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class,
- 'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class,
+ 'auth' => Authenticate::class,
+ 'auth.basic' => AuthenticateWithBasicAuth::class,
+ 'auth.session' => AuthenticateSession::class,
+ 'cache.headers' => SetCacheHeaders::class,
+ 'can' => Authorize::class,
+ 'guest' => RedirectIfAuthenticated::class,
+ 'password.confirm' => RequirePassword::class,
+ 'signed' => ValidateSignature::class,
+ 'throttle' => ThrottleRequests::class,
+ 'verified' => EnsureEmailIsVerified::class,
+ 'abilities' => CheckAbilities::class,
+ 'ability' => CheckForAnyAbility::class,
+ 'api.ability' => ApiAbility::class,
+ 'api.sensitive' => ApiSensitiveData::class,
+ 'can.create.resources' => CanCreateResources::class,
+ 'can.update.resource' => CanUpdateResource::class,
+ 'can.access.terminal' => CanAccessTerminal::class,
+ 'mcp.enabled' => EnsureMcpEnabled::class,
];
}
diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php
index 324eeebaa..f81c7d184 100644
--- a/app/Http/Middleware/ApiAbility.php
+++ b/app/Http/Middleware/ApiAbility.php
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
+use Illuminate\Auth\AuthenticationException;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class ApiAbility extends CheckForAnyAbility
@@ -14,11 +15,22 @@ public function handle($request, $next, ...$abilities)
}
return parent::handle($request, $next, ...$abilities);
- } catch (\Illuminate\Auth\AuthenticationException $e) {
+ } catch (AuthenticationException $e) {
+ auditLog('api.auth.unauthenticated', [
+ 'reason' => $e->getMessage(),
+ 'required_abilities' => $abilities,
+ ], 'warning');
+
return response()->json([
'message' => 'Unauthenticated.',
], 401);
} catch (\Exception $e) {
+ auditLog('api.auth.ability_denied', [
+ 'required_abilities' => $abilities,
+ 'token_id' => $request->user()?->currentAccessToken()?->id,
+ 'reason' => $e->getMessage(),
+ ], 'warning');
+
return response()->json([
'message' => 'Missing required permissions: '.implode(', ', $abilities),
], 403);
diff --git a/app/Http/Middleware/EnsureMcpEnabled.php b/app/Http/Middleware/EnsureMcpEnabled.php
new file mode 100644
index 000000000..9c4f1339c
--- /dev/null
+++ b/app/Http/Middleware/EnsureMcpEnabled.php
@@ -0,0 +1,25 @@
+is_mcp_server_enabled) {
+ abort(404);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 229f46cd8..6d8cf059f 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -33,6 +33,7 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
+use JsonException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
@@ -48,6 +49,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
+ private const RAILPACK_REPOSITORY_CONFIG_PATH = 'railpack.json';
+
+ private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
+
public $tries = 1;
public $timeout = 3600;
@@ -124,6 +129,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_nixpacks_args;
+ private $env_railpack_args;
+
private $docker_compose;
private $docker_compose_base64;
@@ -174,6 +181,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $dockerBuildkitSupported = false;
+ private bool $dockerBuildxAvailable = false;
+
private bool $dockerSecretsSupported = false;
private bool $skip_build = false;
@@ -414,6 +423,7 @@ private function detectBuildKitCapabilities(): void
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
$this->dockerBuildkitSupported = false;
+ $this->dockerBuildxAvailable = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
return;
@@ -427,8 +437,11 @@ private function detectBuildKitCapabilities(): void
if (trim($buildxAvailable) === 'available') {
$this->dockerBuildkitSupported = true;
+ $this->dockerBuildxAvailable = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
} else {
+ $this->dockerBuildxAvailable = false;
+
// Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
$buildkitTest = instant_remote_process(
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
@@ -461,6 +474,7 @@ private function detectBuildKitCapabilities(): void
}
} catch (Exception $e) {
$this->dockerBuildkitSupported = false;
+ $this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
}
@@ -484,8 +498,12 @@ private function decide_what_to_do()
$this->deploy_dockerfile_buildpack();
} elseif ($this->application->build_pack === 'static') {
$this->deploy_static_buildpack();
- } else {
+ } elseif ($this->application->build_pack === 'nixpacks') {
$this->deploy_nixpacks_buildpack();
+ } elseif ($this->application->build_pack === 'railpack') {
+ $this->deploy_railpack_buildpack();
+ } else {
+ throw new DeploymentException("Unsupported build pack: {$this->application->build_pack}");
}
$this->post_deployment();
}
@@ -938,6 +956,37 @@ private function deploy_nixpacks_buildpack()
$this->rolling_update();
}
+ private function deploy_railpack_buildpack(): void
+ {
+ if ($this->use_build_server) {
+ $this->server = $this->build_server;
+ }
+ $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
+ $this->prepare_builder_image();
+ $this->check_git_if_build_needed();
+ $this->generate_image_names();
+ if (! $this->force_rebuild) {
+ $this->check_image_locally_or_remotely();
+ if ($this->should_skip_build()) {
+ return;
+ }
+ }
+ $this->clone_repository();
+ $this->cleanup_git();
+ $this->generate_compose_file();
+
+ // Save build-time .env file BEFORE the build
+ $this->save_buildtime_environment_variables();
+
+ $this->generate_build_env_variables();
+ $this->build_railpack_image();
+
+ // Save runtime environment variables AFTER the build
+ $this->save_runtime_environment_variables();
+ $this->push_to_docker_registry();
+ $this->rolling_update();
+ }
+
private function deploy_static_buildpack()
{
if ($this->use_build_server) {
@@ -1105,12 +1154,15 @@ private function generate_image_names()
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
}
} elseif ($this->pull_request_id !== 0) {
+ $previewImageTag = $this->previewImageTag();
+ $previewBuildImageTag = $this->previewImageTag(build: true);
+
if ($this->application->docker_registry_image_name) {
- $this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build";
- $this->production_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}";
+ $this->build_image_name = "{$this->application->docker_registry_image_name}:{$previewBuildImageTag}";
+ $this->production_image_name = "{$this->application->docker_registry_image_name}:{$previewImageTag}";
} else {
- $this->build_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}-build";
- $this->production_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}";
+ $this->build_image_name = "{$this->application->uuid}:{$previewBuildImageTag}";
+ $this->production_image_name = "{$this->application->uuid}:{$previewImageTag}";
}
} else {
$this->dockerImageTag = str($this->commit)->substr(0, 128);
@@ -1127,6 +1179,27 @@ private function generate_image_names()
}
}
+ private function previewImageTag(bool $build = false): string
+ {
+ $prefix = "pr-{$this->pull_request_id}-";
+ $suffix = $build ? '-build' : '';
+ $maxCommitLength = max(1, 128 - strlen($prefix) - strlen($suffix));
+ $commitSource = ($this->commit === 'HEAD' || blank($this->commit))
+ ? $this->deployment_uuid
+ : $this->commit;
+
+ $commit = Str::of($commitSource)
+ ->replaceMatches('/[^A-Za-z0-9_.-]/', '-')
+ ->substr(0, $maxCommitLength)
+ ->toString();
+
+ if ($commit === '') {
+ $commit = 'HEAD';
+ }
+
+ return "{$prefix}{$commit}{$suffix}";
+ }
+
private function just_restart()
{
$this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}.");
@@ -1217,11 +1290,11 @@ private function generate_runtime_environment_variables()
$envs = collect([]);
$sort = $this->application->settings->is_env_sorting_enabled;
if ($sort) {
- $sorted_environment_variables = $this->application->environment_variables->sortBy('key');
- $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('key');
+ $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key');
+ $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('key');
} else {
- $sorted_environment_variables = $this->application->environment_variables->sortBy('id');
- $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id');
+ $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id');
+ $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
}
if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
@@ -1592,6 +1665,7 @@ private function generate_buildtime_environment_variables()
// 4. Add user-defined build-time variables LAST (highest priority - can override everything)
if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables()
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
@@ -1644,6 +1718,7 @@ private function generate_buildtime_environment_variables()
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
@@ -1983,7 +2058,11 @@ private function deploy_pull_request()
if ($this->application->build_pack === 'dockerfile') {
$this->add_build_env_variables_to_dockerfile();
}
- $this->build_image();
+ if ($this->application->build_pack === 'railpack') {
+ $this->build_railpack_image();
+ } else {
+ $this->build_image();
+ }
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
@@ -2422,7 +2501,409 @@ private function generate_nixpacks_env_variables()
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
}
- private function generate_coolify_env_variables(bool $forBuildTime = false): Collection
+ private function generate_railpack_env_variables(): Collection
+ {
+ $variables = $this->railpack_build_variables();
+
+ $this->env_railpack_args = $variables
+ ->map(function ($value, $key) {
+ return '--env '.escapeShellValue("{$key}={$value}");
+ })
+ ->implode(' ');
+
+ return $variables;
+ }
+
+ private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
+ {
+ $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
+ if (is_null($resolvedValue) || $resolvedValue === '') {
+ return null;
+ }
+
+ if ($environmentVariable->is_literal || $environmentVariable->is_multiline) {
+ return trim($resolvedValue, "'");
+ }
+
+ return $resolvedValue;
+ }
+
+ /**
+ * All buildtime variables that must reach the Railpack build.
+ *
+ * Railpack's BuildKit frontend treats every `--env` passed to `railpack prepare`
+ * as a build secret entry in the generated plan, then pairs it with `--secret id=,env=`
+ * on `docker buildx build`. Because Railpack's schema disallows top-level `variables`
+ * (unlike Nixpacks, which bakes variables into the plan), this `--env` → `--secret`
+ * channel is the only way user-defined buildtime variables become available to
+ * commands declared with `useSecrets: true`.
+ */
+ private function railpack_build_variables(): Collection
+ {
+ $genericBuildVariables = $this->pull_request_id === 0
+ ? $this->application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get()
+ : $this->application->environment_variables_preview()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get();
+
+ $railpackVariables = $this->pull_request_id === 0
+ ? $this->application->railpack_environment_variables()->get()
+ : $this->application->railpack_environment_variables_preview()->get();
+
+ $variables = $genericBuildVariables
+ ->merge($railpackVariables)
+ ->mapWithKeys(function (EnvironmentVariable $environmentVariable) {
+ $value = $this->normalize_resolved_build_variable_value($environmentVariable);
+ if (is_null($value) || $value === '') {
+ return [];
+ }
+
+ return [$environmentVariable->key => $value];
+ });
+
+ if ($this->application->install_command) {
+ $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command);
+ }
+
+ $variables = $this->merge_railpack_deploy_apt_packages($variables);
+
+ // Mirror Nixpacks behavior: expose COOLIFY_* and SOURCE_COMMIT to the build so apps
+ // (e.g. SPAs baking the public URL) can read them via /run/secrets/.
+ foreach ($this->generate_coolify_env_variables(forBuildTime: true) as $key => $value) {
+ if (! is_null($value) && $value !== '') {
+ $variables->put($key, $value);
+ }
+ }
+
+ return $variables;
+ }
+
+ private function merge_railpack_deploy_apt_packages(Collection $variables): Collection
+ {
+ $packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: [])
+ ->filter()
+ ->values();
+
+ foreach (['curl', 'wget'] as $package) {
+ if (! $packages->contains($package)) {
+ $packages->push($package);
+ }
+ }
+
+ $variables->put('RAILPACK_DEPLOY_APT_PACKAGES', $packages->implode(' '));
+
+ return $variables;
+ }
+
+ private function railpack_build_environment_prefix(Collection $variables): string
+ {
+ if ($variables->isEmpty()) {
+ return '';
+ }
+
+ return 'env '.$variables
+ ->map(function ($value, $key) {
+ return escapeShellValue("{$key}={$value}");
+ })
+ ->implode(' ').' ';
+ }
+
+ private function railpack_build_secret_flags(Collection $variables): string
+ {
+ if ($variables->isEmpty()) {
+ return '';
+ }
+
+ return ' '.$variables
+ ->map(function ($value, $key) {
+ return '--secret '.escapeShellValue("id={$key},env={$key}");
+ })
+ ->implode(' ');
+ }
+
+ private function railpack_build_command(string $imageName, Collection $variables): string
+ {
+ $cacheArgs = '';
+ if ($this->force_rebuild) {
+ $cacheArgs = '--no-cache';
+ } else {
+ $cacheArgs = "--build-arg cache-key='{$this->application->uuid}'";
+ }
+
+ if ($variables->isNotEmpty()) {
+ $cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables);
+ }
+
+ $environmentPrefix = $this->railpack_build_environment_prefix($variables);
+ $secretFlags = $this->railpack_build_secret_flags($variables);
+ $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
+
+ return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
+ ." && {$environmentPrefix}docker buildx build --builder coolify-railpack"
+ ." {$this->addHosts} --network host"
+ ." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
+ ." {$cacheArgs}"
+ ."{$secretFlags}"
+ .' -f /artifacts/railpack-plan.json'
+ .' --progress plain'
+ .' --load'
+ ." -t {$imageName}"
+ ." {$this->workdir}";
+ }
+
+ private function decode_railpack_config(string $config, string $source): array
+ {
+ try {
+ $decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception);
+ }
+
+ if (! is_array($decoded)) {
+ throw new DeploymentException("Invalid {$source}: expected a JSON object.");
+ }
+
+ return $decoded;
+ }
+
+ private function is_assoc_array(array $value): bool
+ {
+ if ($value === []) {
+ return false;
+ }
+
+ return array_keys($value) !== range(0, count($value) - 1);
+ }
+
+ private function merge_railpack_config(array $base, array $overrides): array
+ {
+ foreach ($overrides as $key => $value) {
+ if (
+ array_key_exists($key, $base)
+ && is_array($base[$key])
+ && is_array($value)
+ && $this->is_assoc_array($base[$key])
+ && $this->is_assoc_array($value)
+ ) {
+ $base[$key] = $this->merge_railpack_config($base[$key], $value);
+ } else {
+ $base[$key] = $value;
+ }
+ }
+
+ return $base;
+ }
+
+ private function railpack_config_overrides(): array
+ {
+ return [];
+ }
+
+ private function generated_railpack_config_relative_path(): string
+ {
+ return self::RAILPACK_GENERATED_CONFIG_PATH;
+ }
+
+ private function generated_railpack_config_absolute_path(): string
+ {
+ return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH;
+ }
+
+ private function generate_railpack_config_file(): ?string
+ {
+ $repositoryConfig = [];
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"),
+ 'hidden' => true,
+ 'save' => 'railpack_config_exists',
+ ]);
+
+ if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') {
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH),
+ 'hidden' => true,
+ 'save' => 'railpack_repository_config',
+ ]);
+
+ $repositoryConfig = $this->decode_railpack_config(
+ $this->saved_outputs->get('railpack_repository_config', ''),
+ 'repository railpack.json'
+ );
+ }
+
+ $overrides = $this->railpack_config_overrides();
+ if ($repositoryConfig === [] && $overrides === []) {
+ return null;
+ }
+
+ $mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides);
+ if (! array_key_exists('$schema', $mergedConfig)) {
+ $mergedConfig['$schema'] = 'https://schema.railpack.com';
+ }
+
+ try {
+ $encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception);
+ }
+
+ $configPath = $this->generated_railpack_config_absolute_path();
+ $encodedConfig = base64_encode($encodedConfig);
+
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"),
+ 'hidden' => true,
+ ]
+ );
+
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry('Generated Railpack config: '.json_encode($mergedConfig, JSON_PRETTY_PRINT), hidden: true);
+ }
+
+ return $this->generated_railpack_config_relative_path();
+ }
+
+ private function railpack_prepare_command(?string $configFilePath = null): string
+ {
+ $prepare_command = 'railpack prepare';
+
+ if ($this->application->build_command) {
+ $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
+ }
+
+ if ($this->application->start_command) {
+ $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
+ }
+
+ if ($this->env_railpack_args) {
+ $prepare_command .= " {$this->env_railpack_args}";
+ }
+
+ if ($configFilePath) {
+ $prepare_command .= ' --config-file '.escapeShellValue($configFilePath);
+ }
+
+ $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}";
+
+ return $prepare_command;
+ }
+
+ private function ensure_docker_buildx_available_for_railpack(): void
+ {
+ if ($this->dockerBuildxAvailable) {
+ return;
+ }
+
+ throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
+ }
+
+ private function build_railpack_image(): void
+ {
+ $this->ensure_docker_buildx_available_for_railpack();
+
+ $railpackVariables = $this->generate_railpack_env_variables();
+ $railpackConfigPath = $this->generate_railpack_config_file();
+
+ // Step 1: Generate build plan with railpack prepare
+ $prepare_command = $this->railpack_prepare_command($railpackConfigPath);
+
+ $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.');
+ $this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true],
+ [
+ executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'),
+ 'hidden' => true,
+ 'save' => 'railpack_plan',
+ ],
+ );
+
+ $railpackPlanRaw = $this->saved_outputs->get('railpack_plan');
+ if (! empty($railpackPlanRaw)) {
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry("Final Railpack plan: {$railpackPlanRaw}", hidden: true);
+ } else {
+ $parsedPlan = json_decode($railpackPlanRaw, true);
+ if (is_array($parsedPlan)) {
+ // Strip secrets array to avoid logging variable names in production.
+ unset($parsedPlan['secrets']);
+ $this->application_deployment_queue->addLogEntry('Final Railpack plan: '.json_encode($parsedPlan, JSON_PRETTY_PRINT), hidden: true);
+ }
+ }
+ }
+
+ // Step 2: Build image using docker buildx with railpack frontend.
+ // Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder.
+ $this->application_deployment_queue->addLogEntry('Building docker image with Railpack.');
+ $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
+
+ $image_name = $this->application->settings->is_static
+ ? $this->build_image_name
+ : $this->production_image_name;
+
+ if ($this->application->settings->is_static && $this->application->static_image) {
+ $this->pull_latest_image($this->application->static_image);
+ }
+
+ $build_command = $this->railpack_build_command($image_name, $railpackVariables);
+
+ $base64_build_command = base64_encode($build_command);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
+ 'hidden' => true,
+ ]
+ );
+
+ // Step 3: If static, copy built assets into nginx image
+ if ($this->application->settings->is_static) {
+ $this->build_railpack_static_image();
+ }
+ }
+
+ private function build_railpack_static_image(): void
+ {
+ $publishDir = trim($this->application->publish_directory, '/');
+ $publishDir = $publishDir ? "/{$publishDir}" : '';
+ $dockerfile = base64_encode("FROM {$this->application->static_image}
+WORKDIR /usr/share/nginx/html/
+LABEL coolify.deploymentId={$this->deployment_uuid}
+COPY --from={$this->build_image_name} /app{$publishDir} .
+COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
+
+ if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
+ $nginx_config = base64_encode($this->application->custom_nginx_configuration);
+ } else {
+ $nginx_config = $this->application->settings->is_spa
+ ? base64_encode(defaultNginxConfiguration('spa'))
+ : base64_encode(defaultNginxConfiguration());
+ }
+
+ $static_build = $this->dockerBuildkitSupported
+ ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"
+ : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
+
+ $base64_static_build = base64_encode($static_build);
+ $this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")],
+ [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")],
+ [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
+ );
+ }
+
+ protected function generate_coolify_env_variables(bool $forBuildTime = false): Collection
{
$coolify_envs = collect([]);
$local_branch = $this->branch;
@@ -2538,7 +3019,7 @@ private function generate_env_variables()
// For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) {
$envs = $this->application->environment_variables()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
@@ -2550,7 +3031,7 @@ private function generate_env_variables()
}
} else {
$envs = $this->application->environment_variables_preview()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
@@ -3075,29 +3556,28 @@ private function build_image()
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
- $safeNetwork = escapeshellarg($this->destination->network);
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}" : '';
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --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} {$this->addHosts} --network host -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 {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --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} {$this->addHosts} --network host -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 {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_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->build_image_name {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_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->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3638,7 +4118,7 @@ private function add_build_env_variables_to_dockerfile()
if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build
$envs = $this->application->environment_variables()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
@@ -3660,7 +4140,7 @@ private function add_build_env_variables_to_dockerfile()
} else {
// Only add preview environment variables that are available during build
$envs = $this->application->environment_variables_preview()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php
index 041cd812c..54e386676 100644
--- a/app/Jobs/ProcessGithubPullRequestWebhook.php
+++ b/app/Jobs/ProcessGithubPullRequestWebhook.php
@@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Enums\ProcessStatus;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GithubApp;
@@ -17,6 +18,7 @@
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{
+ use DetectsSkipDeployCommits;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
@@ -31,6 +33,7 @@ public function __construct(
public string $action,
public int $pullRequestId,
public string $pullRequestHtmlUrl,
+ public ?string $pullRequestTitle,
public ?string $beforeSha,
public ?string $afterSha,
public string $commitSha,
@@ -83,6 +86,10 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
return;
}
+ if (self::shouldSkipDeployAny([$this->pullRequestTitle])) {
+ return;
+ }
+
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index 7ce316dcd..98ad60fff 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -2,6 +2,7 @@
namespace App\Jobs;
+use App\Events\ServerReachabilityChanged;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Services\ConfigurationRepository;
@@ -43,6 +44,9 @@ private function disableSshMux(): void
public function handle()
{
+ $wasReachable = (bool) $this->server->settings->is_reachable;
+ $wasNotified = (bool) $this->server->unreachable_notification_sent;
+
try {
// Check if server is disabled
if ($this->server->settings->force_disabled) {
@@ -84,6 +88,8 @@ public function handle()
'server_ip' => $this->server->ip,
]);
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
return;
}
@@ -99,6 +105,8 @@ public function handle()
$this->server->update(['unreachable_count' => 0]);
}
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true);
+
} catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [
@@ -111,6 +119,8 @@ public function handle()
]);
$this->server->increment('unreachable_count');
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
return;
}
}
@@ -118,17 +128,41 @@ public function handle()
public function failed(?\Throwable $exception): void
{
if ($exception instanceof TimeoutExceededException) {
+ $wasReachable = (bool) $this->server->settings->is_reachable;
+ $wasNotified = (bool) $this->server->unreachable_notification_sent;
+
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
$this->server->increment('unreachable_count');
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
+ /**
+ * Fire ServerReachabilityChanged when state crosses the unreachable threshold (count >= 2)
+ * or when a previously-notified server recovers. Skips noise from single transient flaps.
+ */
+ private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $wasNotified, bool $isReachable): void
+ {
+ if ($isReachable) {
+ if (! $wasReachable || $wasNotified) {
+ ServerReachabilityChanged::dispatch($this->server);
+ }
+
+ return;
+ }
+
+ if ($this->server->unreachable_count >= 2 && ! $wasNotified) {
+ ServerReachabilityChanged::dispatch($this->server);
+ }
+ }
+
private function checkHetznerStatus(): void
{
$status = null;
diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php
index 3485ffe32..b031b9c7d 100644
--- a/app/Jobs/StripeProcessJob.php
+++ b/app/Jobs/StripeProcessJob.php
@@ -9,6 +9,7 @@
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
+use Stripe\StripeClient;
class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -35,7 +36,7 @@ public function handle(): void
$data = data_get($this->event, 'data.object');
switch ($type) {
case 'radar.early_fraud_warning.created':
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$id = data_get($data, 'id');
$charge = data_get($data, 'charge');
if ($charge) {
@@ -94,12 +95,12 @@ public function handle(): void
}
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
- throw new \RuntimeException("No subscription found for customer: {$customerId}");
+ break;
}
if ($subscription->stripe_subscription_id) {
try {
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
@@ -154,7 +155,7 @@ public function handle(): void
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
// send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found for customer: {$customerId}");
+ break;
}
$team = data_get($subscription, 'team');
if (! $team) {
@@ -165,7 +166,7 @@ public function handle(): void
// Verify payment status with Stripe API before sending failure notification
if ($paymentIntentId) {
try {
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
@@ -190,7 +191,7 @@ public function handle(): void
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
// send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
+ break;
}
if ($subscription->stripe_invoice_paid) {
// send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
@@ -334,7 +335,7 @@ public function handle(): void
}
} else {
// send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
+ break;
}
break;
default:
diff --git a/app/Livewire/ForcePasswordReset.php b/app/Livewire/ForcePasswordReset.php
index e6392497f..2463c68e4 100644
--- a/app/Livewire/ForcePasswordReset.php
+++ b/app/Livewire/ForcePasswordReset.php
@@ -47,14 +47,10 @@ public function submit()
try {
$this->rateLimit(10);
$this->validate();
- $firstLogin = auth()->user()->created_at == auth()->user()->updated_at;
auth()->user()->fill([
'password' => Hash::make($this->password),
'force_password_reset' => false,
])->save();
- if ($firstLogin) {
- send_internal_notification('First login for '.auth()->user()->email);
- }
return redirect()->route('dashboard');
} catch (\Throwable $e) {
diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php
index 364163ff8..724dd0bac 100644
--- a/app/Livewire/Notifications/Email.php
+++ b/app/Livewire/Notifications/Email.php
@@ -45,7 +45,7 @@ class Email extends Component
public ?string $smtpPort = null;
#[Validate(['nullable', 'string', 'in:starttls,tls,none'])]
- public ?string $smtpEncryption = null;
+ public ?string $smtpEncryption = 'starttls';
#[Validate(['nullable', 'string'])]
public ?string $smtpUsername = null;
diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php
index 954670582..c9f818e2c 100644
--- a/app/Livewire/Project/Application/Deployment/Show.php
+++ b/app/Livewire/Project/Application/Deployment/Show.php
@@ -108,19 +108,6 @@ public function getLogLinesProperty()
return decode_remote_command_output($this->application_deployment_queue);
}
- public function copyLogs(): string
- {
- $logs = decode_remote_command_output($this->application_deployment_queue)
- ->map(function ($line) {
- return $line['timestamp'].' '.
- (isset($line['command']) && $line['command'] ? '[CMD]: ' : '').
- trim($line['line']);
- })
- ->join("\n");
-
- return sanitizeLogsForExport($logs);
- }
-
public function downloadAllLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index f89d16912..258b54eed 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -606,7 +606,7 @@ public function updatedBuildPack()
// Sync property to model before checking/modifying
$this->syncData(toModel: true);
- if ($this->buildPack !== 'nixpacks') {
+ if ($this->buildPack !== 'nixpacks' && $this->buildPack !== 'railpack') {
$this->isStatic = false;
$this->application->settings->is_static = false;
$this->application->settings->save();
diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php
index 86e407136..0ce1bd1a2 100644
--- a/app/Livewire/Project/New/GithubPrivateRepository.php
+++ b/app/Livewire/Project/New/GithubPrivateRepository.php
@@ -81,9 +81,11 @@ public function updatedSelectedRepositoryId(): void
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->is_static) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->is_static = false;
diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
index 5a6f288b3..045ddc6cb 100644
--- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
+++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
@@ -94,9 +94,11 @@ public function mount()
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->is_static) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->is_static = false;
diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php
index b350538ac..9fe630d63 100644
--- a/app/Livewire/Project/New/PublicGitRepository.php
+++ b/app/Livewire/Project/New/PublicGitRepository.php
@@ -96,9 +96,11 @@ public function mount()
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->isStatic) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->isStatic = false;
diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 844e37854..2f1a229b4 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -63,13 +63,16 @@ public function mount()
$this->fs_path = $this->fileStorage->fs_path;
}
- $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
+ $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
+ if ($this->fileStorage->is_too_large) {
+ return;
+ }
$this->validate();
// Sync to model
@@ -172,6 +175,12 @@ public function submit()
{
$this->authorize('update', $this->resource);
+ if ($this->fileStorage->is_too_large) {
+ $this->dispatch('error', 'File on server is too large to edit from the UI.');
+
+ return;
+ }
+
$original = $this->fileStorage->getOriginal();
try {
$this->validate();
@@ -197,6 +206,11 @@ public function submit()
public function instantSave(): void
{
$this->authorize('update', $this->resource);
+ if ($this->fileStorage->is_too_large) {
+ $this->dispatch('error', 'File on server is too large to edit from the UI.');
+
+ return;
+ }
$this->syncData(true);
$this->dispatch('success', 'File updated.');
}
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index 6f43662d5..30655691a 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -69,7 +69,11 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
- $this->fileStorage = $this->resource->fileStorages()->get();
+ $this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) {
+ if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
+ $fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
+ }
+ });
$this->resource->load('persistentStorages.resource');
}
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
index c51b27b6a..1dcb7c781 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
@@ -2,9 +2,14 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
+use App\Models\Server;
+use App\Models\Service;
+use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -37,15 +42,23 @@ class Add extends Component
protected $listeners = ['clearAddEnv' => 'clear'];
- protected $rules = [
- 'key' => 'required|string',
- 'value' => 'nullable',
- 'is_multiline' => 'required|boolean',
- 'is_literal' => 'required|boolean',
- 'is_runtime' => 'required|boolean',
- 'is_buildtime' => 'required|boolean',
- 'comment' => 'nullable|string|max:256',
- ];
+ protected function rules(): array
+ {
+ return [
+ 'key' => ValidationPatterns::environmentVariableKeyRules(),
+ 'value' => 'nullable',
+ 'is_multiline' => 'required|boolean',
+ 'is_literal' => 'required|boolean',
+ 'is_runtime' => 'required|boolean',
+ 'is_buildtime' => 'required|boolean',
+ 'comment' => 'nullable|string|max:256',
+ ];
+ }
+
+ protected function messages(): array
+ {
+ return ValidationPatterns::environmentVariableKeyMessages('key');
+ }
protected $validationAttributes = [
'key' => 'key',
@@ -85,7 +98,7 @@ public function availableSharedVariables(): array
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@@ -116,12 +129,12 @@ public function availableSharedVariables(): array
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
@@ -131,7 +144,7 @@ public function availableSharedVariables(): array
$serverUuid = data_get($this->parameters, 'server_uuid');
if ($serverUuid) {
// If we have a specific server_uuid, show variables for that server
- $server = \App\Models\Server::where('team_id', $team->id)
+ $server = Server::where('team_id', $team->id)
->where('uuid', $serverUuid)
->first();
@@ -141,7 +154,7 @@ public function availableSharedVariables(): array
$result['server'] = $server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -149,7 +162,7 @@ public function availableSharedVariables(): array
// For application environment variables, try to use the application's destination server
$applicationUuid = data_get($this->parameters, 'application_uuid');
if ($applicationUuid) {
- $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
+ $application = Application::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $applicationUuid)
->with('destination.server')
->first();
@@ -160,7 +173,7 @@ public function availableSharedVariables(): array
$result['server'] = $application->destination->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -168,7 +181,7 @@ public function availableSharedVariables(): array
// For service environment variables, try to use the service's server
$serviceUuid = data_get($this->parameters, 'service_uuid');
if ($serviceUuid) {
- $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
+ $service = Service::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $serviceUuid)
->with('server')
->first();
@@ -179,7 +192,7 @@ public function availableSharedVariables(): array
$result['server'] = $service->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -192,6 +205,7 @@ public function availableSharedVariables(): array
public function submit()
{
+ $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
$this->validate();
$this->dispatch('saveKey', [
'key' => $this->key,
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
index f250a860b..53b55009e 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
@@ -2,7 +2,9 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\Application;
use App\Models\EnvironmentVariable;
+use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -38,7 +40,7 @@ public function mount()
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
$this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
$this->resourceClass = get_class($this->resource);
- $resourceWithPreviews = [\App\Models\Application::class];
+ $resourceWithPreviews = [Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$this->showPreview = true;
@@ -194,7 +196,7 @@ public function submit($data = null)
private function updateOrder()
{
- $variables = parseEnvFormatToArray($this->variables);
+ $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$order = 1;
foreach ($variables as $key => $value) {
$env = $this->resource->environment_variables()->where('key', $key)->first();
@@ -206,7 +208,7 @@ private function updateOrder()
}
if ($this->showPreview) {
- $previewVariables = parseEnvFormatToArray($this->variablesPreview);
+ $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
$order = 1;
foreach ($previewVariables as $key => $value) {
$env = $this->resource->environment_variables_preview()->where('key', $key)->first();
@@ -221,7 +223,7 @@ private function updateOrder()
private function handleBulkSubmit()
{
- $variables = parseEnvFormatToArray($this->variables);
+ $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$changesMade = false;
$errorOccurred = false;
@@ -241,7 +243,7 @@ private function handleBulkSubmit()
}
if ($this->showPreview) {
- $previewVariables = parseEnvFormatToArray($this->variablesPreview);
+ $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
// Try to delete removed preview variables
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
@@ -267,6 +269,7 @@ private function handleBulkSubmit()
private function handleSingleSubmit($data)
{
+ $data['key'] = ValidationPatterns::validatedEnvironmentVariableKey($data['key']);
$found = $this->resource->environment_variables()->where('key', $data['key'])->first();
if ($found) {
$this->dispatch('error', 'Environment variable already exists.');
@@ -334,6 +337,23 @@ private function deleteRemovedVariables($isPreview, $variables)
return $variablesToDelete->count();
}
+ private function normalizeEnvironmentVariables(array $variables): array
+ {
+ $normalizedVariables = [];
+
+ foreach ($variables as $key => $data) {
+ $normalizedKey = ValidationPatterns::validatedEnvironmentVariableKey((string) $key);
+
+ if (array_key_exists($normalizedKey, $normalizedVariables)) {
+ throw new \InvalidArgumentException("Duplicate environment variable key after normalization: {$normalizedKey}.");
+ }
+
+ $normalizedVariables[$normalizedKey] = $data;
+ }
+
+ return $normalizedVariables;
+ }
+
private function updateOrCreateVariables($isPreview, $variables)
{
$count = 0;
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index 4e8521f27..26369852e 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -2,12 +2,17 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Models\Project;
+use App\Models\Server;
+use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
+use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -64,23 +69,31 @@ class Show extends Component
'compose_loaded' => '$refresh',
];
- protected $rules = [
- 'key' => 'required|string',
- 'value' => 'nullable',
- 'comment' => 'nullable|string|max:256',
- 'is_multiline' => 'required|boolean',
- 'is_literal' => 'required|boolean',
- 'is_shown_once' => 'required|boolean',
- 'is_runtime' => 'required|boolean',
- 'is_buildtime' => 'required|boolean',
- 'real_value' => 'nullable',
- 'is_required' => 'required|boolean',
- ];
+ protected function rules(): array
+ {
+ return [
+ 'key' => ValidationPatterns::environmentVariableKeyRules(),
+ 'value' => 'nullable',
+ 'comment' => 'nullable|string|max:256',
+ 'is_multiline' => 'required|boolean',
+ 'is_literal' => 'required|boolean',
+ 'is_shown_once' => 'required|boolean',
+ 'is_runtime' => 'required|boolean',
+ 'is_buildtime' => 'required|boolean',
+ 'real_value' => 'nullable',
+ 'is_required' => 'required|boolean',
+ ];
+ }
+
+ protected function messages(): array
+ {
+ return ValidationPatterns::environmentVariableKeyMessages('key');
+ }
public function mount()
{
$this->syncData();
- if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) {
+ if ($this->env->getMorphClass() === SharedEnvironmentVariable::class) {
$this->isSharedVariable = true;
}
$this->parameters = get_route_parameters();
@@ -108,9 +121,11 @@ public function refresh()
public function syncData(bool $toModel = false)
{
if ($toModel) {
+ $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
+
if ($this->isSharedVariable) {
$this->validate([
- 'key' => 'required|string',
+ 'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'nullable',
'comment' => 'nullable|string|max:256',
'is_multiline' => 'required|boolean',
@@ -233,7 +248,7 @@ public function availableSharedVariables(): array
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@@ -264,12 +279,12 @@ public function availableSharedVariables(): array
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
@@ -279,7 +294,7 @@ public function availableSharedVariables(): array
$serverUuid = data_get($this->parameters, 'server_uuid');
if ($serverUuid) {
// If we have a specific server_uuid, show variables for that server
- $server = \App\Models\Server::where('team_id', $team->id)
+ $server = Server::where('team_id', $team->id)
->where('uuid', $serverUuid)
->first();
@@ -289,7 +304,7 @@ public function availableSharedVariables(): array
$result['server'] = $server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -297,7 +312,7 @@ public function availableSharedVariables(): array
// For application environment variables, try to use the application's destination server
$applicationUuid = data_get($this->parameters, 'application_uuid');
if ($applicationUuid) {
- $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
+ $application = Application::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $applicationUuid)
->with('destination.server')
->first();
@@ -308,7 +323,7 @@ public function availableSharedVariables(): array
$result['server'] = $application->destination->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -316,7 +331,7 @@ public function availableSharedVariables(): array
// For service environment variables, try to use the service's server
$serviceUuid = data_get($this->parameters, 'service_uuid');
if ($serviceUuid) {
- $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
+ $service = Service::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $serviceUuid)
->with('server')
->first();
@@ -327,7 +342,7 @@ public function availableSharedVariables(): array
$result['server'] = $service->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 84cb65ee6..3e05d9306 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -32,6 +32,8 @@ class Show extends Component
public string $port;
+ public int $connectionTimeout;
+
public ?string $validationLogs = null;
public ?string $wildcardDomain = null;
@@ -110,6 +112,7 @@ protected function rules(): array
'ip' => ['required', new ValidServerIp],
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
'port' => 'required|integer|between:1,65535',
+ 'connectionTimeout' => 'required|integer|min:1|max:300',
'validationLogs' => 'nullable',
'wildcardDomain' => 'nullable|url',
'isReachable' => 'required',
@@ -138,6 +141,10 @@ protected function messages(): array
'ip.required' => 'The IP Address field is required.',
'user.required' => 'The User field is required.',
'port.required' => 'The Port field is required.',
+ 'connectionTimeout.required' => 'The SSH Connection Timeout field is required.',
+ 'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.',
+ 'connectionTimeout.min' => 'The SSH Connection Timeout must be at least 1 second.',
+ 'connectionTimeout.max' => 'The SSH Connection Timeout must not exceed 300 seconds.',
'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.',
'sentinelToken.required' => 'The Sentinel Token field is required.',
'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.',
@@ -210,6 +217,7 @@ public function syncData(bool $toModel = false)
$this->server->validation_logs = $this->validationLogs;
$this->server->save();
+ $this->server->settings->connection_timeout = $this->connectionTimeout;
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
$this->server->settings->wildcard_domain = $this->wildcardDomain;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
@@ -237,6 +245,7 @@ public function syncData(bool $toModel = false)
$this->ip = $this->server->ip;
$this->user = $this->server->user;
$this->port = $this->server->port;
+ $this->connectionTimeout = $this->server->settings->connection_timeout;
$this->wildcardDomain = $this->server->settings->wildcard_domain;
$this->isReachable = $this->server->settings->is_reachable;
@@ -407,7 +416,7 @@ public function checkHetznerServerStatus(bool $manual = false)
return;
}
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = $serverData['status'] ?? null;
@@ -471,7 +480,7 @@ public function startHetznerServer()
return;
}
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$hetznerService->powerOnServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = 'starting';
diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php
index d31f68859..3a6237183 100644
--- a/app/Livewire/Settings/Advanced.php
+++ b/app/Livewire/Settings/Advanced.php
@@ -37,6 +37,9 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $is_wire_navigate_enabled;
+ #[Validate('boolean')]
+ public bool $is_mcp_server_enabled;
+
public function rules()
{
return [
@@ -49,6 +52,7 @@ public function rules()
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
+ 'is_mcp_server_enabled' => 'boolean',
];
}
@@ -67,6 +71,7 @@ public function mount()
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
+ $this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
}
public function submit()
@@ -150,6 +155,7 @@ public function instantSave()
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
+ $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) {
diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php
new file mode 100644
index 000000000..10d87ae92
--- /dev/null
+++ b/app/Mcp/Concerns/BuildsResponse.php
@@ -0,0 +1,225 @@
+
+ */
+ protected array $sensitiveKeys = [
+ // raw IDs / morph types (uuid is the public identifier)
+ 'id', 'team_id', 'tokenable_id', 'tokenable_type',
+ 'server_id', 'private_key_id', 'cloud_provider_token_id',
+ 'hetzner_server_id', 'environment_id', 'destination_id',
+ 'source_id', 'repository_project_id', 'application_id',
+ 'service_id', 'project_id', 'parent_id',
+ 'resourceable', 'resourceable_id', 'resourceable_type',
+ 'destination_type', 'source_type', 'tokenable',
+
+ // sentinel / observability secrets
+ 'sentinel_token', 'sentinel_custom_url',
+ 'logdrain_newrelic_license_key', 'logdrain_axiom_api_key',
+ 'logdrain_custom_config', 'logdrain_custom_config_parser',
+
+ // database passwords
+ 'postgres_password', 'dragonfly_password', 'keydb_password',
+ 'redis_password', 'mongo_initdb_root_password',
+ 'mariadb_password', 'mariadb_root_password',
+ 'mysql_password', 'mysql_root_password',
+ 'clickhouse_admin_password',
+
+ // app/env secrets
+ 'value', 'real_value', 'http_basic_auth_password',
+
+ // database connection strings embed credentials
+ 'internal_db_url', 'external_db_url', 'init_scripts',
+
+ // webhook secrets
+ 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea',
+ 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab',
+
+ // bulky / unsafe blobs
+ 'dockerfile', 'docker_compose', 'docker_compose_raw',
+ 'custom_labels', 'environment_variables',
+ 'environment_variables_preview', 'validation_logs',
+ 'server_metadata',
+ ];
+
+ /**
+ * Recursively remove sensitive keys from any nested array structure.
+ *
+ * @param array $data
+ * @return array
+ */
+ protected function scrubSensitive(array $data): array
+ {
+ $deny = array_flip($this->sensitiveKeys);
+
+ $walk = function ($value) use (&$walk, $deny) {
+ if (! is_array($value)) {
+ return $value;
+ }
+
+ $out = [];
+ foreach ($value as $key => $inner) {
+ if (is_string($key) && isset($deny[$key])) {
+ continue;
+ }
+ $out[$key] = $walk($inner);
+ }
+
+ return $out;
+ };
+
+ return $walk($data);
+ }
+
+ /**
+ * @param array|array $data
+ * @param array> $actions
+ * @param array|null $pagination
+ */
+ protected function respond(array $data, array $actions = [], ?array $pagination = null): Response
+ {
+ $payload = ['data' => $data];
+
+ if ($actions !== []) {
+ $payload['_actions'] = $actions;
+ }
+
+ if ($pagination !== null) {
+ $payload['_pagination'] = $pagination;
+ }
+
+ return Response::json($payload);
+ }
+
+ /**
+ * @return array{page:int, per_page:int, offset:int}
+ */
+ protected function paginationArgs(Request $request): array
+ {
+ $page = max(1, (int) ($request->get('page') ?? 1));
+ $perPage = (int) ($request->get('per_page') ?? $this->defaultPerPage);
+ $perPage = max(1, min($this->maxPerPage, $perPage));
+
+ return [
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'offset' => ($page - 1) * $perPage,
+ ];
+ }
+
+ /**
+ * @param array{page:int, per_page:int, offset:int} $args
+ * @return array|null
+ */
+ protected function paginationMeta(string $tool, array $args, int $total, array $extraArgs = []): ?array
+ {
+ $page = $args['page'];
+ $perPage = $args['per_page'];
+ $totalPages = (int) ceil($total / $perPage);
+
+ $meta = [
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'total' => $total,
+ 'total_pages' => $totalPages,
+ ];
+
+ if ($page < $totalPages) {
+ $meta['next'] = [
+ 'tool' => $tool,
+ 'args' => array_merge($extraArgs, ['page' => $page + 1, 'per_page' => $perPage]),
+ ];
+ }
+
+ return $meta;
+ }
+
+ /**
+ * HATEOAS-style action suggestions for an application.
+ *
+ * @return array>
+ */
+ protected function actionsForApplication(string $uuid, ?string $status = null): array
+ {
+ $actions = [
+ ['tool' => 'get_application', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
+ ];
+
+ $s = strtolower((string) $status);
+ if (str_contains($s, 'running')) {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart'];
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop'];
+ } else {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
+ }
+
+ return $actions;
+ }
+
+ /**
+ * @return array>
+ */
+ protected function actionsForDatabase(string $uuid, ?string $status = null): array
+ {
+ $actions = [
+ ['tool' => 'get_database', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
+ ];
+
+ $s = strtolower((string) $status);
+ if (str_contains($s, 'running')) {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart'];
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop'];
+ } else {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
+ }
+
+ return $actions;
+ }
+
+ /**
+ * @return array>
+ */
+ protected function actionsForService(string $uuid, ?string $status = null): array
+ {
+ $actions = [
+ ['tool' => 'get_service', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
+ ];
+
+ $s = strtolower((string) $status);
+ if (str_contains($s, 'running')) {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart'];
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop'];
+ } else {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
+ }
+
+ return $actions;
+ }
+
+ /**
+ * @return array>
+ */
+ protected function actionsForServer(string $uuid): array
+ {
+ return [
+ ['tool' => 'get_server', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
+ ];
+ }
+}
diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php
new file mode 100644
index 000000000..f75219fcf
--- /dev/null
+++ b/app/Mcp/Concerns/ResolvesTeam.php
@@ -0,0 +1,35 @@
+user();
+ if (! $user) {
+ return Response::error('Unauthenticated.');
+ }
+
+ $token = $user->currentAccessToken();
+ if (! $token) {
+ return Response::error('Invalid token.');
+ }
+
+ if ($token->can('root') || $token->can($ability)) {
+ return null;
+ }
+
+ return Response::error("Missing required permissions: {$ability}");
+ }
+
+ protected function resolveTeamId(Request $request): ?int
+ {
+ $token = $request->user()?->currentAccessToken();
+
+ return $token?->team_id;
+ }
+}
diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php
new file mode 100644
index 000000000..aff7e3f76
--- /dev/null
+++ b/app/Mcp/Servers/CoolifyServer.php
@@ -0,0 +1,50 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $uuid = $request->get('uuid');
+ if (! is_string($uuid) || $uuid === '') {
+ return Response::error('uuid argument is required.');
+ }
+
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
+ if (! $application) {
+ return Response::error("Application [{$uuid}] not found.");
+ }
+
+ // Drop relations that the server_status accessor lazy-loads — they
+ // pull in sensitive nested data (server.settings.sentinel_token, etc.)
+ $application->setRelations([]);
+ $application->makeHidden(['destination', 'source', 'additional_servers', 'environment', 'tags', 'environmentVariables']);
+
+ return $this->respond(
+ $this->scrubSensitive($application->toArray()),
+ $this->actionsForApplication($uuid, $application->status),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'uuid' => $schema->string()->description('Application UUID.')->required(),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/GetDatabase.php b/app/Mcp/Tools/GetDatabase.php
new file mode 100644
index 000000000..4eee9c961
--- /dev/null
+++ b/app/Mcp/Tools/GetDatabase.php
@@ -0,0 +1,58 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $uuid = $request->get('uuid');
+ if (! is_string($uuid) || $uuid === '') {
+ return Response::error('uuid argument is required.');
+ }
+
+ $database = queryDatabaseByUuidWithinTeam($uuid, (string) $teamId);
+ if (! $database) {
+ return Response::error("Database [{$uuid}] not found.");
+ }
+
+ // Drop relations so deep server/destination data doesn't leak.
+ $database->setRelations([]);
+ $database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']);
+
+ return $this->respond(
+ $this->scrubSensitive($database->toArray()),
+ $this->actionsForDatabase($uuid, $database->status ?? null),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'uuid' => $schema->string()->description('Database UUID.')->required(),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php
new file mode 100644
index 000000000..06e91ff57
--- /dev/null
+++ b/app/Mcp/Tools/GetInfrastructureOverview.php
@@ -0,0 +1,93 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $servers = Server::whereTeamId($teamId)
+ ->select('id', 'name', 'uuid', 'ip', 'description')
+ ->with('settings:id,server_id,is_reachable,is_usable')
+ ->get()
+ ->map(fn ($s) => [
+ 'uuid' => $s->uuid,
+ 'name' => $s->name,
+ 'ip' => $s->ip,
+ 'is_reachable' => $s->settings?->is_reachable,
+ 'is_usable' => $s->settings?->is_usable,
+ ])
+ ->values()
+ ->all();
+
+ $projects = Project::where('team_id', $teamId)->get();
+
+ $appCount = 0;
+ $serviceCount = 0;
+ $databaseCount = 0;
+ $projectSummaries = [];
+
+ foreach ($projects as $project) {
+ $apps = $project->applications()->count();
+ $services = $project->services()->count();
+ $databases = $project->databases()->count();
+
+ $appCount += $apps;
+ $serviceCount += $services;
+ $databaseCount += $databases;
+
+ $projectSummaries[] = [
+ 'uuid' => $project->uuid,
+ 'name' => $project->name,
+ 'counts' => [
+ 'applications' => $apps,
+ 'services' => $services,
+ 'databases' => $databases,
+ ],
+ ];
+ }
+
+ return $this->respond([
+ 'coolify_version' => config('constants.coolify.version'),
+ 'servers' => $servers,
+ 'projects' => $projectSummaries,
+ 'counts' => [
+ 'servers' => count($servers),
+ 'projects' => count($projectSummaries),
+ 'applications' => $appCount,
+ 'services' => $serviceCount,
+ 'databases' => $databaseCount,
+ ],
+ ]);
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [];
+ }
+}
diff --git a/app/Mcp/Tools/GetServer.php b/app/Mcp/Tools/GetServer.php
new file mode 100644
index 000000000..fc3e72f14
--- /dev/null
+++ b/app/Mcp/Tools/GetServer.php
@@ -0,0 +1,57 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $uuid = $request->get('uuid');
+ if (! is_string($uuid) || $uuid === '') {
+ return Response::error('uuid argument is required.');
+ }
+
+ $server = Server::whereTeamId($teamId)->where('uuid', $uuid)->with('settings')->first();
+ if (! $server) {
+ return Response::error("Server [{$uuid}] not found.");
+ }
+
+ $data = $this->scrubSensitive($server->toArray());
+ $data['is_reachable'] = $server->settings?->is_reachable;
+ $data['is_usable'] = $server->settings?->is_usable;
+ $data['connection_timeout'] = $server->settings?->connection_timeout;
+
+ return $this->respond($data, $this->actionsForServer($uuid));
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'uuid' => $schema->string()->description('Server UUID.')->required(),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/GetService.php b/app/Mcp/Tools/GetService.php
new file mode 100644
index 000000000..475958272
--- /dev/null
+++ b/app/Mcp/Tools/GetService.php
@@ -0,0 +1,61 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $uuid = $request->get('uuid');
+ if (! is_string($uuid) || $uuid === '') {
+ return Response::error('uuid argument is required.');
+ }
+
+ $service = Service::whereRelation('environment.project.team', 'id', $teamId)
+ ->where('uuid', $uuid)
+ ->first();
+
+ if (! $service) {
+ return Response::error("Service [{$uuid}] not found.");
+ }
+
+ $service->setRelations([]);
+ $service->makeHidden(['destination', 'source', 'environment', 'applications', 'databases', 'serviceApplications', 'serviceDatabases']);
+
+ return $this->respond(
+ $this->scrubSensitive($service->toArray()),
+ $this->actionsForService($uuid, $service->status ?? null),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'uuid' => $schema->string()->description('Service UUID.')->required(),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php
new file mode 100644
index 000000000..815edd61a
--- /dev/null
+++ b/app/Mcp/Tools/ListApplications.php
@@ -0,0 +1,77 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $tagName = $request->get('tag');
+ if ($tagName !== null && (! is_string($tagName) || trim($tagName) === '')) {
+ return Response::error('tag argument must be a non-empty string.');
+ }
+ $args = $this->paginationArgs($request);
+
+ $query = Application::ownedByCurrentTeamAPI($teamId)
+ ->when($tagName !== null, function ($query) use ($tagName) {
+ $query->whereHas('tags', fn ($q) => $q->where('name', $tagName));
+ });
+
+ $total = (clone $query)->count();
+
+ $summaries = $query
+ ->skip($args['offset'])
+ ->take($args['per_page'])
+ ->get()
+ ->map(fn ($app) => [
+ 'uuid' => $app->uuid,
+ 'name' => $app->name,
+ 'status' => $app->status,
+ 'fqdn' => $app->fqdn,
+ 'git_repository' => $app->git_repository,
+ ])
+ ->values()
+ ->all();
+
+ $extra = $tagName ? ['tag' => $tagName] : [];
+
+ return $this->respond(
+ $summaries,
+ [],
+ $this->paginationMeta('list_applications', $args, $total, $extra),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'tag' => $schema->string()->description('Optional tag name filter.'),
+ 'page' => $schema->integer()->description('Page number (default 1).'),
+ 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/ListDatabases.php b/app/Mcp/Tools/ListDatabases.php
new file mode 100644
index 000000000..7eb1fde00
--- /dev/null
+++ b/app/Mcp/Tools/ListDatabases.php
@@ -0,0 +1,69 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $args = $this->paginationArgs($request);
+
+ $projects = Project::where('team_id', $teamId)->get();
+ $databases = collect();
+ foreach ($projects as $project) {
+ $databases = $databases->merge($project->databases());
+ }
+
+ $total = $databases->count();
+
+ $summaries = $databases
+ ->sortBy('name')
+ ->slice($args['offset'], $args['per_page'])
+ ->map(fn ($db) => [
+ 'uuid' => $db->uuid,
+ 'name' => $db->name,
+ 'status' => $db->status ?? null,
+ 'type' => method_exists($db, 'type') ? $db->type() : class_basename($db),
+ ])
+ ->values()
+ ->all();
+
+ return $this->respond(
+ $summaries,
+ [],
+ $this->paginationMeta('list_databases', $args, $total),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'page' => $schema->integer()->description('Page number (default 1).'),
+ 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/ListProjects.php b/app/Mcp/Tools/ListProjects.php
new file mode 100644
index 000000000..9ce1576b9
--- /dev/null
+++ b/app/Mcp/Tools/ListProjects.php
@@ -0,0 +1,66 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $args = $this->paginationArgs($request);
+
+ $query = Project::whereTeamId($teamId);
+ $total = (clone $query)->count();
+
+ $summaries = $query
+ ->select('name', 'description', 'uuid')
+ ->orderBy('name')
+ ->skip($args['offset'])
+ ->take($args['per_page'])
+ ->get()
+ ->map(fn ($p) => [
+ 'uuid' => $p->uuid,
+ 'name' => $p->name,
+ 'description' => $p->description,
+ ])
+ ->values()
+ ->all();
+
+ return $this->respond(
+ $summaries,
+ [],
+ $this->paginationMeta('list_projects', $args, $total),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'page' => $schema->integer()->description('Page number (default 1).'),
+ 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/ListServers.php b/app/Mcp/Tools/ListServers.php
new file mode 100644
index 000000000..20250c454
--- /dev/null
+++ b/app/Mcp/Tools/ListServers.php
@@ -0,0 +1,67 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $args = $this->paginationArgs($request);
+
+ $query = Server::whereTeamId($teamId)->with('settings:id,server_id,is_reachable,is_usable');
+ $total = (clone $query)->count();
+
+ $summaries = $query
+ ->orderBy('name')
+ ->skip($args['offset'])
+ ->take($args['per_page'])
+ ->get()
+ ->map(fn ($s) => [
+ 'uuid' => $s->uuid,
+ 'name' => $s->name,
+ 'ip' => $s->ip,
+ 'is_reachable' => $s->settings?->is_reachable,
+ 'is_usable' => $s->settings?->is_usable,
+ ])
+ ->values()
+ ->all();
+
+ return $this->respond(
+ $summaries,
+ [],
+ $this->paginationMeta('list_servers', $args, $total),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'page' => $schema->integer()->description('Page number (default 1).'),
+ 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php
new file mode 100644
index 000000000..b0bff4fad
--- /dev/null
+++ b/app/Mcp/Tools/ListServices.php
@@ -0,0 +1,66 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $args = $this->paginationArgs($request);
+
+ $query = Service::whereHas('environment.project', fn ($q) => $q->where('team_id', $teamId));
+
+ $total = (clone $query)->count();
+
+ $summaries = $query
+ ->orderBy('name')
+ ->skip($args['offset'])
+ ->take($args['per_page'])
+ ->get()
+ ->map(fn ($svc) => [
+ 'uuid' => $svc->uuid,
+ 'name' => $svc->name,
+ 'status' => $svc->status ?? null,
+ ])
+ ->values()
+ ->all();
+
+ return $this->respond(
+ $summaries,
+ [],
+ $this->paginationMeta('list_services', $args, $total),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'page' => $schema->integer()->description('Page number (default 1).'),
+ 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
+ ];
+ }
+}
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 85e94bfd6..b5a0f0ea9 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -39,7 +39,7 @@
'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'],
'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'],
- 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose']],
+ 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']],
'static_image' => ['type' => 'string', 'description' => 'Static image used when static site is deployed.'],
'install_command' => ['type' => 'string', 'description' => 'Install command.'],
'build_command' => ['type' => 'string', 'description' => 'Build command.'],
@@ -886,8 +886,8 @@ public function status(): Attribute
public function customNginxConfiguration(): Attribute
{
return Attribute::make(
- set: fn ($value) => base64_encode($value),
- get: fn ($value) => base64_decode($value),
+ set: fn ($value) => is_null($value) ? null : base64_encode($value),
+ get: fn ($value) => is_null($value) ? null : base64_decode($value),
);
}
@@ -960,7 +960,7 @@ public function runtime_environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
- ->where('key', 'not like', 'NIXPACKS_%');
+ ->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables()
@@ -970,6 +970,13 @@ public function nixpacks_environment_variables()
->where('key', 'like', 'NIXPACKS_%');
}
+ public function railpack_environment_variables()
+ {
+ return $this->morphMany(EnvironmentVariable::class, 'resourceable')
+ ->where('is_preview', false)
+ ->where('key', 'like', 'RAILPACK_%');
+ }
+
public function environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
@@ -988,7 +995,7 @@ public function runtime_environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
- ->where('key', 'not like', 'NIXPACKS_%');
+ ->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables_preview()
@@ -998,6 +1005,13 @@ public function nixpacks_environment_variables_preview()
->where('key', 'like', 'NIXPACKS_%');
}
+ public function railpack_environment_variables_preview()
+ {
+ return $this->morphMany(EnvironmentVariable::class, 'resourceable')
+ ->where('is_preview', true)
+ ->where('key', 'like', 'RAILPACK_%');
+ }
+
public function scheduled_tasks(): HasMany
{
return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc');
@@ -1117,7 +1131,7 @@ public function deploymentType()
public function could_set_build_commands(): bool
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
return true;
}
diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php
index 83212267c..bfb02a470 100644
--- a/app/Models/EnvironmentVariable.php
+++ b/app/Models/EnvironmentVariable.php
@@ -3,6 +3,8 @@
namespace App\Models;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
+use App\Support\ValidationPatterns;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use OpenApi\Attributes as OA;
@@ -32,6 +34,8 @@
)]
class EnvironmentVariable extends BaseModel
{
+ public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_'];
+
protected $attributes = [
'is_runtime' => true,
'is_buildtime' => true,
@@ -74,11 +78,11 @@ class EnvironmentVariable extends BaseModel
'resourceable_id' => 'integer',
];
- protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify'];
+ protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify'];
protected static function booted()
{
- static::created(function (EnvironmentVariable $environment_variable) {
+ static::created(function (ModelsEnvironmentVariable $environment_variable) {
if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) {
$found = ModelsEnvironmentVariable::where('key', $environment_variable->key)
->where('resourceable_type', Application::class)
@@ -109,7 +113,7 @@ protected static function booted()
]);
});
- static::saving(function (EnvironmentVariable $environmentVariable) {
+ static::saving(function (ModelsEnvironmentVariable $environmentVariable) {
$environmentVariable->updateIsShared();
});
}
@@ -119,6 +123,30 @@ public function service()
return $this->belongsTo(Service::class);
}
+ public function scopeWithoutBuildpackControlVariables(Builder $query): Builder
+ {
+ foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
+ $query->where('key', 'not like', "{$prefix}%");
+ }
+
+ return $query;
+ }
+
+ public static function isBuildpackControlKey(?string $key): bool
+ {
+ if (blank($key)) {
+ return false;
+ }
+
+ foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
+ if (str($key)->startsWith($prefix)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
protected function value(): Attribute
{
return Attribute::make(
@@ -188,16 +216,10 @@ protected function isReallyRequired(): Attribute
);
}
- protected function isNixpacks(): Attribute
+ protected function isBuildpackControl(): Attribute
{
return Attribute::make(
- get: function () {
- if (str($this->key)->startsWith('NIXPACKS_')) {
- return true;
- }
-
- return false;
- }
+ get: fn () => self::isBuildpackControlKey($this->key),
);
}
@@ -349,7 +371,9 @@ private function set_environment_variables(?string $environment_variable = null)
protected function key(): Attribute
{
return Attribute::make(
- set: fn (string $value) => str($value)->trim()->replace(' ', '_')->value,
+ set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey(
+ ValidationPatterns::normalizeEnvironmentVariableKey($value)
+ ),
);
}
diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php
index 6061bc863..d5c3bfa28 100644
--- a/app/Models/InstanceSettings.php
+++ b/app/Models/InstanceSettings.php
@@ -45,6 +45,7 @@ class InstanceSettings extends Model
'is_sponsorship_popup_enabled',
'dev_helper_version',
'is_wire_navigate_enabled',
+ 'is_mcp_server_enabled',
];
protected $casts = [
@@ -67,6 +68,7 @@ class InstanceSettings extends Model
'update_check_frequency' => 'string',
'sentinel_token' => 'encrypted',
'is_wire_navigate_enabled' => 'boolean',
+ 'is_mcp_server_enabled' => 'boolean',
];
protected static function booted(): void
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index 4b5c602c2..627750232 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -10,6 +10,12 @@
class LocalFileVolume extends BaseModel
{
+ public const MAX_CONTENT_SIZE = 5_242_880;
+
+ public const BINARY_PLACEHOLDER = '[binary file]';
+
+ public const TOO_LARGE_PLACEHOLDER = '[file too large to display]';
+
protected $casts = [
// 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted',
@@ -33,7 +39,7 @@ class LocalFileVolume extends BaseModel
'is_preview_suffix_enabled',
];
- public $appends = ['is_binary'];
+ public $appends = ['is_binary', 'is_too_large'];
protected static function booted()
{
@@ -46,9 +52,14 @@ protected static function booted()
protected function isBinary(): Attribute
{
return Attribute::make(
- get: function () {
- return $this->content === '[binary file]';
- }
+ get: fn () => $this->content === self::BINARY_PLACEHOLDER
+ );
+ }
+
+ protected function isTooLarge(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER
);
}
@@ -81,10 +92,17 @@ public function loadStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
+ if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
+ $this->content = self::TOO_LARGE_PLACEHOLDER;
+ $this->is_directory = false;
+ $this->save();
+
+ return;
+ }
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
- $content = '[binary file]';
+ $content = self::BINARY_PLACEHOLDER;
}
$this->content = $content;
$this->is_directory = false;
@@ -92,6 +110,18 @@ public function loadStorageOnServer()
}
}
+ protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
+ {
+ $sizeOutput = instant_remote_process(
+ ["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"],
+ $server,
+ false,
+ );
+ $size = (int) trim((string) $sizeOutput);
+
+ return $size > self::MAX_CONTENT_SIZE;
+ }
+
public function deleteStorageOnServer()
{
$this->load(['service']);
@@ -173,9 +203,12 @@ public function saveStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK' && $this->is_directory) {
- $content = instant_remote_process(["cat {$escapedPath}"], $server, false);
+ if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
+ $this->content = self::TOO_LARGE_PLACEHOLDER;
+ } else {
+ $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
+ }
$this->is_directory = false;
- $this->content = $content;
$this->save();
FileStorageChanged::dispatch(data_get($server, 'team_id'));
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');
diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php
index 40f8e1860..0a53395d3 100644
--- a/app/Models/ScheduledTask.php
+++ b/app/Models/ScheduledTask.php
@@ -76,20 +76,14 @@ public function executions(): HasMany
return $this->hasMany(ScheduledTaskExecution::class)->orderBy('created_at', 'desc');
}
- public function server()
+ public function server(): ?Server
{
if ($this->application) {
- if ($this->application->destination && $this->application->destination->server) {
- return $this->application->destination->server;
- }
- } elseif ($this->service) {
- if ($this->service->destination && $this->service->destination->server) {
- return $this->service->destination->server;
- }
- } elseif ($this->database) {
- if ($this->database->destination && $this->database->destination->server) {
- return $this->database->destination->server;
- }
+ return $this->application->destination?->server;
+ }
+
+ if ($this->service) {
+ return $this->service->destination?->server;
}
return null;
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 06426f211..74e8ba5b0 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -1236,10 +1236,8 @@ public function isReachableChanged()
$this->refresh();
$unreachableNotificationSent = (bool) $this->unreachable_notification_sent;
$isReachable = (bool) $this->settings->is_reachable;
- if ($isReachable === true) {
- $this->unreachable_count = 0;
- $this->save();
+ if ($isReachable === true) {
if ($unreachableNotificationSent === true) {
$this->sendReachableNotification();
}
@@ -1247,28 +1245,8 @@ public function isReachableChanged()
return;
}
- $this->increment('unreachable_count');
-
- if ($this->unreachable_count === 1) {
- $this->settings->is_reachable = true;
- $this->settings->save();
-
- return;
- }
-
if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) {
- $failedChecks = 0;
- for ($i = 0; $i < 3; $i++) {
- $status = $this->serverStatus();
- sleep(5);
- if (! $status) {
- $failedChecks++;
- }
- }
-
- if ($failedChecks === 3 && ! $unreachableNotificationSent) {
- $this->sendUnreachableNotification();
- }
+ $this->sendUnreachableNotification();
}
}
diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php
index 30fc1e165..79f62f4b7 100644
--- a/app/Models/ServerSetting.php
+++ b/app/Models/ServerSetting.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
@@ -49,6 +50,7 @@
'updated_at' => ['type' => 'string'],
'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'],
'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'],
+ 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'],
]
)]
class ServerSetting extends Model
@@ -97,6 +99,7 @@ class ServerSetting extends Model
'is_terminal_enabled',
'deployment_queue_limit',
'disable_application_image_retention',
+ 'connection_timeout',
];
protected $casts = [
@@ -108,6 +111,7 @@ class ServerSetting extends Model
'is_usable' => 'boolean',
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
+ 'connection_timeout' => 'integer',
];
protected static function booted()
@@ -141,19 +145,54 @@ protected static function booted()
* Validate that a sentinel token contains only safe characters.
* Prevents OS command injection when the token is interpolated into shell commands.
*/
- public static function isValidSentinelToken(string $token): bool
+ public static function isValidSentinelToken(?string $token): bool
{
+ if ($token === null) {
+ return false;
+ }
+
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
}
- public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
+ /**
+ * Returns a valid sentinel token, regenerating it if the stored value is
+ * empty, undecryptable, or otherwise invalid. Throws only when regeneration
+ * still fails to produce a valid token.
+ */
+ public function ensureValidSentinelToken(): string
+ {
+ try {
+ $token = $this->sentinel_token;
+ } catch (DecryptException) {
+ $token = null;
+ }
+
+ if (! self::isValidSentinelToken($token)) {
+ // Clear undecryptable raw value so Eloquent's dirty-check won't try to
+ // decrypt the bad original during save().
+ $attrs = $this->getAttributes();
+ $attrs['sentinel_token'] = null;
+ $this->setRawAttributes($attrs, true);
+
+ $this->generateSentinelToken(save: true, ignoreEvent: true);
+ $this->refresh();
+ $token = $this->sentinel_token;
+ }
+
+ if (! self::isValidSentinelToken($token)) {
+ throw new \RuntimeException('Sentinel token invalid after regeneration. Allowed characters: a-z, A-Z, 0-9, dot, underscore, hyphen, plus, slash, equals.');
+ }
+
+ return $token;
+ }
+
+ public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
{
$data = [
'server_uuid' => $this->server->uuid,
];
- $token = json_encode($data);
- $encrypted = encrypt($token);
- $this->sentinel_token = $encrypted;
+ $token = encrypt(json_encode($data));
+ $this->sentinel_token = $token;
if ($save) {
if ($ignoreEvent) {
$this->saveQuietly();
diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php
index fa6fd45e0..eadc33ec2 100644
--- a/app/Models/SharedEnvironmentVariable.php
+++ b/app/Models/SharedEnvironmentVariable.php
@@ -2,6 +2,8 @@
namespace App\Models;
+use App\Support\ValidationPatterns;
+use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class SharedEnvironmentVariable extends Model
@@ -33,6 +35,13 @@ class SharedEnvironmentVariable extends Model
'value' => 'encrypted',
];
+ protected function key(): Attribute
+ {
+ return Attribute::make(
+ set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value),
+ );
+ }
+
public function team()
{
return $this->belongsTo(Team::class);
diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php
index d6b4d1a1c..d12a15a7c 100644
--- a/app/Models/StandaloneDocker.php
+++ b/app/Models/StandaloneDocker.php
@@ -134,8 +134,11 @@ public function databases()
$mongodbs = $this->mongodbs;
$mysqls = $this->mysqls;
$mariadbs = $this->mariadbs;
+ $keydbs = $this->keydbs;
+ $dragonflies = $this->dragonflies;
+ $clickhouses = $this->clickhouses;
- return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs);
+ return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses);
}
public function attachedTo()
diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php
index 0caa3a3a9..c29f7fc41 100644
--- a/app/Providers/HorizonServiceProvider.php
+++ b/app/Providers/HorizonServiceProvider.php
@@ -3,9 +3,12 @@
namespace App\Providers;
use App\Contracts\CustomJobRepositoryInterface;
+use App\Exceptions\DeploymentException;
use App\Models\ApplicationDeploymentQueue;
use App\Models\User;
use App\Repositories\CustomJobRepository;
+use Illuminate\Queue\Events\JobFailed;
+use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Contracts\JobRepository;
@@ -48,6 +51,26 @@ public function boot(): void
]);
}
});
+
+ Event::listen(function (JobFailed $event) {
+ if (! isCloud()) {
+ return;
+ }
+
+ $exception = $event->exception;
+ if (! ($exception instanceof DeploymentException) && ! ($exception instanceof TimeoutExceededException)) {
+ return;
+ }
+
+ try {
+ $uuid = $event->job->uuid();
+ if ($uuid) {
+ app(JobRepository::class)->deleteFailed($uuid);
+ }
+ } catch (\Throwable $e) {
+ // Best-effort scrub; never mask the original failure.
+ }
+ });
}
protected function gate(): void
diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
index 58dbbe1ac..07926e1cf 100644
--- a/app/Support/ValidationPatterns.php
+++ b/app/Support/ValidationPatterns.php
@@ -82,6 +82,12 @@ class ValidationPatterns
*/
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
+ /**
+ * Pattern for Docker-compatible environment variable keys.
+ * Docker environment entries are KEY=value strings, so keys must be non-empty and cannot contain '=' or NUL.
+ */
+ public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[^=\x00]+\z/u';
+
/**
* Pattern for SQL-safe unquoted database identifiers (usernames, database names).
* Allows letters, digits, underscore; first char must be letter or underscore.
@@ -96,6 +102,67 @@ class ValidationPatterns
*/
public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/';
+ /**
+ * Normalize environment variable keys before validation and storage.
+ */
+ public static function normalizeEnvironmentVariableKey(string $value): string
+ {
+ return str($value)->trim()->value;
+ }
+
+ /**
+ * Get validation rules for environment variable keys.
+ */
+ public static function environmentVariableKeyRules(bool $required = true, int $maxLength = 255): array
+ {
+ $rules = [];
+
+ if ($required) {
+ $rules[] = 'required';
+ } else {
+ $rules[] = 'nullable';
+ }
+
+ $rules[] = 'string';
+ $rules[] = "max:$maxLength";
+ $rules[] = 'regex:'.self::ENVIRONMENT_VARIABLE_KEY_PATTERN;
+
+ return $rules;
+ }
+
+ /**
+ * Get validation messages for environment variable key fields.
+ */
+ public static function environmentVariableKeyMessages(string $field = 'key', string $label = 'key'): array
+ {
+ return [
+ "{$field}.regex" => "The {$label} must be a non-empty Docker-compatible environment variable key and cannot contain '=' or NUL characters.",
+ "{$field}.max" => "The {$label} may not be greater than :max characters.",
+ ];
+ }
+
+ /**
+ * Check if a string is a valid environment variable key.
+ */
+ public static function isValidEnvironmentVariableKey(string $value): bool
+ {
+ return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1;
+ }
+
+ /**
+ * Normalize and validate an environment variable key.
+ */
+ public static function validatedEnvironmentVariableKey(string $value, string $label = 'key'): string
+ {
+ $key = self::normalizeEnvironmentVariableKey($value);
+
+ if (! self::isValidEnvironmentVariableKey($key)) {
+ throw new \InvalidArgumentException(self::environmentVariableKeyMessages(label: $label)['key.regex']);
+ }
+
+ return $key;
+ }
+
/**
* Get validation rules for database identifier fields (username, database name).
*
diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php
index 7ed82cc91..20b3752f5 100644
--- a/app/Traits/HasMetrics.php
+++ b/app/Traits/HasMetrics.php
@@ -2,7 +2,9 @@
namespace App\Traits;
-use App\Models\ServerSetting;
+use App\Models\Server;
+use Illuminate\Contracts\Encryption\DecryptException;
+use Illuminate\Support\Facades\Log;
trait HasMetrics
{
@@ -28,9 +30,15 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$endpoint = $this->getMetricsEndpoint($type, $from);
- $token = $server->settings->sentinel_token;
- if (! ServerSetting::isValidSentinelToken($token)) {
- throw new \Exception('Invalid sentinel token format. Please regenerate the token.');
+ $previousToken = null;
+ try {
+ $previousToken = $server->settings->sentinel_token;
+ } catch (DecryptException) {
+ // fall through to ensureValidSentinelToken which will regenerate
+ }
+ $token = $server->settings->ensureValidSentinelToken();
+ if ($token !== $previousToken) {
+ Log::warning('Regenerated sentinel token during metrics read; sentinel container restart required', ['server_id' => $server->id]);
}
$response = instant_remote_process(
@@ -61,10 +69,10 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
private function isServerMetrics(): bool
{
- return $this instanceof \App\Models\Server;
+ return $this instanceof Server;
}
- private function getMetricsServer(): \App\Models\Server
+ private function getMetricsServer(): Server
{
return $this->isServerMetrics() ? $this : $this->destination->server;
}
diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php
index 48e0a8c78..4707b0a07 100644
--- a/bootstrap/helpers/applications.php
+++ b/bootstrap/helpers/applications.php
@@ -12,8 +12,9 @@
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
-function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
+function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
{
+ $commit = $commit ?: ($application->git_commit_sha ?: 'HEAD');
$application_id = $application->id;
$deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}");
$deployment_url = $deployment_link->getPath();
diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php
new file mode 100644
index 000000000..8477450c4
--- /dev/null
+++ b/bootstrap/helpers/audit.php
@@ -0,0 +1,81 @@
+ $context Identifiers + outcome details.
+ * @param string $level Log level: info | warning | error.
+ */
+ function auditLog(string $event, array $context = [], string $level = 'info'): void
+ {
+ try {
+ $request = app()->bound('request') ? request() : null;
+ $user = auth()->check() ? auth()->user() : null;
+ $token = $user?->currentAccessToken();
+
+ $base = [
+ 'event' => $event,
+ 'ip' => $request?->ip(),
+ 'ua' => substr((string) $request?->userAgent(), 0, 200),
+ 'user_id' => $user?->id,
+ 'user_email' => $user?->email,
+ 'team_id' => $token ? data_get($token, 'team_id') : null,
+ 'token_id' => $token?->id ?? null,
+ 'token_name' => $token?->name ?? null,
+ 'method' => $request?->method(),
+ 'path' => $request?->path(),
+ ];
+
+ $payload = array_merge($base, $context);
+
+ Log::channel('audit')->{$level}($event, $payload);
+ } catch (Throwable $e) {
+ // Audit logging must never break the request path.
+ try {
+ Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]);
+ } catch (Throwable) {
+ }
+ }
+ }
+}
+
+if (! function_exists('auditLogWebhookFailure')) {
+ /**
+ * Record a webhook signature/auth verification failure to the `audit` channel.
+ */
+ function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void
+ {
+ try {
+ $request = app()->bound('request') ? request() : null;
+
+ $event = "webhook.{$provider}.signature_failed";
+
+ $base = [
+ 'event' => $event,
+ 'reason' => $reason,
+ 'ip' => $request?->ip(),
+ 'ua' => substr((string) $request?->userAgent(), 0, 200),
+ 'method' => $request?->method(),
+ 'path' => $request?->path(),
+ 'event_header' => $request?->header('X-GitHub-Event')
+ ?? $request?->header('X-Gitlab-Event')
+ ?? $request?->header('X-Gitea-Event')
+ ?? $request?->header('X-Event-Key'),
+ ];
+
+ Log::channel('audit')->warning($event, array_merge($base, $context));
+ } catch (Throwable $e) {
+ try {
+ Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]);
+ } catch (Throwable) {
+ }
+ }
+ }
+}
diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php
index ee6a3bc03..e395f3faf 100644
--- a/bootstrap/helpers/constants.php
+++ b/bootstrap/helpers/constants.php
@@ -1,7 +1,26 @@
';
const DATABASE_TYPES = ['postgresql', 'redis', 'mongodb', 'mysql', 'mariadb', 'keydb', 'dragonfly', 'clickhouse'];
+const STANDALONE_DATABASE_MODELS = [
+ 'postgresql' => StandalonePostgresql::class,
+ 'redis' => StandaloneRedis::class,
+ 'mongodb' => StandaloneMongodb::class,
+ 'mysql' => StandaloneMysql::class,
+ 'mariadb' => StandaloneMariadb::class,
+ 'keydb' => StandaloneKeydb::class,
+ 'dragonfly' => StandaloneDragonfly::class,
+ 'clickhouse' => StandaloneClickhouse::class,
+];
const VALID_CRON_STRINGS = [
'every_minute' => '* * * * *',
'hourly' => '0 * * * *',
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 881211513..860b550dd 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -1058,44 +1058,17 @@ function getResourceByUuid(string $uuid, ?int $teamId = null)
}
function queryDatabaseByUuidWithinTeam(string $uuid, string $teamId)
{
- $postgresql = StandalonePostgresql::whereUuid($uuid)->first();
- if ($postgresql && $postgresql->team()->id == $teamId) {
- return $postgresql->unsetRelation('environment');
- }
- $redis = StandaloneRedis::whereUuid($uuid)->first();
- if ($redis && $redis->team()->id == $teamId) {
- return $redis->unsetRelation('environment');
- }
- $mongodb = StandaloneMongodb::whereUuid($uuid)->first();
- if ($mongodb && $mongodb->team()->id == $teamId) {
- return $mongodb->unsetRelation('environment');
- }
- $mysql = StandaloneMysql::whereUuid($uuid)->first();
- if ($mysql && $mysql->team()->id == $teamId) {
- return $mysql->unsetRelation('environment');
- }
- $mariadb = StandaloneMariadb::whereUuid($uuid)->first();
- if ($mariadb && $mariadb->team()->id == $teamId) {
- return $mariadb->unsetRelation('environment');
- }
- $keydb = StandaloneKeydb::whereUuid($uuid)->first();
- if ($keydb && $keydb->team()->id == $teamId) {
- return $keydb->unsetRelation('environment');
- }
- $dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
- if ($dragonfly && $dragonfly->team()->id == $teamId) {
- return $dragonfly->unsetRelation('environment');
- }
- $clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
- if ($clickhouse && $clickhouse->team()->id == $teamId) {
- return $clickhouse->unsetRelation('environment');
+ foreach (STANDALONE_DATABASE_MODELS as $modelClass) {
+ $database = $modelClass::whereUuid($uuid)->first();
+ if ($database && $database->team()->id == $teamId) {
+ return $database->unsetRelation('environment');
+ }
}
return null;
}
function queryResourcesByUuid(string $uuid)
{
- $resource = null;
$application = Application::whereUuid($uuid)->first();
if ($application) {
return $application;
@@ -1104,37 +1077,11 @@ function queryResourcesByUuid(string $uuid)
if ($service) {
return $service;
}
- $postgresql = StandalonePostgresql::whereUuid($uuid)->first();
- if ($postgresql) {
- return $postgresql;
- }
- $redis = StandaloneRedis::whereUuid($uuid)->first();
- if ($redis) {
- return $redis;
- }
- $mongodb = StandaloneMongodb::whereUuid($uuid)->first();
- if ($mongodb) {
- return $mongodb;
- }
- $mysql = StandaloneMysql::whereUuid($uuid)->first();
- if ($mysql) {
- return $mysql;
- }
- $mariadb = StandaloneMariadb::whereUuid($uuid)->first();
- if ($mariadb) {
- return $mariadb;
- }
- $keydb = StandaloneKeydb::whereUuid($uuid)->first();
- if ($keydb) {
- return $keydb;
- }
- $dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
- if ($dragonfly) {
- return $dragonfly;
- }
- $clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
- if ($clickhouse) {
- return $clickhouse;
+ foreach (STANDALONE_DATABASE_MODELS as $modelClass) {
+ $database = $modelClass::whereUuid($uuid)->first();
+ if ($database) {
+ return $database;
+ }
}
// Check for ServiceDatabase by its own UUID
@@ -1143,7 +1090,7 @@ function queryResourcesByUuid(string $uuid)
return $serviceDatabase;
}
- return $resource;
+ return null;
}
function generateTagDeployWebhook($tag_name)
{
@@ -1453,23 +1400,23 @@ function generateEnvValue(string $command, Service|Application|null $service = n
break;
// This is base64,
case 'REALBASE64_64':
- $generatedValue = base64_encode(Str::random(64));
+ $generatedValue = base64_encode(random_bytes(64));
break;
case 'REALBASE64_128':
- $generatedValue = base64_encode(Str::random(128));
+ $generatedValue = base64_encode(random_bytes(128));
break;
case 'REALBASE64':
case 'REALBASE64_32':
- $generatedValue = base64_encode(Str::random(32));
+ $generatedValue = base64_encode(random_bytes(32));
break;
case 'HEX_32':
- $generatedValue = bin2hex(Str::random(32));
+ $generatedValue = bin2hex(random_bytes(16));
break;
case 'HEX_64':
- $generatedValue = bin2hex(Str::random(64));
+ $generatedValue = bin2hex(random_bytes(32));
break;
case 'HEX_128':
- $generatedValue = bin2hex(Str::random(128));
+ $generatedValue = bin2hex(random_bytes(64));
break;
case 'USER':
$generatedValue = Str::random(16);
@@ -3532,10 +3479,10 @@ function wireNavigate(): string
try {
$settings = instanceSettings();
- // Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled
- return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : '';
+ // Return wire:navigate for SPA navigation with prefetching, or empty string if disabled
+ return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate' : '';
} catch (Exception $e) {
- return 'wire:navigate.hover';
+ return 'wire:navigate';
}
}
diff --git a/composer.json b/composer.json
index e2b16b31b..9415aa624 100644
--- a/composer.json
+++ b/composer.json
@@ -18,6 +18,7 @@
"laravel/fortify": "^1.34.0",
"laravel/framework": "^12.49.0",
"laravel/horizon": "^5.43.0",
+ "laravel/mcp": "^0.6.7",
"laravel/nightwatch": "^1.24",
"laravel/pail": "^1.2.4",
"laravel/prompts": "^0.3.11|^0.3.11|^0.3.11",
diff --git a/composer.lock b/composer.lock
index 2f27235f5..5947a1588 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "40bddea995c1744e4aec517263109a2f",
+ "content-hash": "64b77285a7140ce68e83db2659e9a21d",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -2066,6 +2066,79 @@
},
"time": "2026-03-18T14:14:59+00:00"
},
+ {
+ "name": "laravel/mcp",
+ "version": "v0.6.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/mcp.git",
+ "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/c3775e57b95d7eadb580d543689d9971ec8721f2",
+ "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "illuminate/console": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/container": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/http": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/json-schema": "^12.41.1|^13.0",
+ "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/support": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/validation": "^11.45.3|^12.41.1|^13.0",
+ "php": "^8.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.20",
+ "orchestra/testbench": "^9.15|^10.8|^11.0",
+ "pestphp/pest": "^3.8.5|^4.3.2",
+ "phpstan/phpstan": "^2.1.27",
+ "rector/rector": "^2.2.4"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
+ },
+ "providers": [
+ "Laravel\\Mcp\\Server\\McpServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Mcp\\": "src/",
+ "Laravel\\Mcp\\Server\\": "src/Server/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Rapidly build MCP servers for your Laravel applications.",
+ "homepage": "https://github.com/laravel/mcp",
+ "keywords": [
+ "laravel",
+ "mcp"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/mcp/issues",
+ "source": "https://github.com/laravel/mcp"
+ },
+ "time": "2026-04-15T08:30:42+00:00"
+ },
{
"name": "laravel/nightwatch",
"version": "v1.24.4",
@@ -5156,16 +5229,16 @@
},
{
"name": "phpseclib/phpseclib",
- "version": "3.0.51",
+ "version": "3.0.52",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
- "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748"
+ "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748",
- "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748",
+ "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce",
+ "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce",
"shasum": ""
},
"require": {
@@ -5246,7 +5319,7 @@
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
- "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51"
+ "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
},
"funding": [
{
@@ -5262,7 +5335,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-10T01:33:53+00:00"
+ "time": "2026-04-27T07:02:15+00:00"
},
{
"name": "phpstan/phpdoc-parser",
@@ -13738,79 +13811,6 @@
},
"time": "2026-03-21T11:50:49+00:00"
},
- {
- "name": "laravel/mcp",
- "version": "v0.6.4",
- "source": {
- "type": "git",
- "url": "https://github.com/laravel/mcp.git",
- "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/laravel/mcp/zipball/f822c5eb5beed19adb2e5bfe2f46f8c977ecea42",
- "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42",
- "shasum": ""
- },
- "require": {
- "ext-json": "*",
- "ext-mbstring": "*",
- "illuminate/console": "^11.45.3|^12.41.1|^13.0",
- "illuminate/container": "^11.45.3|^12.41.1|^13.0",
- "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
- "illuminate/http": "^11.45.3|^12.41.1|^13.0",
- "illuminate/json-schema": "^12.41.1|^13.0",
- "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
- "illuminate/support": "^11.45.3|^12.41.1|^13.0",
- "illuminate/validation": "^11.45.3|^12.41.1|^13.0",
- "php": "^8.2"
- },
- "require-dev": {
- "laravel/pint": "^1.20",
- "orchestra/testbench": "^9.15|^10.8|^11.0",
- "pestphp/pest": "^3.8.5|^4.3.2",
- "phpstan/phpstan": "^2.1.27",
- "rector/rector": "^2.2.4"
- },
- "type": "library",
- "extra": {
- "laravel": {
- "aliases": {
- "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
- },
- "providers": [
- "Laravel\\Mcp\\Server\\McpServiceProvider"
- ]
- }
- },
- "autoload": {
- "psr-4": {
- "Laravel\\Mcp\\": "src/",
- "Laravel\\Mcp\\Server\\": "src/Server/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Taylor Otwell",
- "email": "taylor@laravel.com"
- }
- ],
- "description": "Rapidly build MCP servers for your Laravel applications.",
- "homepage": "https://github.com/laravel/mcp",
- "keywords": [
- "laravel",
- "mcp"
- ],
- "support": {
- "issues": "https://github.com/laravel/mcp/issues",
- "source": "https://github.com/laravel/mcp"
- },
- "time": "2026-03-19T12:37:13+00:00"
- },
{
"name": "laravel/pint",
"version": "v1.29.0",
@@ -17311,5 +17311,5 @@
"php": "^8.4"
},
"platform-dev": {},
- "plugin-api-version": "2.9.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/conductor.json b/conductor.json
deleted file mode 100644
index 688de3a90..000000000
--- a/conductor.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "scripts": {
- "setup": "./scripts/conductor-setup.sh",
- "run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down"
- },
- "runScriptMode": "nonconcurrent"
-}
\ No newline at end of file
diff --git a/config/constants.php b/config/constants.php
index 743b5e38c..5497c0102 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,9 +2,10 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.474',
+ 'version' => '4.1.0',
'helper_version' => '1.0.13',
- 'realtime_version' => '1.0.13',
+ 'realtime_version' => '1.0.15',
+ 'railpack_version' => '0.22.0',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
diff --git a/config/logging.php b/config/logging.php
index 1dbb1135f..05cf8e13d 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -132,6 +132,14 @@
'level' => 'warning',
'days' => 14,
],
+
+ 'audit' => [
+ 'driver' => 'daily',
+ 'path' => storage_path('logs/audit.log'),
+ 'level' => env('LOG_AUDIT_LEVEL', 'info'),
+ 'days' => env('LOG_AUDIT_DAYS', 90),
+ 'replace_placeholders' => true,
+ ],
],
];
diff --git a/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php b/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php
new file mode 100644
index 000000000..f24548142
--- /dev/null
+++ b/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php
@@ -0,0 +1,28 @@
+boolean('is_mcp_server_enabled')->default(false);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('instance_settings', function (Blueprint $table) {
+ $table->dropColumn('is_mcp_server_enabled');
+ });
+ }
+};
diff --git a/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php
new file mode 100644
index 000000000..1700feebc
--- /dev/null
+++ b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php
@@ -0,0 +1,22 @@
+integer('connection_timeout')->default(10)->after('deployment_queue_limit');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('server_settings', function (Blueprint $table) {
+ $table->dropColumn('connection_timeout');
+ });
+ }
+};
diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php
index 2a0273e0f..212bcce79 100644
--- a/database/seeders/ApplicationSeeder.php
+++ b/database/seeders/ApplicationSeeder.php
@@ -47,6 +47,22 @@ public function run(): void
'source_id' => 1,
'source_type' => GithubApp::class,
]);
+ Application::create([
+ 'uuid' => 'railpack-nodejs',
+ 'name' => 'Railpack NodeJS Fastify Example',
+ 'fqdn' => 'http://railpack-nodejs.127.0.0.1.sslip.io',
+ 'repository_project_id' => 603035348,
+ 'git_repository' => 'coollabsio/coolify-examples',
+ 'git_branch' => 'v4.x',
+ 'base_directory' => '/nodejs',
+ 'build_pack' => 'railpack',
+ 'ports_exposes' => '3000',
+ 'environment_id' => 1,
+ 'destination_id' => 0,
+ 'destination_type' => StandaloneDocker::class,
+ 'source_id' => 1,
+ 'source_type' => GithubApp::class,
+ ]);
Application::create([
'uuid' => 'dockerfile',
'name' => 'Dockerfile Example',
@@ -145,5 +161,21 @@ public function run(): void
'source_id' => 1,
'source_type' => GitlabApp::class,
]);
+ Application::create([
+ 'uuid' => 'railpack-static',
+ 'name' => 'Railpack Static Example',
+ 'fqdn' => 'http://railpack-static.127.0.0.1.sslip.io',
+ 'repository_project_id' => 603035348,
+ 'git_repository' => 'coollabsio/coolify-examples',
+ 'git_branch' => 'v4.x',
+ 'base_directory' => '/static',
+ 'build_pack' => 'railpack',
+ 'ports_exposes' => '80',
+ 'environment_id' => 1,
+ 'destination_id' => 0,
+ 'destination_type' => StandaloneDocker::class,
+ 'source_id' => 1,
+ 'source_type' => GithubApp::class,
+ ]);
}
}
diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php
index 87236df8a..e8be0ba70 100644
--- a/database/seeders/ApplicationSettingsSeeder.php
+++ b/database/seeders/ApplicationSettingsSeeder.php
@@ -22,5 +22,12 @@ public function run(): void
$gitlabPublic->settings->is_static = true;
$gitlabPublic->settings->save();
}
+
+ $railpackStatic = Application::where('uuid', 'railpack-static')->first();
+ if ($railpackStatic) {
+ $railpackStatic->load(['settings']);
+ $railpackStatic->settings->is_static = true;
+ $railpackStatic->settings->save();
+ }
}
}
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 57ccab4ae..4f5c4431a 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -31,5 +31,11 @@ public function run(): void
CaSslCertSeeder::class,
PersonalAccessTokenSeeder::class,
]);
+
+ if (in_array(config('app.env'), ['local', 'development', 'dev'], true)) {
+ $this->call([
+ DevelopmentRailpackExamplesSeeder::class,
+ ]);
+ }
}
}
diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php
new file mode 100644
index 000000000..78659b457
--- /dev/null
+++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php
@@ -0,0 +1,513 @@
+isDevelopmentEnvironment()) {
+ $this->command?->warn('Skipping DevelopmentRailpackExamplesSeeder outside development mode.');
+
+ return;
+ }
+
+ $this->ensureDevelopmentPrerequisitesExist();
+ $destination = StandaloneDocker::query()->find(0);
+
+ if (! $destination) {
+ throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.');
+ }
+
+ $environment = $this->prepareEnvironment();
+
+ foreach (self::examples() as $example) {
+ $this->upsertApplication($environment, $destination, $example);
+ }
+ }
+
+ /**
+ * @return array>
+ */
+ public static function examples(): array
+ {
+ return [
+ [
+ 'uuid' => 'railpack-simple-webserver',
+ 'name' => 'Railpack Simple Webserver Example',
+ 'base_directory' => '/node/simple-webserver',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-expressjs',
+ 'name' => 'Railpack Express.js Example',
+ 'base_directory' => '/node/expressjs',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-fastify',
+ 'name' => 'Railpack Fastify Example',
+ 'base_directory' => '/node/fastify',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-nestjs',
+ 'name' => 'Railpack NestJS Example',
+ 'base_directory' => '/node/nestjs',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start:prod',
+ ],
+ [
+ 'uuid' => 'railpack-adonisjs',
+ 'name' => 'Railpack AdonisJS Example',
+ 'base_directory' => '/node/adonisjs',
+ 'ports_exposes' => '3333',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-hono',
+ 'name' => 'Railpack Hono Example',
+ 'base_directory' => '/node/hono',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-koa',
+ 'name' => 'Railpack Koa Example',
+ 'base_directory' => '/node/koa',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-nextjs-ssr',
+ 'name' => 'Railpack Next.js SSR Example',
+ 'base_directory' => '/node/nextjs/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-nuxtjs-ssr',
+ 'name' => 'Railpack NuxtJS SSR Example',
+ 'base_directory' => '/node/nuxtjs/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run preview -- --host 0.0.0.0 --port 3000',
+ ],
+ [
+ 'uuid' => 'railpack-astro-ssr',
+ 'name' => 'Railpack Astro SSR Example',
+ 'base_directory' => '/node/astro/ssr',
+ 'ports_exposes' => '4321',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-sveltekit-ssr',
+ 'name' => 'Railpack SvelteKit SSR Example',
+ 'base_directory' => '/node/sveltekit/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-tanstack-start-ssr',
+ 'name' => 'Railpack TanStack Start SSR Example',
+ 'base_directory' => '/node/tanstack-start/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-angular-ssr',
+ 'name' => 'Railpack Angular SSR Example',
+ 'base_directory' => '/node/angular/ssr',
+ 'ports_exposes' => '4000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-vue-ssr',
+ 'name' => 'Railpack Vue SSR Example',
+ 'base_directory' => '/node/vue/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-qwik-ssr',
+ 'name' => 'Railpack Qwik SSR Example',
+ 'base_directory' => '/node/qwik/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run serve',
+ ],
+ [
+ 'uuid' => 'railpack-react-static',
+ 'name' => 'Railpack React Static Example',
+ 'base_directory' => '/node/react',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-vite-static',
+ 'name' => 'Railpack Vite Static Example',
+ 'base_directory' => '/node/vite',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-eleventy-static',
+ 'name' => 'Railpack Eleventy Static Example',
+ 'base_directory' => '/node/eleventy',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/_site',
+ 'is_static' => true,
+ ],
+ [
+ 'uuid' => 'railpack-gatsby-static',
+ 'name' => 'Railpack Gatsby Static Example',
+ 'base_directory' => '/node/gatsby',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/public',
+ 'is_static' => true,
+ ],
+ [
+ 'uuid' => 'railpack-nextjs-static',
+ 'name' => 'Railpack Next.js Static Example',
+ 'base_directory' => '/node/nextjs/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/out',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-nuxtjs-static',
+ 'name' => 'Railpack NuxtJS Static Example',
+ 'base_directory' => '/node/nuxtjs/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/.output/public',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-astro-static',
+ 'name' => 'Railpack Astro Static Example',
+ 'base_directory' => '/node/astro/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ ],
+ [
+ 'uuid' => 'railpack-sveltekit-static',
+ 'name' => 'Railpack SvelteKit Static Example',
+ 'base_directory' => '/node/sveltekit/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/build',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-tanstack-start-static',
+ 'name' => 'Railpack TanStack Start Static Example',
+ 'base_directory' => '/node/tanstack-start/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/.output/public',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-angular-static',
+ 'name' => 'Railpack Angular Static Example',
+ 'base_directory' => '/node/angular/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist/static/browser',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-vue-static',
+ 'name' => 'Railpack Vue Static Example',
+ 'base_directory' => '/node/vue/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-qwik-static',
+ 'name' => 'Railpack Qwik Static Example',
+ 'base_directory' => '/node/qwik/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ // Multi-language examples (only available on v4.x branch).
+ [
+ 'uuid' => 'railpack-python-flask',
+ 'name' => 'Railpack Python Flask Example',
+ 'base_directory' => '/flask',
+ 'ports_exposes' => '5000',
+ 'git_branch' => 'v4.x',
+ 'start_command' => 'flask run --host=0.0.0.0 --port=5000',
+ ],
+ [
+ 'uuid' => 'railpack-go-gin',
+ 'name' => 'Railpack Go Gin Example',
+ 'base_directory' => '/go/gin',
+ 'ports_exposes' => '3000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-rust',
+ 'name' => 'Railpack Rust Example',
+ 'base_directory' => '/rust',
+ 'ports_exposes' => '8000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-laravel',
+ 'name' => 'Railpack Laravel Example',
+ 'base_directory' => '/laravel',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-laravel-pure',
+ 'name' => 'Railpack Laravel Pure Example',
+ 'base_directory' => '/laravel-pure',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-laravel-inertia',
+ 'name' => 'Railpack Laravel Inertia Example',
+ 'base_directory' => '/laravel-inertia',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-symfony',
+ 'name' => 'Railpack Symfony Example',
+ 'base_directory' => '/symfony',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-rails',
+ 'name' => 'Railpack Ruby on Rails Example',
+ 'base_directory' => '/rails-example',
+ 'ports_exposes' => '3000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-elixir-phoenix',
+ 'name' => 'Railpack Elixir Phoenix Example',
+ 'base_directory' => '/elixir-phoenix',
+ 'ports_exposes' => '4000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-bun',
+ 'name' => 'Railpack Bun Example',
+ 'base_directory' => '/bun',
+ 'ports_exposes' => '3000',
+ 'git_branch' => 'v4.x',
+ ],
+ ];
+ }
+
+ private function ensureDevelopmentPrerequisitesExist(): void
+ {
+ Team::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'name' => 'Root Team',
+ 'description' => 'The root team',
+ 'personal_team' => true,
+ ],
+ );
+
+ PrivateKey::query()->firstOrCreate(
+ ['id' => 1],
+ [
+ 'uuid' => 'ssh',
+ 'team_id' => 0,
+ 'name' => 'Testing Host Key',
+ 'description' => 'This is a test docker container',
+ 'private_key' => <<<'KEY'
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
+hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
+AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
+uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
+-----END OPENSSH PRIVATE KEY-----
+KEY,
+ ],
+ );
+
+ Server::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'uuid' => 'localhost',
+ 'name' => 'localhost',
+ 'description' => 'This is a test docker container in development mode',
+ 'ip' => 'coolify-testing-host',
+ 'team_id' => 0,
+ 'private_key_id' => 1,
+ 'proxy' => [
+ 'type' => ProxyTypes::TRAEFIK->value,
+ 'status' => ProxyStatus::EXITED->value,
+ ],
+ ],
+ );
+
+ StandaloneDocker::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'uuid' => 'docker',
+ 'name' => 'Standalone Docker 1',
+ 'network' => 'coolify',
+ 'server_id' => 0,
+ ],
+ );
+
+ $this->ensurePublicGithubSourceExists();
+ }
+
+ private function ensurePublicGithubSourceExists(): void
+ {
+ GithubApp::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'uuid' => 'github-public',
+ 'name' => 'Public GitHub',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'is_public' => true,
+ 'team_id' => 0,
+ ],
+ );
+ }
+
+ private function isDevelopmentEnvironment(): bool
+ {
+ return in_array(config('app.env'), ['local', 'development', 'dev'], true);
+ }
+
+ private function prepareEnvironment(): Environment
+ {
+ $project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]);
+ $project->fill([
+ 'name' => 'Railpack Examples',
+ 'description' => 'Development-only Railpack examples from coollabsio/coolify-examples@next.',
+ 'team_id' => 0,
+ ]);
+ $project->save();
+
+ $environment = $project->environments()->first();
+
+ if (! $environment) {
+ $environment = $project->environments()->create([
+ 'name' => 'production',
+ 'uuid' => self::ENVIRONMENT_UUID,
+ ]);
+ } else {
+ $environment->update([
+ 'name' => 'production',
+ 'uuid' => self::ENVIRONMENT_UUID,
+ ]);
+ }
+
+ return $environment;
+ }
+
+ /**
+ * @param array $example
+ */
+ private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example): void
+ {
+ $application = Application::withTrashed()->firstOrNew(['uuid' => $example['uuid']]);
+ $application->fill([
+ 'name' => $example['name'],
+ 'description' => $example['name'],
+ 'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io",
+ 'repository_project_id' => self::REPOSITORY_PROJECT_ID,
+ 'git_repository' => self::GIT_REPOSITORY,
+ 'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH,
+ 'build_pack' => 'railpack',
+ 'ports_exposes' => $example['ports_exposes'],
+ 'base_directory' => $example['base_directory'],
+ 'publish_directory' => $example['publish_directory'] ?? null,
+ 'static_image' => 'nginx:alpine',
+ 'install_command' => $example['install_command'] ?? null,
+ 'build_command' => $example['build_command'] ?? null,
+ 'start_command' => $example['start_command'] ?? null,
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ 'source_id' => 0,
+ 'source_type' => GithubApp::class,
+ ]);
+ $application->save();
+
+ if ($application->trashed()) {
+ $application->restore();
+ }
+
+ $application->settings()->updateOrCreate(
+ ['application_id' => $application->id],
+ [
+ 'is_static' => $example['is_static'] ?? false,
+ 'is_spa' => $example['is_spa'] ?? false,
+ ],
+ );
+ }
+}
diff --git a/database/seeders/InstanceSettingsSeeder.php b/database/seeders/InstanceSettingsSeeder.php
index baa7abffc..930a7db8e 100644
--- a/database/seeders/InstanceSettingsSeeder.php
+++ b/database/seeders/InstanceSettingsSeeder.php
@@ -23,23 +23,25 @@ public function run(): void
'smtp_from_address' => 'hi@localhost.com',
'smtp_from_name' => 'Coolify',
]);
- try {
- $ipv4 = Process::run('curl -4s https://ifconfig.io')->output();
- $ipv4 = trim($ipv4);
- $ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP);
- $settings = instanceSettings();
- if (is_null($settings->public_ipv4) && $ipv4) {
- $settings->update(['public_ipv4' => $ipv4]);
+ if (! isDev()) {
+ try {
+ $ipv4 = Process::run('curl -4s https://ifconfig.io')->output();
+ $ipv4 = trim($ipv4);
+ $ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP);
+ $settings = instanceSettings();
+ if (is_null($settings->public_ipv4) && $ipv4) {
+ $settings->update(['public_ipv4' => $ipv4]);
+ }
+ $ipv6 = Process::run('curl -6s https://ifconfig.io')->output();
+ $ipv6 = trim($ipv6);
+ $ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP);
+ $settings = instanceSettings();
+ if (is_null($settings->public_ipv6) && $ipv6) {
+ $settings->update(['public_ipv6' => $ipv6]);
+ }
+ } catch (\Throwable $e) {
+ echo "Error: {$e->getMessage()}\n";
}
- $ipv6 = Process::run('curl -6s https://ifconfig.io')->output();
- $ipv6 = trim($ipv6);
- $ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP);
- $settings = instanceSettings();
- if (is_null($settings->public_ipv6) && $ipv6) {
- $settings->update(['public_ipv6' => $ipv6]);
- }
- } catch (\Throwable $e) {
- echo "Error: {$e->getMessage()}\n";
}
}
}
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index f608fe3cb..50edc140f 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -129,10 +129,9 @@ services:
networks:
- coolify
minio:
- image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
+ image: ghcr.io/coollabsio/maxio:latest
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"
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 901aeb833..56c5b416b 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml
index 998d35974..e1c09c64c 100644
--- a/docker-compose.windows.yml
+++ b/docker-compose.windows.yml
@@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
+ image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile
index 9c984a5ee..263c5a311 100644
--- a/docker/coolify-helper/Dockerfile
+++ b/docker/coolify-helper/Dockerfile
@@ -11,6 +11,10 @@ ARG DOCKER_BUILDX_VERSION=0.25.0
ARG PACK_VERSION=0.38.2
# https://github.com/railwayapp/nixpacks/releases
ARG NIXPACKS_VERSION=1.41.0
+# https://github.com/railwayapp/railpack/releases
+ARG RAILPACK_VERSION=0.22.0
+# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt)
+ARG MISE_VERSION=2026.3.12
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
@@ -25,18 +29,34 @@ ARG DOCKER_COMPOSE_VERSION
ARG DOCKER_BUILDX_VERSION
ARG PACK_VERSION
ARG NIXPACKS_VERSION
+ARG RAILPACK_VERSION
+ARG MISE_VERSION
USER root
WORKDIR /artifacts
+ENV RAILPACK_VERSION=${RAILPACK_VERSION}
RUN apk upgrade --no-cache && \
apk add --no-cache bash curl git git-lfs openssh-client tar tini
RUN mkdir -p ~/.docker/cli-plugins
+
+# Install mise (musl build) at the path railpack expects (/tmp/railpack/mise/mise-VERSION).
+# Railpack hardcodes a glibc mise download that fails on Alpine, so we pre-place a musl binary.
+RUN mkdir -p /tmp/railpack/mise && \
+ if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \
+ curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64-musl.tar.gz" | tar xz && \
+ mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \
+ elif [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \
+ curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-arm64-musl.tar.gz" | tar xz && \
+ mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \
+ fi
+
RUN if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \
curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx && \
curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose && \
(curl -sSL https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \
(curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \
curl -sSL https://nixpacks.com/install.sh | bash && \
+ curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \
chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \
;fi
@@ -46,6 +66,7 @@ RUN if [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \
(curl -sSL https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \
(curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux-arm64.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \
curl -sSL https://nixpacks.com/install.sh | bash && \
+ curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \
chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \
;fi
diff --git a/docker/coolify-realtime/Dockerfile b/docker/coolify-realtime/Dockerfile
index 325a30dcc..8395d6f87 100644
--- a/docker/coolify-realtime/Dockerfile
+++ b/docker/coolify-realtime/Dockerfile
@@ -12,8 +12,8 @@ ARG CLOUDFLARED_VERSION
WORKDIR /terminal
RUN apk upgrade --no-cache && \
apk add --no-cache openssh-client make g++ python3 curl
-COPY docker/coolify-realtime/package.json ./
-RUN npm i
+COPY docker/coolify-realtime/package*.json ./
+RUN npm ci
RUN npm rebuild node-pty --update-binary
COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh
COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js
diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json
index 174077562..5c6fa94aa 100644
--- a/docker/coolify-realtime/package-lock.json
+++ b/docker/coolify-realtime/package-lock.json
@@ -7,7 +7,6 @@
"dependencies": {
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
- "axios": "1.15.0",
"cookie": "1.1.1",
"dotenv": "17.3.1",
"node-pty": "1.1.0",
@@ -29,48 +28,6 @@
"addons/*"
]
},
- "node_modules/asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "license": "MIT"
- },
- "node_modules/axios": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
- "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
- "license": "MIT",
- "dependencies": {
- "follow-redirects": "^1.15.11",
- "form-data": "^4.0.5",
- "proxy-from-env": "^2.1.0"
- }
- },
- "node_modules/call-bind-apply-helpers": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
- "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/combined-stream": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "license": "MIT",
- "dependencies": {
- "delayed-stream": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -84,15 +41,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -105,228 +53,6 @@
"url": "https://dotenvx.com"
}
},
- "node_modules/dunder-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.1",
- "es-errors": "^1.3.0",
- "gopd": "^1.2.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-define-property": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-errors": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-object-atoms": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-set-tostringtag": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
- "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.6",
- "has-tostringtag": "^1.0.2",
- "hasown": "^2.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/RubenVerborgh"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=4.0"
- },
- "peerDependenciesMeta": {
- "debug": {
- "optional": true
- }
- }
- },
- "node_modules/form-data": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
- "license": "MIT",
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/function-bind": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-intrinsic": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
- "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.2",
- "es-define-property": "^1.0.1",
- "es-errors": "^1.3.0",
- "es-object-atoms": "^1.1.1",
- "function-bind": "^1.1.2",
- "get-proto": "^1.0.1",
- "gopd": "^1.2.0",
- "has-symbols": "^1.1.0",
- "hasown": "^2.0.2",
- "math-intrinsics": "^1.1.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
- "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "license": "MIT",
- "dependencies": {
- "dunder-proto": "^1.0.1",
- "es-object-atoms": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/gopd": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-symbols": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
- "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-tostringtag": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
- "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "license": "MIT",
- "dependencies": {
- "has-symbols": "^1.0.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/math-intrinsics": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "license": "MIT",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -343,15 +69,6 @@
"node-addon-api": "^7.1.0"
}
},
- "node_modules/proxy-from-env": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
- "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json
index 30bfbcef7..25bf786a8 100644
--- a/docker/coolify-realtime/package.json
+++ b/docker/coolify-realtime/package.json
@@ -5,9 +5,8 @@
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"cookie": "1.1.1",
- "axios": "1.15.0",
"dotenv": "17.3.1",
"node-pty": "1.1.0",
"ws": "8.19.0"
}
-}
\ No newline at end of file
+}
diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js
index 3ae77857f..42ca7c81d 100755
--- a/docker/coolify-realtime/terminal-server.js
+++ b/docker/coolify-realtime/terminal-server.js
@@ -1,7 +1,6 @@
import { WebSocketServer } from 'ws';
import http from 'http';
import pty from 'node-pty';
-import axios from 'axios';
import cookie from 'cookie';
import 'dotenv/config';
import {
@@ -12,9 +11,60 @@ import {
isAuthorizedTargetHost,
} from './terminal-utils.js';
+async function postToCoolify(path, headers) {
+ return new Promise((resolve, reject) => {
+ const request = http.request({
+ hostname: 'coolify',
+ port: 8080,
+ path,
+ method: 'POST',
+ headers,
+ }, (response) => {
+ let responseText = '';
+
+ response.setEncoding('utf8');
+ response.on('data', (chunk) => {
+ responseText += chunk;
+ });
+ response.on('end', () => {
+ try {
+ resolve({
+ status: response.statusCode ?? 0,
+ data: parseResponseData(response.headers['content-type'], responseText),
+ });
+ } catch (error) {
+ reject(error);
+ }
+ });
+ });
+
+ request.on('error', reject);
+ request.end();
+ });
+}
+
+function parseResponseData(contentType = '', responseText = '') {
+ if (responseText === '') {
+ return null;
+ }
+
+ if (contentType.includes('application/json')) {
+ return JSON.parse(responseText);
+ }
+
+ return responseText;
+}
+
+function createHttpError(response) {
+ const error = new Error(`Request failed with status code ${response.status}`);
+ error.response = response;
+
+ return error;
+}
+
const userSessions = new Map();
-const terminalDebugEnabled = ['local', 'development'].includes(
- String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase()
+const terminalDebugEnabled = ['1', 'true', 'yes'].includes(
+ String(process.env.TERMINAL_DEBUG || '').toLowerCase()
);
function logTerminal(level, message, context = {}) {
@@ -74,11 +124,9 @@ const verifyClient = async (info, callback) => {
try {
// Authenticate with Laravel backend
- const response = await axios.post(`http://coolify:8080/terminal/auth`, null, {
- headers: {
- 'Cookie': `${sessionCookieName}=${laravelSession}`,
- 'X-XSRF-TOKEN': xsrfToken
- },
+ const response = await postToCoolify('/terminal/auth', {
+ 'Cookie': `${sessionCookieName}=${laravelSession}`,
+ 'X-XSRF-TOKEN': xsrfToken
});
if (response.status === 200) {
@@ -105,9 +153,25 @@ const verifyClient = async (info, callback) => {
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
+const HEARTBEAT_INTERVAL_MS = 30000;
+const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
+
wss.on('connection', async (ws, req) => {
+ ws.isAlive = true;
+ ws.on('pong', () => { ws.isAlive = true; });
+
const userId = generateUserId();
- const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
+ ws.userId = userId;
+ const userSession = {
+ ws,
+ userId,
+ ptyProcess: null,
+ isActive: false,
+ authorizedIPs: [],
+ lastActivityAt: Date.now(),
+ authReady: false,
+ pendingMessages: [],
+ };
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
userId,
@@ -117,6 +181,26 @@ wss.on('connection', async (ws, req) => {
hasLaravelSession: Boolean(laravelSession),
};
+ // Register socket handlers up front so messages sent immediately by the client
+ // (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch
+ // below is still pending.
+ ws.on('message', (message) => {
+ if (userSession.authReady) {
+ handleMessage(userSession, message);
+ } else {
+ userSession.pendingMessages.push(message);
+ }
+ });
+ ws.on('error', (err) => handleError(err, userId));
+ ws.on('close', (code, reason) => {
+ logTerminal('log', 'Terminal websocket connection closed.', {
+ userId,
+ code,
+ reason: reason?.toString(),
+ });
+ handleClose(userId);
+ });
+
// Verify presence of required tokens
if (!laravelSession || !xsrfToken) {
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
@@ -125,12 +209,15 @@ wss.on('connection', async (ws, req) => {
}
try {
- const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
- headers: {
- 'Cookie': `${sessionCookieName}=${laravelSession}`,
- 'X-XSRF-TOKEN': xsrfToken
- },
+ const response = await postToCoolify('/terminal/auth/ips', {
+ 'Cookie': `${sessionCookieName}=${laravelSession}`,
+ 'X-XSRF-TOKEN': xsrfToken
});
+
+ if (response.status !== 200) {
+ throw createHttpError(response);
+ }
+
userSession.authorizedIPs = response.data.ipAddresses || [];
logTerminal('log', 'Fetched authorized terminal hosts for websocket session.', {
...connectionContext,
@@ -148,28 +235,66 @@ wss.on('connection', async (ws, req) => {
}
userSessions.set(userId, userSession);
+ userSession.authReady = true;
logTerminal('log', 'Terminal websocket connection established.', {
...connectionContext,
authorizedHostCount: userSession.authorizedIPs.length,
+ bufferedMessages: userSession.pendingMessages.length,
});
- ws.on('message', (message) => {
- handleMessage(userSession, message);
- });
- ws.on('error', (err) => handleError(err, userId));
- ws.on('close', (code, reason) => {
- logTerminal('log', 'Terminal websocket connection closed.', {
- userId,
- code,
- reason: reason?.toString(),
- });
- handleClose(userId);
- });
+ // Drain any messages that arrived while we were waiting on the IP auth call.
+ while (userSession.pendingMessages.length > 0) {
+ handleMessage(userSession, userSession.pendingMessages.shift());
+ }
});
+const heartbeat = setInterval(() => {
+ wss.clients.forEach((ws) => {
+ if (ws.isAlive === false) {
+ logTerminal('warn', 'Terminating WS due to missed protocol pong.');
+ return ws.terminate();
+ }
+ ws.isAlive = false;
+ try {
+ ws.ping();
+ } catch (_) {
+ // ignore — close handler will follow
+ }
+
+ const session = ws.userId ? userSessions.get(ws.userId) : null;
+ if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) {
+ const idleMs = Date.now() - session.lastActivityAt;
+ logTerminal('warn', 'Closing terminal session due to idle timeout.', {
+ userId: ws.userId,
+ idleMs,
+ idleTimeoutMs: IDLE_TIMEOUT_MS,
+ });
+ try {
+ ws.send('idle-timeout');
+ } catch (_) {
+ // ignore — close still attempted below
+ }
+ killPtyProcess(ws.userId);
+ setTimeout(() => {
+ try {
+ ws.close(1000, 'Idle timeout');
+ } catch (_) {
+ // ignore — already closed
+ }
+ }, 100);
+ }
+ });
+}, HEARTBEAT_INTERVAL_MS);
+
+wss.on('close', () => clearInterval(heartbeat));
+
const messageHandlers = {
- message: (session, data) => session.ptyProcess.write(data),
+ message: (session, data) => {
+ session.lastActivityAt = Date.now();
+ session.ptyProcess.write(data);
+ },
resize: (session, { cols, rows }) => {
+ session.lastActivityAt = Date.now();
cols = cols > 0 ? cols : 80;
rows = rows > 0 ? rows : 30;
session.ptyProcess.resize(cols, rows)
@@ -197,12 +322,6 @@ function handleMessage(userSession, message) {
return;
}
- logTerminal('log', 'Received websocket message.', {
- userId: userSession.userId,
- keys: Object.keys(parsed),
- isActive: userSession.isActive,
- });
-
Object.entries(parsed).forEach(([key, value]) => {
const handler = messageHandlers[key];
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
@@ -301,6 +420,7 @@ async function handleCommand(ws, command, userId) {
userSession.ptyProcess = ptyProcess;
userSession.isActive = true;
+ userSession.lastActivityAt = Date.now();
ws.send('pty-ready');
diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile
index 77013e1b9..dc9a06c1e 100644
--- a/docker/development/Dockerfile
+++ b/docker/development/Dockerfile
@@ -38,6 +38,8 @@ RUN apk upgrade --no-cache && \
mkdir -p /usr/share/keyrings && \
curl -fSsL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/postgresql.gpg
+RUN sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories
+
# Install system dependencies
RUN apk add --no-cache \
postgresql${POSTGRES_VERSION}-client \
diff --git a/openapi.json b/openapi.json
index d83b30d80..25aada1e1 100644
--- a/openapi.json
+++ b/openapi.json
@@ -111,6 +111,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -569,6 +570,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -1019,6 +1021,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -1448,10 +1451,7 @@
"build_pack": {
"type": "string",
"enum": [
- "nixpacks",
- "static",
- "dockerfile",
- "dockercompose"
+ "dockerfile"
],
"description": "The build pack type."
},
@@ -2092,173 +2092,6 @@
]
}
},
- "\/applications\/dockercompose": {
- "post": {
- "tags": [
- "Applications"
- ],
- "summary": "Create (Docker Compose)",
- "description": "Deprecated: Use POST \/api\/v1\/services instead.",
- "operationId": "create-dockercompose-application",
- "requestBody": {
- "description": "Application object that needs to be created.",
- "required": true,
- "content": {
- "application\/json": {
- "schema": {
- "required": [
- "project_uuid",
- "server_uuid",
- "environment_name",
- "environment_uuid",
- "docker_compose_raw"
- ],
- "properties": {
- "project_uuid": {
- "type": "string",
- "description": "The project UUID."
- },
- "server_uuid": {
- "type": "string",
- "description": "The server UUID."
- },
- "environment_name": {
- "type": "string",
- "description": "The environment name. You need to provide at least one of environment_name or environment_uuid."
- },
- "environment_uuid": {
- "type": "string",
- "description": "The environment UUID. You need to provide at least one of environment_name or environment_uuid."
- },
- "docker_compose_raw": {
- "type": "string",
- "description": "The Docker Compose raw content."
- },
- "destination_uuid": {
- "type": "string",
- "description": "The destination UUID if the server has more than one destinations."
- },
- "name": {
- "type": "string",
- "description": "The application name."
- },
- "description": {
- "type": "string",
- "description": "The application description."
- },
- "instant_deploy": {
- "type": "boolean",
- "description": "The flag to indicate if the application should be deployed instantly."
- },
- "use_build_server": {
- "type": "boolean",
- "nullable": true,
- "description": "Use build server."
- },
- "connect_to_docker_network": {
- "type": "boolean",
- "description": "The flag to connect the service to the predefined Docker network."
- },
- "force_domain_override": {
- "type": "boolean",
- "description": "Force domain usage even if conflicts are detected. Default is false."
- },
- "is_container_label_escape_enabled": {
- "type": "boolean",
- "default": true,
- "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
- }
- },
- "type": "object"
- }
- }
- }
- },
- "responses": {
- "201": {
- "description": "Application created successfully.",
- "content": {
- "application\/json": {
- "schema": {
- "properties": {
- "uuid": {
- "type": "string"
- }
- },
- "type": "object"
- }
- }
- }
- },
- "401": {
- "$ref": "#\/components\/responses\/401"
- },
- "400": {
- "$ref": "#\/components\/responses\/400"
- },
- "409": {
- "description": "Domain conflicts detected.",
- "content": {
- "application\/json": {
- "schema": {
- "properties": {
- "message": {
- "type": "string",
- "example": "Domain conflicts detected. Use force_domain_override=true to proceed."
- },
- "warning": {
- "type": "string",
- "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior."
- },
- "conflicts": {
- "type": "array",
- "items": {
- "properties": {
- "domain": {
- "type": "string",
- "example": "example.com"
- },
- "resource_name": {
- "type": "string",
- "example": "My Application"
- },
- "resource_uuid": {
- "type": "string",
- "nullable": true,
- "example": "abc123-def456"
- },
- "resource_type": {
- "type": "string",
- "enum": [
- "application",
- "service",
- "instance"
- ],
- "example": "application"
- },
- "message": {
- "type": "string",
- "example": "Domain example.com is already in use by application 'My Application'"
- }
- },
- "type": "object"
- }
- }
- },
- "type": "object"
- }
- }
- }
- }
- },
- "deprecated": true,
- "security": [
- {
- "bearerAuth": []
- }
- ]
- }
- },
"\/applications\/{uuid}": {
"get": {
"tags": [
@@ -2457,6 +2290,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -4381,8 +4215,8 @@
"description": "Number of days to retain backups locally"
},
"database_backup_retention_max_storage_locally": {
- "type": "integer",
- "description": "Max storage (MB) for local backups"
+ "type": "number",
+ "description": "Max storage (GB) for local backups"
},
"database_backup_retention_amount_s3": {
"type": "integer",
@@ -4393,8 +4227,8 @@
"description": "Number of days to retain backups in S3"
},
"database_backup_retention_max_storage_s3": {
- "type": "integer",
- "description": "Max storage (MB) for S3 backups"
+ "type": "number",
+ "description": "Max storage (GB) for S3 backups"
},
"timeout": {
"type": "integer",
@@ -4951,7 +4785,7 @@
"description": "Retention days of the backup locally"
},
"database_backup_retention_max_storage_locally": {
- "type": "integer",
+ "type": "number",
"description": "Max storage of the backup locally"
},
"database_backup_retention_amount_s3": {
@@ -4963,7 +4797,7 @@
"description": "Retention days of the backup in s3"
},
"database_backup_retention_max_storage_s3": {
- "type": "integer",
+ "type": "number",
"description": "Max storage of the backup in S3"
},
"timeout": {
@@ -8650,6 +8484,110 @@
]
}
},
+ "\/mcp\/enable": {
+ "post": {
+ "summary": "Enable MCP Server",
+ "description": "Enable the MCP server endpoint at \/mcp (only with root permissions).",
+ "operationId": "enable-mcp",
+ "responses": {
+ "200": {
+ "description": "MCP server enabled.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "MCP server enabled."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "You are not allowed to enable the MCP server.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "You are not allowed to enable the MCP server."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/mcp\/disable": {
+ "post": {
+ "summary": "Disable MCP Server",
+ "description": "Disable the MCP server endpoint at \/mcp (only with root permissions).",
+ "operationId": "disable-mcp",
+ "responses": {
+ "200": {
+ "description": "MCP server disabled.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "MCP server disabled."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "You are not allowed to disable the MCP server.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "You are not allowed to disable the MCP server."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/health": {
"get": {
"summary": "Healthcheck",
@@ -10545,6 +10483,10 @@
"server_disk_usage_check_frequency": {
"type": "string",
"description": "Cron expression for disk usage check frequency."
+ },
+ "connection_timeout": {
+ "type": "integer",
+ "description": "SSH connection timeout in seconds (1-300). Default: 10."
}
},
"type": "object"
@@ -12499,6 +12441,7 @@
"description": "Build pack.",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -13349,6 +13292,10 @@
"delete_unused_networks": {
"type": "boolean",
"description": "The flag to indicate if the unused networks should be deleted."
+ },
+ "connection_timeout": {
+ "type": "integer",
+ "description": "SSH connection timeout in seconds."
}
},
"type": "object"
diff --git a/openapi.yaml b/openapi.yaml
index aab408098..4597b06f7 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -81,7 +81,7 @@ paths:
description: 'The git branch.'
build_pack:
type: string
- enum: [nixpacks, static, dockerfile, dockercompose]
+ enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
ports_exposes:
type: string
@@ -375,7 +375,7 @@ paths:
description: 'The destination UUID.'
build_pack:
type: string
- enum: [nixpacks, static, dockerfile, dockercompose]
+ enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
name:
type: string
@@ -663,7 +663,7 @@ paths:
description: 'The destination UUID.'
build_pack:
type: string
- enum: [nixpacks, static, dockerfile, dockercompose]
+ enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
name:
type: string
@@ -935,7 +935,7 @@ paths:
description: 'The Dockerfile content.'
build_pack:
type: string
- enum: [nixpacks, static, dockerfile, dockercompose]
+ enum: [dockerfile]
description: 'The build pack type.'
ports_exposes:
type: string
@@ -1337,95 +1337,6 @@ paths:
security:
-
bearerAuth: []
- /applications/dockercompose:
- post:
- tags:
- - Applications
- summary: 'Create (Docker Compose)'
- description: 'Deprecated: Use POST /api/v1/services instead.'
- operationId: create-dockercompose-application
- requestBody:
- description: 'Application object that needs to be created.'
- required: true
- content:
- application/json:
- schema:
- required:
- - project_uuid
- - server_uuid
- - environment_name
- - environment_uuid
- - docker_compose_raw
- properties:
- project_uuid:
- type: string
- description: 'The project UUID.'
- server_uuid:
- type: string
- description: 'The server UUID.'
- environment_name:
- type: string
- description: 'The environment name. You need to provide at least one of environment_name or environment_uuid.'
- environment_uuid:
- type: string
- description: 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'
- docker_compose_raw:
- type: string
- description: 'The Docker Compose raw content.'
- destination_uuid:
- type: string
- description: 'The destination UUID if the server has more than one destinations.'
- name:
- type: string
- description: 'The application name.'
- description:
- type: string
- description: 'The application description.'
- instant_deploy:
- type: boolean
- description: 'The flag to indicate if the application should be deployed instantly.'
- use_build_server:
- type: boolean
- nullable: true
- description: 'Use build server.'
- connect_to_docker_network:
- type: boolean
- description: 'The flag to connect the service to the predefined Docker network.'
- force_domain_override:
- type: boolean
- description: 'Force domain usage even if conflicts are detected. Default is false.'
- is_container_label_escape_enabled:
- type: boolean
- default: true
- description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
- type: object
- responses:
- '201':
- description: 'Application created successfully.'
- content:
- application/json:
- schema:
- properties:
- uuid: { type: string }
- type: object
- '401':
- $ref: '#/components/responses/401'
- '400':
- $ref: '#/components/responses/400'
- '409':
- description: 'Domain conflicts detected.'
- content:
- application/json:
- schema:
- properties:
- message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' }
- warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' }
- conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } }
- type: object
- deprecated: true
- security:
- -
- bearerAuth: []
'/applications/{uuid}':
get:
tags:
@@ -1568,7 +1479,7 @@ paths:
description: 'The destination UUID.'
build_pack:
type: string
- enum: [nixpacks, static, dockerfile, dockercompose]
+ enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
name:
type: string
@@ -2765,8 +2676,8 @@ paths:
type: integer
description: 'Number of days to retain backups locally'
database_backup_retention_max_storage_locally:
- type: integer
- description: 'Max storage (MB) for local backups'
+ type: number
+ description: 'Max storage (GB) for local backups'
database_backup_retention_amount_s3:
type: integer
description: 'Number of backups to retain in S3'
@@ -2774,8 +2685,8 @@ paths:
type: integer
description: 'Number of days to retain backups in S3'
database_backup_retention_max_storage_s3:
- type: integer
- description: 'Max storage (MB) for S3 backups'
+ type: number
+ description: 'Max storage (GB) for S3 backups'
timeout:
type: integer
description: 'Backup job timeout in seconds (min: 60, max: 36000)'
@@ -3160,7 +3071,7 @@ paths:
type: integer
description: 'Retention days of the backup locally'
database_backup_retention_max_storage_locally:
- type: integer
+ type: number
description: 'Max storage of the backup locally'
database_backup_retention_amount_s3:
type: integer
@@ -3169,7 +3080,7 @@ paths:
type: integer
description: 'Retention days of the backup in s3'
database_backup_retention_max_storage_s3:
- type: integer
+ type: number
description: 'Max storage of the backup in S3'
timeout:
type: integer
@@ -5484,6 +5395,64 @@ paths:
security:
-
bearerAuth: []
+ /mcp/enable:
+ post:
+ summary: 'Enable MCP Server'
+ description: 'Enable the MCP server endpoint at /mcp (only with root permissions).'
+ operationId: enable-mcp
+ responses:
+ '200':
+ description: 'MCP server enabled.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'MCP server enabled.' }
+ type: object
+ '403':
+ description: 'You are not allowed to enable the MCP server.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'You are not allowed to enable the MCP server.' }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ security:
+ -
+ bearerAuth: []
+ /mcp/disable:
+ post:
+ summary: 'Disable MCP Server'
+ description: 'Disable the MCP server endpoint at /mcp (only with root permissions).'
+ operationId: disable-mcp
+ responses:
+ '200':
+ description: 'MCP server disabled.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'MCP server disabled.' }
+ type: object
+ '403':
+ description: 'You are not allowed to disable the MCP server.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'You are not allowed to disable the MCP server.' }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ security:
+ -
+ bearerAuth: []
/health:
get:
summary: Healthcheck
@@ -6734,6 +6703,9 @@ paths:
server_disk_usage_check_frequency:
type: string
description: 'Cron expression for disk usage check frequency.'
+ connection_timeout:
+ type: integer
+ description: 'SSH connection timeout in seconds (1-300). Default: 10.'
type: object
responses:
'201':
@@ -7916,6 +7888,7 @@ components:
description: 'Build pack.'
enum:
- nixpacks
+ - railpack
- static
- dockerfile
- dockercompose
@@ -8538,6 +8511,9 @@ components:
delete_unused_networks:
type: boolean
description: 'The flag to indicate if the unused networks should be deleted.'
+ connection_timeout:
+ type: integer
+ description: 'SSH connection timeout in seconds.'
type: object
Service:
description: 'Service model'
diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml
index 901aeb833..3a9bfd501 100644
--- a/other/nightly/docker-compose.prod.yml
+++ b/other/nightly/docker-compose.prod.yml
@@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml
index 998d35974..cc72d487b 100644
--- a/other/nightly/docker-compose.windows.yml
+++ b/other/nightly/docker-compose.windows.yml
@@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
+ image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 27d911c67..368e1e379 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.474"
+ "version": "4.1.0"
},
"nightly": {
"version": "4.0.0"
@@ -10,7 +10,7 @@
"version": "1.0.13"
},
"realtime": {
- "version": "1.0.13"
+ "version": "1.0.15"
},
"sentinel": {
"version": "0.0.21"
diff --git a/package-lock.json b/package-lock.json
index 20aa0e822..dcca9c394 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,7 +16,6 @@
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
"@vitejs/plugin-vue": "6.0.3",
- "axios": "1.15.0",
"laravel-echo": "2.2.7",
"laravel-vite-plugin": "2.0.1",
"postcss": "8.5.6",
@@ -1466,39 +1465,6 @@
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
- "node_modules/asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/axios": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
- "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "follow-redirects": "^1.15.11",
- "form-data": "^4.0.5",
- "proxy-from-env": "^2.1.0"
- }
- },
- "node_modules/call-bind-apply-helpers": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
- "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1518,19 +1484,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/combined-stream": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "delayed-stream": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1567,16 +1520,6 @@
}
}
},
- "node_modules/delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -1596,21 +1539,6 @@
"node": ">=8"
}
},
- "node_modules/dunder-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.1",
- "es-errors": "^1.3.0",
- "gopd": "^1.2.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
@@ -1664,55 +1592,6 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
- "node_modules/es-define-property": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-errors": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-object-atoms": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-set-tostringtag": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
- "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.6",
- "has-tostringtag": "^1.0.2",
- "hasown": "^2.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -1780,44 +1659,6 @@
}
}
},
- "node_modules/follow-redirects": {
- "version": "1.16.0",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
- "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
- "dev": true,
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/RubenVerborgh"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=4.0"
- },
- "peerDependenciesMeta": {
- "debug": {
- "optional": true
- }
- }
- },
- "node_modules/form-data": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1833,68 +1674,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/function-bind": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-intrinsic": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
- "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.2",
- "es-define-property": "^1.0.1",
- "es-errors": "^1.3.0",
- "es-object-atoms": "^1.1.1",
- "function-bind": "^1.1.2",
- "get-proto": "^1.0.1",
- "gopd": "^1.2.0",
- "has-symbols": "^1.1.0",
- "hasown": "^2.0.2",
- "math-intrinsics": "^1.1.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
- "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "dunder-proto": "^1.0.1",
- "es-object-atoms": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/gopd": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1902,48 +1681,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/has-symbols": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
- "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-tostringtag": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
- "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-symbols": "^1.0.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
"node_modules/ioredis": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz",
@@ -2313,39 +2050,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/math-intrinsics": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
@@ -2500,16 +2204,6 @@
"react": ">=16.0.0"
}
},
- "node_modules/proxy-from-env": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
- "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/pusher-js": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
diff --git a/package.json b/package.json
index 3afefa833..6b0b58522 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,6 @@
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
"@vitejs/plugin-vue": "6.0.3",
- "axios": "1.15.0",
"laravel-echo": "2.2.7",
"laravel-vite-plugin": "2.0.1",
"postcss": "8.5.6",
diff --git a/public/svgs/cap-captcha.png b/public/svgs/cap-captcha.png
new file mode 100644
index 000000000..4b6a7df14
Binary files /dev/null and b/public/svgs/cap-captcha.png differ
diff --git a/resources/css/utilities.css b/resources/css/utilities.css
index a8e807041..7eb926a36 100644
--- a/resources/css/utilities.css
+++ b/resources/css/utilities.css
@@ -343,3 +343,12 @@ @utility log-debug {
@utility log-info {
@apply bg-blue-500/10 dark:bg-blue-500/15;
}
+
+@media (min-width: 1024px) {
+ .sidebar-collapsed .menu-item {
+ justify-content: center;
+ padding-left: 0;
+ padding-right: 0;
+ gap: 0;
+ }
+}
diff --git a/resources/js/terminal.js b/resources/js/terminal.js
index aa5f37353..7a7fc8536 100644
--- a/resources/js/terminal.js
+++ b/resources/js/terminal.js
@@ -42,6 +42,10 @@ export function initializeTerminalComponent() {
maxHeartbeatMisses: 3,
// Command buffering for race condition prevention
pendingCommand: null,
+ // Last successfully sent SSH command — replayed after a transient reconnect
+ // so the PTY respawns automatically. Cleared on intentional terminations
+ // (pty-exited, idle-timeout, unprocessable).
+ lastSentCommand: null,
// Resize handling
resizeObserver: null,
resizeTimeout: null,
@@ -75,8 +79,6 @@ export function initializeTerminalComponent() {
focusWhenReady();
});
- this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
-
this.$watch('terminalActive', (active) => {
if (!active && this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
@@ -150,8 +152,11 @@ export function initializeTerminalComponent() {
},
clearAllTimers() {
- [this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
- .forEach(timer => timer && clearInterval(timer));
+ if (this.keepAliveInterval) {
+ clearInterval(this.keepAliveInterval);
+ }
+ [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
+ .forEach(timer => timer && clearTimeout(timer));
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
@@ -161,9 +166,17 @@ export function initializeTerminalComponent() {
resetTerminal() {
if (this.term) {
- this.$wire.dispatch('error', 'Terminal websocket connection lost.');
- this.term.reset();
- this.term.clear();
+ this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
+ // Preserve scrollback so the user keeps the context of their previous
+ // session. Print a visible marker so they know where the disconnect
+ // happened. Old PTY shell state cannot be restored — this is purely
+ // a visual carry-over.
+ try {
+ const stamp = new Date().toLocaleTimeString();
+ this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`);
+ } catch (_) {
+ // ignore — terminal not ready to receive writes
+ }
this.pendingWrites = 0;
this.paused = false;
this.commandBuffer = '';
@@ -276,10 +289,22 @@ export function initializeTerminalComponent() {
this.connectionTimeoutId = null;
}
- // Flush any buffered command from before WebSocket was ready
+ // Flush any buffered command from before WebSocket was ready, otherwise
+ // replay the last command so a transient reconnect respawns the PTY
+ // automatically without requiring the user to click Connect again.
if (this.pendingCommand) {
this.sendMessage(this.pendingCommand);
this.pendingCommand = null;
+ } else if (this.lastSentCommand) {
+ logTerminal('log', '[Terminal] Replaying last command after reconnect.');
+ this.sendMessage(this.lastSentCommand);
+ }
+
+ // (Re)start application-level keepalive on every successful connect.
+ // Server-side WebSocket protocol pings are the primary heartbeat; this
+ // adds a JSON-level ping in case the server-side is older or restarting.
+ if (!this.keepAliveInterval) {
+ this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
}
// Start ping timeout monitoring
@@ -354,6 +379,9 @@ export function initializeTerminalComponent() {
sendMessage(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
+ if (message && message.command) {
+ this.lastSentCommand = message;
+ }
} else {
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
}
@@ -368,8 +396,6 @@ export function initializeTerminalComponent() {
},
handleSocketMessage(event) {
- logTerminal('log', '[Terminal] Received WebSocket message:', event.data);
-
// Handle pong responses
if (event.data === 'pong') {
this.heartbeatMissed = 0;
@@ -387,7 +413,15 @@ export function initializeTerminalComponent() {
this.term.open(document.getElementById('terminal'));
this.term._initialized = true;
} else {
- this.term.reset();
+ // Already initialized — this is a reconnect or a follow-up command.
+ // Preserve scrollback so the user keeps context. Write a visible
+ // separator so the new shell prompt is easy to spot.
+ try {
+ const stamp = new Date().toLocaleTimeString();
+ this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`);
+ } catch (_) {
+ // ignore — fall through; xterm will render the new prompt anyway
+ }
}
this.terminalActive = true;
this.term.focus();
@@ -415,6 +449,7 @@ export function initializeTerminalComponent() {
} else if (event.data === 'unprocessable') {
if (this.term) this.term.reset();
this.terminalActive = false;
+ this.lastSentCommand = null;
this.message = '(sorry, something went wrong, please try again)';
// Notify parent component that terminal connection failed
@@ -423,9 +458,19 @@ export function initializeTerminalComponent() {
this.terminalActive = false;
this.term.reset();
this.commandBuffer = '';
+ this.lastSentCommand = null;
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
+ } else if (event.data === 'idle-timeout') {
+ this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.');
+ this.terminalActive = false;
+ if (this.term) {
+ this.term.reset();
+ }
+ this.commandBuffer = '';
+ this.lastSentCommand = null;
+ this.$wire.dispatch('terminalDisconnected');
} else if (
typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
@@ -494,11 +539,6 @@ export function initializeTerminalComponent() {
},
keepAlive() {
- // Skip keepalive when document is hidden to prevent unnecessary disconnects
- if (!this.isDocumentVisible) {
- return;
- }
-
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
@@ -524,10 +564,23 @@ export function initializeTerminalComponent() {
logTerminal('log', '[Terminal] Tab visible, resuming connection management');
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
- // Send immediate ping to verify connection is still alive
+ // Connection may be half-open after Cloudflare/proxy idle drop while hidden.
+ // Probe with a short timeout (5s) instead of the default 35s — force a
+ // reconnect quickly if no pong arrives so the user is not stuck typing
+ // into a dead socket.
this.heartbeatMissed = 0;
this.sendMessage({ ping: true });
- this.resetPingTimeout();
+ if (this.pingTimeoutId) {
+ clearTimeout(this.pingTimeoutId);
+ }
+ this.pingTimeoutId = setTimeout(() => {
+ logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.');
+ try {
+ this.socket.close(4000, 'Visibility-resume timeout');
+ } catch (_) {
+ // ignore — close handler will run on its own
+ }
+ }, 5000);
} else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') {
// Was connected before but now disconnected - attempt reconnection
this.reconnectAttempts = 0;
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php
index 642bbcfb0..f637425c1 100644
--- a/resources/views/components/forms/env-var-input.blade.php
+++ b/resources/views/components/forms/env-var-input.blade.php
@@ -229,7 +229,7 @@ class="flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer dar
@readonly($readonly)
@if ($modelBinding !== 'null')
wire:model="{{ $modelBinding }}"
- wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"
+ wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
@endif
wire:loading.attr="disabled"
@disabled($disabled)
diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php
index 394f6275f..2542839f1 100644
--- a/resources/views/components/helper.blade.php
+++ b/resources/views/components/helper.blade.php
@@ -1,4 +1,5 @@
-merge(['class' => 'group']) }}>
+
merge(['class' => 'group']) }}>
@isset($icon)
{{ $icon }}
@@ -10,7 +11,7 @@
@endisset
-