/** * Dokploy API Client (v2 — correct tRPC-based API) * ----------------------------------------------- * Manages tenant instances on the Dokploy server. * * API format (verified from @dokploy/mcp source): * - Base URL: {DOKPLOY_URL}/api * - Auth: x-api-key header (NOT Authorization: Bearer) * - Endpoints: /application.create, /application.deploy, /application.delete, etc. * - Application creation is multi-step: * 1. POST /application.create { name, environmentId, sourceType: 'gitea' } * 2. POST /application.saveGiteaProvider { applicationId, giteaOwner, giteaRepository, giteaBranch, giteaBuildPath } * (or saveGitProvider for generic git, saveGithubProvider for github) * 3. POST /application.saveEnvironment { applicationId, env: [...] } * 4. POST /application.deploy { applicationId } * * Config (env): * DOKPLOY_URL - e.g. https://cp.project-url.eu * DOKPLOY_API_KEY - x-api-key value * DOKPLOY_ENVIRONMENT_ID - the Dokploy environment ID to create apps under * DOKPLOY_SERVER_ID - (optional) specific server, null = default */ export interface DokployConfig { url: string apiKey: string environmentId?: string serverId?: string | null } export interface DokployApp { applicationId: string name: string appName?: string status?: string previewDomain?: string createdAt?: string } export interface DokployDomain { domainId: string hostName: string applicationId: string https: boolean } export interface ProvisionInstanceInput { name: string subdomain: string gitRepo: string gitBranch: string buildPath?: string envVars: Record gitProvider?: 'gitea' | 'github' | 'git' // default: gitea // Resource limits (applied via application.update after creation) cpuLimit?: string memoryLimit?: string } export interface ProvisionInstanceResult { app: DokployApp domain?: DokployDomain url: string } class DokployError extends Error { constructor(message: string, public status: number, public endpoint: string, public body?: unknown) { super(message) this.name = 'DokployError' } } export function createDokployClient(config: DokployConfig) { const baseUrl = config.url.replace(/\/+$/, '') + '/api' const headers: Record = { 'Content-Type': 'application/json', Accept: 'application/json', 'x-api-key': config.apiKey, } async function call(path: string, body?: Record, method = 'POST'): Promise { const url = `${baseUrl}${path}` const isGet = method === 'GET' const res = await fetch(url, { method, headers, body: !isGet && body ? JSON.stringify(body) : undefined, }) const text = await res.text() let json: unknown try { json = text ? JSON.parse(text) : null } catch { json = text } if (!res.ok) { throw new DokployError( `Dokploy API ${path} failed: HTTP ${res.status}`, res.status, path, json ) } return json as T } // --- Applications --- async function createApplication(input: { name: string environmentId: string sourceType?: string serverId?: string | null description?: string }): Promise { const body: Record = { name: input.name, environmentId: input.environmentId, sourceType: input.sourceType ?? 'gitea', } if (input.serverId) body.serverId = input.serverId if (input.description) body.description = input.description const res = await call<{ applicationId?: string; app?: DokployApp; [k: string]: unknown }>( '/application.create', body ) const applicationId = res.applicationId ?? (res.app as DokployApp | undefined)?.applicationId if (!applicationId) { throw new DokployError('createApplication returned no applicationId', 200, '/application.create', res) } return { applicationId, name: input.name, status: 'created' } } async function saveGiteaProvider(input: { applicationId: string owner: string repository: string branch: string buildPath?: string | null id?: string | null }): Promise { await call('/application.saveGiteaProvider', { applicationId: input.applicationId, owner: input.owner, repository: input.repository, branch: input.branch, buildPath: input.buildPath ?? '/', id: input.id ?? null, }) } async function saveGitProvider(input: { applicationId: string owner?: string | null repository?: string | null branch?: string buildPath?: string | null id?: string | null }): Promise { await call('/application.saveGitProvider', { applicationId: input.applicationId, owner: input.owner ?? null, repository: input.repository ?? null, branch: input.branch ?? 'main', buildPath: input.buildPath ?? '/', id: input.id ?? null, }) } async function saveGithubProvider(input: { applicationId: string owner: string repository: string branch: string buildPath?: string | null githubId?: string | null }): Promise { await call('/application.saveGithubProvider', { applicationId: input.applicationId, owner: input.owner, repository: input.repository, branch: input.branch, buildPath: input.buildPath ?? '/', githubId: input.githubId ?? null, }) } async function saveBuildType(input: { applicationId: string buildType?: string dockerfile?: string dockerContextPath?: string dockerBuildStage?: string herokuVersion?: string railpackVersion?: string paketoBuilderImage?: string paketoBuildpacks?: string[] }): Promise { await call('/application.saveBuildType', { applicationId: input.applicationId, buildType: input.buildType ?? 'dockerfile', dockerfile: input.dockerfile ?? 'Dockerfile', dockerContextPath: input.dockerContextPath ?? '/', dockerBuildStage: input.dockerBuildStage ?? '', herokuVersion: input.herokuVersion ?? '24', railpackVersion: input.railpackVersion ?? 'latest', paketoBuilderImage: input.paketoBuilderImage ?? '', paketoBuildpacks: input.paketoBuildpacks ?? [], }) } async function saveEnvironment(applicationId: string, envVars: Record): Promise { // Dokploy expects env as a .env-style string (KEY=value\nKEY2=value2) const envString = Object.entries(envVars) .map(([key, value]) => `${key}=${value}`) .join('\n') try { await call('/application.saveEnvironment', { applicationId, env: envString, buildArgs: '[]', buildSecrets: '[]', createEnvFile: true, }) } catch (e) { // Re-throw with the actual error body for debugging if (e instanceof DokployError) { throw new DokployError( `saveEnvironment failed: ${e.message} | body: ${JSON.stringify(e.body)?.substring(0, 300)}`, e.status, '/application.saveEnvironment', e.body ) } throw e } } async function deployApplication(applicationId: string): Promise<{ jobId?: string; status: string }> { const res = await call | null>('/application.deploy', { applicationId, }) if (!res) return { jobId: undefined, status: 'deploying' } const jobId = (res.jobId as string) ?? (res.deployId as string) ?? (res.id as string) const status = (res.status as string) ?? 'deploying' return { jobId, status } } async function getApplication(applicationId: string): Promise { const res = await call( '/application.one', { applicationId }, 'GET' ) return { applicationId: res.applicationId, name: res.name, appName: res.appName, status: res.applicationStatus ?? res.status, previewDomain: res.previewDomain, createdAt: res.createdAt, } } async function deleteApplication(applicationId: string): Promise { await call('/application.delete', { applicationId }) } async function stopApplication(applicationId: string): Promise { await call('/application.stop', { applicationId }) } async function startApplication(applicationId: string): Promise { await call('/application.start', { applicationId }) } async function redeployApplication(applicationId: string): Promise { await call('/application.redeploy', { applicationId }) } // --- Domains --- async function createDomain(input: { applicationId: string host: string path?: string port?: number https?: boolean certificateType?: string }): Promise { const res = await call<{ domainId?: string; id?: string }>('/domain.create', { applicationId: input.applicationId, host: input.host, path: input.path ?? '/', port: input.port ?? 3000, https: input.https ?? true, certificateType: input.certificateType ?? 'letsencrypt', }) const domainId = res.domainId ?? res.id if (!domainId) { throw new DokployError('createDomain returned no domainId', 200, '/domain.create', res) } return { domainId, hostName: input.host, applicationId: input.applicationId, https: input.https ?? true, } } async function deleteDomain(domainId: string): Promise { await call('/domain.delete', { domainId }) } // --- High-level: provision a full instance --- async function provisionInstance(input: ProvisionInstanceInput): Promise { const environmentId = config.environmentId ?? '' if (!environmentId) { throw new DokployError('DOKPLOY_ENVIRONMENT_ID not configured', 500, 'config') } // 1. Create application const app = await createApplication({ name: input.name, environmentId, sourceType: input.gitProvider ?? 'gitea', serverId: config.serverId, description: `Tenant instance: ${input.subdomain}`, }) // 2. Save Git provider (repo connection) // Parse git repo URL: https://git.dfmpanel.eu/dfmsolutions/AOS.git // → owner: dfmsolutions, repo: AOS const gitMatch = input.gitRepo.match(/\/([^/]+)\/([^/]+?)(?:\.git)?$/) const owner = gitMatch?.[1] ?? '' const repo = gitMatch?.[2] ?? '' if (input.gitProvider === 'github' || input.gitRepo.includes('github.com')) { await saveGithubProvider({ applicationId: app.applicationId, owner, repository: repo, branch: input.gitBranch, buildPath: input.buildPath ?? '/', }) } else if (input.gitProvider === 'gitea' || input.gitRepo.includes('git.dfmpanel.eu')) { await saveGiteaProvider({ applicationId: app.applicationId, owner, repository: repo, branch: input.gitBranch, buildPath: input.buildPath ?? '/', }) } else { // Generic git await saveGitProvider({ applicationId: app.applicationId, owner, repository: repo, branch: input.gitBranch, buildPath: input.buildPath ?? '/', }) } // 3. Set build type to Dockerfile (default is nixpacks which fails for many repos) await saveBuildType({ applicationId: app.applicationId, buildType: 'dockerfile', dockerfile: 'Dockerfile', dockerContextPath: '/', }) // 4. Save environment variables await saveEnvironment(app.applicationId, input.envVars) // 5. Deploy await deployApplication(app.applicationId) // 6. Attach domain (with HTTPS + Let's Encrypt) let domain: DokployDomain | undefined try { domain = await createDomain({ applicationId: app.applicationId, host: input.subdomain, port: 3000, https: true, certificateType: 'letsencrypt', }) } catch (e) { // Domain creation is non-fatal — app is already deployed console.warn('[dokploy] Domain creation failed (non-fatal):', e instanceof Error ? e.message : e) } return { app, domain, url: `https://${input.subdomain}`, } } // --- High-level: teardown an instance (idempotent) --- async function teardownInstance(input: { dokployAppId?: string dokployDomainId?: string }): Promise<{ steps: string[] }> { const steps: string[] = [] // Remove domain first (stop traffic) if (input.dokployDomainId) { try { await deleteDomain(input.dokployDomainId) steps.push('domain.removed') } catch (e) { if (e instanceof DokployError && e.status !== 404) { steps.push(`domain.error: ${e.message}`) } } } // Delete application (+ volumes) if (input.dokployAppId) { try { await deleteApplication(input.dokployAppId) steps.push('application.removed') } catch (e) { if (e instanceof DokployError && e.status !== 404) { steps.push(`application.error: ${e.message}`) } } } return { steps } } return { // Low-level createApplication, saveGiteaProvider, saveGitProvider, saveGithubProvider, saveBuildType, saveEnvironment, deployApplication, getApplication, deleteApplication, stopApplication, startApplication, redeployApplication, createDomain, deleteDomain, call, // High-level provisionInstance, teardownInstance, } } export type DokployClient = ReturnType export function createDokployClientFromEnv(): DokployClient | null { const url = process.env.DOKPLOY_URL const apiKey = process.env.DOKPLOY_API_KEY if (!url || !apiKey) return null return createDokployClient({ url, apiKey, environmentId: process.env.DOKPLOY_ENVIRONMENT_ID, serverId: process.env.DOKPLOY_SERVER_ID ?? null, }) }