Compare commits

..

15 Commits

Author SHA1 Message Date
dosubot[bot]
d9b41a258e docs: add comprehensive API and environment setup guide 2026-03-19 04:51:41 +00:00
Mauricio Siu
7c55eba506 Merge pull request #3923 from fdarian/feat/expose-drop-deployment-api
feat: expose drop deployment endpoint in public API
2026-03-18 22:49:57 -06:00
Mauricio Siu
7878bf29ba chore: update @dokploy/trpc-openapi to version 0.0.18
- Bumped the version of @dokploy/trpc-openapi in both package.json and pnpm-lock.yaml.
- Removed unnecessary metadata from the dropDeployment procedure in application.ts.
2026-03-18 22:49:08 -06:00
Mauricio Siu
1b70763ba5 Merge branch 'canary' into feat/expose-drop-deployment-api 2026-03-18 22:28:55 -06:00
Mauricio Siu
e47263ae5f Merge pull request #4033 from Dokploy/feat/improve-update-process-to-validate-dokploy-services
feat: enhance web server update process with health checks
2026-03-18 22:28:09 -06:00
autofix-ci[bot]
b139d6f277 [autofix.ci] apply automated fixes 2026-03-19 04:26:50 +00:00
Mauricio Siu
cddb06f515 feat: enhance web server update process with health checks
- Added health check functionality for PostgreSQL, Redis, and Traefik services before updating the web server.
- Introduced a modal state management system to guide users through the verification and update process.
- Updated UI components to display service health status and relevant messages during the update workflow.
- Refactored the update server button to reflect the latest version and availability of updates.
2026-03-18 22:26:12 -06:00
Mauricio Siu
d0c92d84ef fix: update API key deletion authorization check
- Changed the authorization check for deleting an API key to use referenceId instead of userId, ensuring proper validation against the current user's ID.
2026-03-18 16:33:19 -06:00
Mauricio Siu
72974e00a6 Merge pull request #4028 from Dokploy/4024-api-keys-not-working-and-unbale-to-generate-new-ones-after-upgrade-to-0287
feat: update apikey schema and relationships
2026-03-18 16:29:22 -06:00
Mauricio Siu
d96e2bbeb7 chore: bump version to v0.28.8 in package.json 2026-03-18 16:28:54 -06:00
Mauricio Siu
a45d8ee8f4 feat: update apikey schema and relationships
- Modified the apikey table to drop the user_id column and replace it with reference_id, establishing a foreign key relationship with the user table.
- Added config_id column with a default value to the apikey table.
- Updated related code in the account schema and user service to reflect these changes.
- Enhanced the journal and snapshot files to include the latest schema updates.
2026-03-18 16:26:05 -06:00
Farrel Darian
1203d0589b fix: use dedicated schema 2026-03-09 05:28:01 +07:00
Farrel Darian
653e5fa3a0 fix: validate applicationId
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-07 16:31:50 +07:00
Farrel Darian
66931fe24f feat: use zod-form-data schema for dropDeployment input
Switch from z.instanceof(FormData) to uploadFileSchema (zod-form-data)
so the OpenAPI generator produces a proper multipart/form-data spec
with typed fields (zip as binary, applicationId, dropBuildPath).

Regenerate openapi.json with the drop-deployment endpoint included.
2026-03-07 16:22:05 +07:00
Farrel Darian
7feb4061f8 feat: expose dropDeployment endpoint in public API
Enable file upload deployments via the public API, unlocking CI/CD workflows
similar to `railway up`. Users can now programmatically deploy by uploading
zip archives.

Depends on: Dokploy/trpc-openapi multipart/form-data support
2026-03-07 15:41:43 +07:00
17 changed files with 65229 additions and 21262 deletions

View File

@@ -6,3 +6,387 @@ npm run dev
```
open http://localhost:3000
```
## Environment Variables
The API server requires the following environment variables for configuration:
### Inngest Configuration
Required for the GET /jobs endpoint to list deployment jobs:
- **INNGEST_BASE_URL** - The base URL for the Inngest instance
- Self-hosted: `http://localhost:8288`
- Production: `https://dev-inngest.dokploy.com`
- **INNGEST_SIGNING_KEY** - The signing key for authenticating with Inngest
Optional configuration for filtering and pagination:
- **INNGEST_EVENTS_RECEIVED_AFTER** (optional) - An RFC3339 timestamp to filter events received after a specific date (e.g., `2024-01-01T00:00:00Z`). If unset, no date filter is applied.
- **INNGEST_JOBS_MAX_EVENTS** (optional) - Maximum number of events to fetch when listing jobs. Default is 100, maximum is 10000. Used for pagination with cursor.
### Lemon Squeezy Integration
- **LEMON_SQUEEZY_API_KEY** - API key for Lemon Squeezy integration
- **LEMON_SQUEEZY_STORE_ID** - Store ID for Lemon Squeezy integration
### Docker Configuration
Optional configuration for customizing Docker daemon connections:
- **DOCKER_API_VERSION** (optional) - Specifies which Docker API version to use when connecting to the Docker daemon. If not set, the Docker client uses the default API version.
- **DOKPLOY_DOCKER_HOST** (optional) - Specifies the Docker daemon host to connect to. If not set, uses the default Docker socket connection.
- **DOKPLOY_DOCKER_PORT** (optional) - Specifies the port for connecting to the Docker daemon. If not set, uses the default port.
These variables allow advanced users to customize how the Dokploy API server connects to Docker, which can be useful for connecting to remote Docker daemons or using specific API versions.
## API Endpoints
### GET /jobs
Lists deployment jobs (Inngest runs) for a specified server.
**Query Parameters:**
- `serverId` (required) - The ID of the server to list deployment jobs for
**Response:**
Returns an array of deployment job objects with the same shape as BullMQ queue jobs:
```json
[
{
"id": "string",
"name": "string",
"data": {},
"timestamp": 0,
"processedOn": 0,
"finishedOn": 0,
"failedReason": "string",
"state": "string"
}
]
```
**Error Responses:**
- `400` - serverId is not provided
- `503` - INNGEST_BASE_URL is not configured
- `200` - Empty array on other errors
This endpoint is used by the UI to display deployment queue information in the dashboard.
### POST /drop-deployment
Upload and deploy application code via ZIP file.
**Content-Type:** `multipart/form-data`
**Form Fields:**
- `applicationId` (required) - The ID of the application to deploy
- `zip` (required) - A ZIP file containing the application code
- `dropBuildPath` (optional) - Custom build path within the ZIP file
**Response:**
Initiates a deployment using the uploaded ZIP file.
**Example:**
```bash
curl -X POST https://your-dokploy-instance.com/api/drop-deployment \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-F "applicationId=YOUR_APP_ID" \
-F "zip=@/path/to/your/app.zip" \
-F "dropBuildPath=optional/build/path"
```
## Search Endpoints
The following search endpoints provide flexible querying capabilities with pagination support. All search endpoints respect member permissions, returning only resources the user has access to.
### application.search
Search applications across name, appName, description, repository, owner, and dockerImage fields.
**Query Parameters:**
- `q` (optional string) - General search term that searches across name, appName, description, repository, owner, and dockerImage
- `name` (optional string) - Filter by application name
- `appName` (optional string) - Filter by app name
- `description` (optional string) - Filter by description
- `repository` (optional string) - Filter by repository
- `owner` (optional string) - Filter by owner
- `dockerImage` (optional string) - Filter by Docker image
- `projectId` (optional string) - Filter by project ID
- `environmentId` (optional string) - Filter by environment ID
- `limit` (number, default 20, min 1, max 100) - Maximum number of results
- `offset` (number, default 0, min 0) - Pagination offset
**Response:**
```json
{
"items": [
{
"applicationId": "string",
"name": "string",
"appName": "string",
"description": "string",
"environmentId": "string",
"applicationStatus": "string",
"sourceType": "string",
"createdAt": "string"
}
],
"total": 0
}
```
### compose.search
Search compose services with filtering by name, appName, and description.
**Query Parameters:**
- `q` (optional string) - General search term across name, appName, description
- `name` (optional string) - Filter by name
- `appName` (optional string) - Filter by app name
- `description` (optional string) - Filter by description
- `projectId` (optional string) - Filter by project ID
- `environmentId` (optional string) - Filter by environment ID
- `limit` (number, default 20, min 1, max 100) - Maximum results
- `offset` (number, default 0, min 0) - Pagination offset
**Response:**
```json
{
"items": [
{
"composeId": "string",
"name": "string",
"appName": "string",
"description": "string",
"environmentId": "string",
"composeStatus": "string",
"sourceType": "string",
"createdAt": "string"
}
],
"total": 0
}
```
### environment.search
Search environments by name and description.
**Query Parameters:**
- `q` (optional string) - General search term across name and description
- `name` (optional string) - Filter by name
- `description` (optional string) - Filter by description
- `projectId` (optional string) - Filter by project ID
- `limit` (number, default 20, min 1, max 100) - Maximum results
- `offset` (number, default 0, min 0) - Pagination offset
**Response:**
```json
{
"items": [
{
"environmentId": "string",
"name": "string",
"description": "string",
"createdAt": "string",
"env": "string",
"projectId": "string",
"isDefault": true
}
],
"total": 0
}
```
### project.search
Search projects by name and description.
**Query Parameters:**
- `q` (optional string) - General search term across name and description
- `name` (optional string) - Filter by name
- `description` (optional string) - Filter by description
- `limit` (number, default 20, min 1, max 100) - Maximum results
- `offset` (number, default 0, min 0) - Pagination offset
**Response:**
```json
{
"items": [
{
"projectId": "string",
"name": "string",
"description": "string",
"createdAt": "string",
"organizationId": "string",
"env": "string"
}
],
"total": 0
}
```
### Database Service Search Endpoints
The following database services all share the same search interface:
- **postgres.search**
- **mysql.search**
- **mariadb.search**
- **mongo.search**
- **redis.search**
**Query Parameters:**
- `q` (optional string) - General search term across name, appName, description
- `name` (optional string) - Filter by name
- `appName` (optional string) - Filter by app name
- `description` (optional string) - Filter by description
- `projectId` (optional string) - Filter by project ID
- `environmentId` (optional string) - Filter by environment ID
- `limit` (number, default 20, min 1, max 100) - Maximum results
- `offset` (number, default 0, min 0) - Pagination offset
**Response:**
```json
{
"items": [
{
"postgresId": "string",
"name": "string",
"appName": "string",
"description": "string",
"environmentId": "string",
"applicationStatus": "string",
"createdAt": "string"
}
],
"total": 0
}
```
*Note: The response shape is similar across all database services, with the ID field varying (e.g., `mysqlId`, `mariadbId`, `mongoId`, `redisId`).*
**Search Behavior:**
- All searches use case-insensitive pattern matching with wildcards
- Results are ordered by creation date (descending)
- Members only see services they have access to
- Returns total count for pagination UI
## Database Service Update Endpoints
All database services support update operations with flexible configuration options. The following database services share a common update interface:
- **postgres.update** (apiUpdatePostgres)
- **mysql.update** (apiUpdateMySql)
- **mariadb.update** (apiUpdateMariaDB)
- **mongo.update** (apiUpdateMongo)
- **redis.update** (apiUpdateRedis)
**Common Parameters:**
All database update endpoints accept their respective ID field (e.g., `postgresId`, `mysqlId`, `mariadbId`, `mongoId`, `redisId`) as a required parameter, along with optional configuration fields.
**Optional Configuration:**
- `dockerImage` (optional string) - Specifies a custom Docker image for the database service. This allows users to use specific versions or custom-built images instead of the default image for the database type. Available for all five database services (PostgreSQL, MySQL, MariaDB, MongoDB, and Redis).
Additional service-specific parameters are available depending on the database type. The `dockerImage` field provides enhanced configuration flexibility for advanced use cases such as version pinning or using specialized database distributions.
## Whitelabeling Endpoints
The whitelabeling endpoints allow enterprise/self-hosted Dokploy instances to customize branding, logos, colors, and UI appearance. These endpoints are only available in self-hosted mode (not cloud).
### whitelabeling.get
Get the current whitelabeling configuration.
**Requirements:**
- Enterprise license required
- Only available for self-hosted (not cloud)
**Response:**
Returns the whitelabeling configuration object or null if not configured.
```json
{
"appName": "string | null",
"appDescription": "string | null",
"logoUrl": "string | null",
"faviconUrl": "string | null",
"primaryColor": "string | null",
"customCss": "string | null",
"loginLogoUrl": "string | null",
"supportUrl": "string | null",
"docsUrl": "string | null",
"errorPageTitle": "string | null",
"errorPageDescription": "string | null",
"metaTitle": "string | null",
"footerText": "string | null"
}
```
### whitelabeling.update
Update the whitelabeling configuration.
**Requirements:**
- Enterprise license required
- Owner role required
- Only available for self-hosted (not cloud)
**Input:**
```json
{
"whitelabelingConfig": {
"appName": "string | null",
"appDescription": "string | null",
"logoUrl": "string | null",
"faviconUrl": "string | null",
"primaryColor": "string | null",
"customCss": "string | null",
"loginLogoUrl": "string | null",
"supportUrl": "string | null",
"docsUrl": "string | null",
"errorPageTitle": "string | null",
"errorPageDescription": "string | null",
"metaTitle": "string | null",
"footerText": "string | null"
}
}
```
**Response:**
```json
{
"success": true
}
```
### whitelabeling.reset
Reset whitelabeling configuration to default values (all fields set to null).
**Requirements:**
- Enterprise license required
- Owner role required
- Only available for self-hosted (not cloud)
**Response:**
```json
{
"success": true
}
```
### whitelabeling.getPublic
Public endpoint to fetch whitelabeling configuration. This endpoint can be accessed without authentication, allowing the whitelabeling settings to be applied globally (including on the login page before auth).
**Requirements:**
- No authentication required
- Only available for self-hosted (not cloud)
**Response:**
Returns the whitelabeling configuration object or null if not configured. Response shape is identical to `whitelabeling.get`.

View File

@@ -1,4 +1,11 @@
import { HardDriveDownload, Loader2 } from "lucide-react";
import {
AlertTriangle,
CheckCircle2,
HardDriveDownload,
Loader2,
RefreshCw,
XCircle,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
@@ -15,11 +22,70 @@ import {
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
type ServiceStatus = {
status: "healthy" | "unhealthy";
message?: string;
};
type HealthResult = {
postgres: ServiceStatus;
redis: ServiceStatus;
traefik: ServiceStatus;
};
type ModalState = "idle" | "checking" | "results" | "updating";
const ServiceStatusItem = ({
name,
service,
}: {
name: string;
service: ServiceStatus;
}) => (
<div className="flex items-center gap-2">
{service.status === "healthy" ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<span className="text-sm font-medium">{name}</span>
{service.status === "unhealthy" && service.message && (
<span className="text-xs text-muted-foreground"> {service.message}</span>
)}
</div>
);
export const UpdateWebServer = () => {
const [updating, setUpdating] = useState(false);
const [modalState, setModalState] = useState<ModalState>("idle");
const [open, setOpen] = useState(false);
const [healthResult, setHealthResult] = useState<HealthResult | null>(null);
const { mutateAsync: updateServer } = api.settings.updateServer.useMutation();
const { refetch: checkHealth } =
api.settings.checkInfrastructureHealth.useQuery(undefined, {
enabled: false,
});
const handleVerify = async () => {
setModalState("checking");
setHealthResult(null);
try {
const result = await checkHealth();
if (result.data) {
setHealthResult(result.data);
}
} catch {
// checkHealth failed entirely
}
setModalState("results");
};
const allHealthy =
healthResult &&
healthResult.postgres.status === "healthy" &&
healthResult.redis.status === "healthy" &&
healthResult.traefik.status === "healthy";
const checkIsUpdateFinished = async () => {
try {
@@ -33,28 +99,24 @@ export const UpdateWebServer = () => {
);
setTimeout(() => {
// Allow seeing the toast before reloading
window.location.reload();
}, 2000);
} catch {
// Delay each request
await new Promise((resolve) => setTimeout(resolve, 2000));
// Keep running until it returns 200
void checkIsUpdateFinished();
}
};
const handleConfirm = async () => {
try {
setUpdating(true);
setModalState("updating");
await updateServer();
// Give some time for docker service restart before starting to check status
await new Promise((resolve) => setTimeout(resolve, 8000));
await checkIsUpdateFinished();
} catch (error) {
setUpdating(false);
setModalState("results");
console.error("Error updating server:", error);
toast.error(
"An error occurred while updating the server, please try again.",
@@ -62,6 +124,14 @@ export const UpdateWebServer = () => {
}
};
const handleClose = () => {
if (modalState !== "updating") {
setOpen(false);
setModalState("idle");
setHealthResult(null);
}
};
return (
<AlertDialog open={open}>
<AlertDialogTrigger asChild>
@@ -81,36 +151,111 @@ export const UpdateWebServer = () => {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{updating
? "Server update in progress"
: "Are you absolutely sure?"}
{modalState === "idle" && "Are you absolutely sure?"}
{modalState === "checking" && "Verifying Services..."}
{modalState === "results" &&
(allHealthy ? "Ready to Update" : "Service Issues Detected")}
{modalState === "updating" && "Server update in progress"}
</AlertDialogTitle>
<AlertDialogDescription>
{updating ? (
<span className="flex items-center gap-1">
<Loader2 className="animate-spin" />
The server is being updated, please wait...
</span>
) : (
<>
This action cannot be undone. This will update the web server to
the new version. You will not be able to use the panel during
the update process. The page will be reloaded once the update is
finished.
</>
)}
<AlertDialogDescription asChild>
<div>
{modalState === "idle" && (
<span>
This will update the web server to the new version. You will
not be able to use the panel during the update process. The
page will be reloaded once the update is finished.
<br />
<br />
We recommend verifying that all services are running before
updating.
</span>
)}
{modalState === "checking" && (
<span className="flex items-center gap-2">
<Loader2 className="animate-spin h-4 w-4" />
Checking PostgreSQL, Redis and Traefik...
</span>
)}
{modalState === "results" && healthResult && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<ServiceStatusItem
name="PostgreSQL"
service={healthResult.postgres}
/>
<ServiceStatusItem
name="Redis"
service={healthResult.redis}
/>
<ServiceStatusItem
name="Traefik"
service={healthResult.traefik}
/>
</div>
{!allHealthy && (
<div className="flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-3">
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
Some services are not healthy. You can still proceed
with the update.
</span>
</div>
)}
{allHealthy && (
<span className="text-sm text-muted-foreground">
All services are running. You can proceed with the update.
</span>
)}
</div>
)}
{modalState === "results" && !healthResult && (
<div className="flex items-start gap-2 rounded-md border border-yellow-500/30 bg-yellow-500/10 p-3">
<AlertTriangle className="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
<span className="text-sm text-yellow-600 dark:text-yellow-400">
Could not verify services. You can still proceed with the
update.
</span>
</div>
)}
{modalState === "updating" && (
<span className="flex items-center gap-2">
<Loader2 className="animate-spin h-4 w-4" />
The server is being updated, please wait...
</span>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
{!updating && (
{modalState === "idle" && (
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOpen(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogCancel onClick={handleClose}>Cancel</AlertDialogCancel>
<Button variant="secondary" onClick={handleVerify}>
<RefreshCw className="h-4 w-4" />
Verify Status
</Button>
<AlertDialogAction onClick={handleConfirm}>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
)}
{modalState === "results" && (
<AlertDialogFooter>
<AlertDialogCancel onClick={handleClose}>Cancel</AlertDialogCancel>
<Button variant="secondary" onClick={handleVerify}>
<RefreshCw className="h-4 w-4" />
Re-check
</Button>
<AlertDialogAction onClick={handleConfirm}>
{allHealthy ? "Confirm" : "Confirm Anyway"}
</AlertDialogAction>
</AlertDialogFooter>
)}
</AlertDialogContent>
</AlertDialog>
);

View File

@@ -0,0 +1,5 @@
ALTER TABLE "apikey" ALTER COLUMN "user_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "apikey" ADD COLUMN "config_id" text DEFAULT 'default' NOT NULL;--> statement-breakpoint
ALTER TABLE "apikey" ADD COLUMN "reference_id" text;--> statement-breakpoint
UPDATE "apikey" SET "reference_id" = "user_id" WHERE "reference_id" IS NULL;--> statement-breakpoint
ALTER TABLE "apikey" ALTER COLUMN "reference_id" SET NOT NULL;

View File

@@ -0,0 +1,4 @@
ALTER TABLE "apikey" DROP CONSTRAINT "apikey_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "apikey" ADD CONSTRAINT "apikey_reference_id_user_id_fk" FOREIGN KEY ("reference_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "apikey" DROP COLUMN "user_id";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1051,6 +1051,20 @@
"when": 1773637297592,
"tag": "0149_rare_radioactive_man",
"breakpoints": true
},
{
"idx": 150,
"version": "7",
"when": 1773870095817,
"tag": "0150_nappy_blue_blade",
"breakpoints": true
},
{
"idx": 151,
"version": "7",
"when": 1773872561300,
"tag": "0151_modern_sunfire",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.28.7",
"version": "v0.28.8",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -57,7 +57,7 @@
"@codemirror/search": "^6.6.0",
"@codemirror/view": "^6.39.15",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.17",
"@dokploy/trpc-openapi": "0.0.18",
"@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^5.2.2",
"@octokit/auth-app": "^6.1.3",

View File

@@ -38,6 +38,7 @@ import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import { zfd } from "zod-form-data";
import {
createTRPCRouter,
protectedProcedure,
@@ -769,21 +770,17 @@ export const applicationRouter = createTRPCRouter({
}),
dropDeployment: protectedProcedure
.meta({
openapi: {
path: "/drop-deployment",
method: "POST",
override: true,
enabled: false,
},
})
.input(z.instanceof(FormData))
.input(
zfd.formData({
applicationId: z.string(),
zip: zfd.file(),
dropBuildPath: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const formData = input;
const zipFile = formData.get("zip") as File;
const applicationId = formData.get("applicationId") as string;
const dropBuildPath = formData.get("dropBuildPath") as string | null;
const zipFile = input.zip;
const applicationId = input.applicationId;
const dropBuildPath = input.dropBuildPath ?? null;
await checkServicePermissionAndAccess(ctx, applicationId, {
deployment: ["create"],

View File

@@ -2,6 +2,9 @@ import {
CLEANUP_CRON_JOB,
checkGPUStatus,
checkPortInUse,
checkPostgresHealth,
checkRedisHealth,
checkTraefikHealth,
cleanupAll,
cleanupAllBackground,
cleanupBuilders,
@@ -44,8 +47,8 @@ import {
writeTraefikConfigInPath,
writeTraefikSetup,
} from "@dokploy/server";
import { checkPermission } from "@dokploy/server/services/permission";
import { db } from "@dokploy/server/db";
import { checkPermission } from "@dokploy/server/services/permission";
import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
import { TRPCError } from "@trpc/server";
import { eq, sql } from "drizzle-orm";
@@ -864,6 +867,23 @@ export const settingsRouter = createTRPCRouter({
throw error;
}
}),
checkInfrastructureHealth: adminProcedure.query(async () => {
if (IS_CLOUD) {
return {
postgres: { status: "healthy" as const },
redis: { status: "healthy" as const },
traefik: { status: "healthy" as const },
};
}
const [postgres, redis, traefik] = await Promise.all([
checkPostgresHealth(),
checkRedisHealth(),
checkTraefikHealth(),
]);
return { postgres, redis, traefik };
}),
setupGPU: adminProcedure
.input(
z.object({

View File

@@ -465,7 +465,7 @@ export const userRouter = createTRPCRouter({
});
}
if (apiKeyToDelete.userId !== ctx.user.id) {
if (apiKeyToDelete.referenceId !== ctx.user.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this API key",

70177
openapi.json

File diff suppressed because it is too large Load Diff

View File

@@ -214,7 +214,8 @@ export const apikey = pgTable("apikey", {
start: text("start"),
prefix: text("prefix"),
key: text("key").notNull(),
userId: text("user_id")
configId: text("config_id").default("default").notNull(),
referenceId: text("reference_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
refillInterval: integer("refill_interval"),
@@ -236,7 +237,7 @@ export const apikey = pgTable("apikey", {
export const apikeyRelations = relations(apikey, ({ one }) => ({
user: one(user, {
fields: [apikey.userId],
fields: [apikey.referenceId],
references: [user.id],
}),
}));

View File

@@ -367,6 +367,7 @@ const { handler, api } = betterAuth({
plugins: [
apiKey({
enableMetadata: true,
references: "user",
}),
sso(),
twoFactor(),

View File

@@ -432,7 +432,7 @@ export const createApiKey = async (
refillInterval?: number;
},
) => {
const apiKey = await auth.createApiKey({
const result = await auth.createApiKey({
body: {
name: input.name,
expiresIn: input.expiresIn,
@@ -450,10 +450,9 @@ export const createApiKey = async (
if (input.metadata) {
await db
.update(apikey)
.set({
metadata: JSON.stringify(input.metadata),
})
.where(eq(apikey.id, apiKey.id));
.set({ metadata: JSON.stringify(input.metadata) })
.where(eq(apikey.id, result.id));
}
return apiKey;
return result;
};

View File

@@ -741,3 +741,177 @@ export const getComposeContainer = async (
throw error;
}
};
type ServiceHealthStatus = {
status: "healthy" | "unhealthy";
message?: string;
};
const checkSwarmServiceRunning = async (
serviceName: string,
): Promise<ServiceHealthStatus> => {
try {
const service = docker.getService(serviceName);
const info = await service.inspect();
const replicas = info.Spec?.Mode?.Replicated?.Replicas ?? 0;
if (replicas === 0) {
return {
status: "unhealthy",
message: "Service has 0 replicas configured",
};
}
// Check that at least one task is actually running
const tasks = await docker.listTasks({
filters: JSON.stringify({
service: [serviceName],
"desired-state": ["running"],
}),
});
const runningTask = tasks.find((t) => t.Status?.State === "running");
if (!runningTask) {
const latestTask = tasks[0];
const taskState = latestTask?.Status?.State ?? "unknown";
return {
status: "unhealthy",
message: `No running tasks (current state: ${taskState})`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message: error instanceof Error ? error.message : "Service not found",
};
}
};
const getSwarmServiceContainerId = async (
serviceName: string,
): Promise<string | null> => {
try {
const tasks = await docker.listTasks({
filters: JSON.stringify({
service: [serviceName],
"desired-state": ["running"],
}),
});
const runningTask = tasks.find((t) => t.Status?.State === "running");
return runningTask?.Status?.ContainerStatus?.ContainerID ?? null;
} catch {
return null;
}
};
export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-postgres");
if (serviceCheck.status === "unhealthy") {
return serviceCheck;
}
// Verify PostgreSQL actually accepts connections
const containerId = await getSwarmServiceContainerId("dokploy-postgres");
if (!containerId) {
return { status: "unhealthy", message: "Could not find running container" };
}
try {
const exec = await docker.getContainer(containerId).exec({
Cmd: ["pg_isready", "-U", "dokploy"],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({});
const output = await new Promise<string>((resolve) => {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.on("end", () => resolve(data));
});
const inspectResult = await exec.inspect();
if (inspectResult.ExitCode !== 0) {
return {
status: "unhealthy",
message: `PostgreSQL not ready: ${output.trim()}`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message:
error instanceof Error ? error.message : "Failed to check PostgreSQL",
};
}
};
export const checkRedisHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-redis");
if (serviceCheck.status === "unhealthy") {
return serviceCheck;
}
// Verify Redis actually responds to PING
const containerId = await getSwarmServiceContainerId("dokploy-redis");
if (!containerId) {
return { status: "unhealthy", message: "Could not find running container" };
}
try {
const exec = await docker.getContainer(containerId).exec({
Cmd: ["redis-cli", "ping"],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({});
const output = await new Promise<string>((resolve) => {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.on("end", () => resolve(data));
});
if (!output.includes("PONG")) {
return {
status: "unhealthy",
message: `Redis did not respond with PONG: ${output.trim()}`,
};
}
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
message: error instanceof Error ? error.message : "Failed to check Redis",
};
}
};
export const checkTraefikHealth = async (): Promise<ServiceHealthStatus> => {
// Traefik can run as a standalone container or a swarm service
try {
const container = docker.getContainer("dokploy-traefik");
const info = await container.inspect();
if (!info.State.Running) {
return {
status: "unhealthy",
message: "Container is not running",
};
}
return { status: "healthy" };
} catch {
// Not a standalone container, check as swarm service
return checkSwarmServiceRunning("dokploy-traefik");
}
};

10
pnpm-lock.yaml generated
View File

@@ -147,8 +147,8 @@ importers:
specifier: workspace:*
version: link:../../packages/server
'@dokploy/trpc-openapi':
specifier: 0.0.17
version: 0.0.17(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
specifier: 0.0.18
version: 0.0.18(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
'@faker-js/faker':
specifier: ^8.4.1
version: 8.4.1
@@ -1282,8 +1282,8 @@ packages:
'@codemirror/view@6.39.15':
resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
'@dokploy/trpc-openapi@0.0.17':
resolution: {integrity: sha512-pXWbqx2W0MoWav/wehEqcXzORLgn7PhnmLsZza1v6+lOSo0Vwuu47PrITbRYKQ2zZcR1nTL18TrgPuMzXK23Iw==}
'@dokploy/trpc-openapi@0.0.18':
resolution: {integrity: sha512-CbppvUEe8eK1fiNGQL5AH8KIRRlHk5bGPUEIyc2VBZE0un4kfUs5DXKSKsMLDomoES5ZEdrjT4nKpwYvhDha0w==}
peerDependencies:
'@trpc/server': ^11.1.0
zod: ^4.3.6
@@ -8926,7 +8926,7 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@dokploy/trpc-openapi@0.0.17(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
'@dokploy/trpc-openapi@0.0.18(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
dependencies:
'@trpc/server': 11.10.0(typescript@5.9.3)
co-body: 6.2.0