Merge branch 'canary' into 216-domains-for-services-created-via-template

This commit is contained in:
Mauricio Siu 2024-08-15 00:00:41 -06:00
commit 29ca894a97
67 changed files with 19595 additions and 11993 deletions

View File

@ -58,7 +58,7 @@ jobs:
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then if [ "${CIRCLE_BRANCH}" == "main" ]; then
VERSION=$(node -p "require('apps/dokploy/package.json').version") VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo $VERSION echo $VERSION
TAG="latest" TAG="latest"

50
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: Build Docs & Website Docker images
on:
push:
branches: ["canary", "main"]
jobs:
build-and-push-image-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.docs
push: true
tags: dokploy/docs:latest
platforms: linux/amd64
build-and-push-image-website:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.website
push: true
tags: dokploy/website:latest
platforms: linux/amd64

4
.gitignore vendored
View File

@ -33,6 +33,10 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# Editor
.vscode
.idea
# Misc # Misc
.DS_Store .DS_Store
*.pem *.pem

35
Dockerfile.docs Normal file
View File

@ -0,0 +1,35 @@
FROM node:18-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --filter=./apps/docs --frozen-lockfile
# Deploy only the dokploy app
ENV NODE_ENV=production
RUN pnpm --filter=./apps/docs run build
RUN pnpm --filter=./apps/docs --prod deploy /prod/docs
RUN cp -R /usr/src/app/apps/docs/.next /prod/docs/.next
FROM base AS dokploy
WORKDIR /app
# Set production
ENV NODE_ENV=production
# Copy only the necessary files
COPY --from=build /prod/docs/.next ./.next
COPY --from=build /prod/docs/public ./public
COPY --from=build /prod/docs/package.json ./package.json
COPY --from=build /prod/docs/node_modules ./node_modules
EXPOSE 3000
CMD HOSTNAME=0.0.0.0 && pnpm start

35
Dockerfile.website Normal file
View File

@ -0,0 +1,35 @@
FROM node:18-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --filter=./apps/website --frozen-lockfile
# Deploy only the dokploy app
ENV NODE_ENV=production
RUN pnpm --filter=./apps/website run build
RUN pnpm --filter=./apps/website --prod deploy /prod/website
RUN cp -R /usr/src/app/apps/website/.next /prod/website/.next
FROM base AS dokploy
WORKDIR /app
# Set production
ENV NODE_ENV=production
# Copy only the necessary files
COPY --from=build /prod/website/.next ./.next
COPY --from=build /prod/website/public ./public
COPY --from=build /prod/website/package.json ./package.json
COPY --from=build /prod/website/node_modules ./node_modules
EXPOSE 3000
CMD HOSTNAME=0.0.0.0 && pnpm start

View File

@ -1,12 +1,19 @@
<div align="center"> <div align="center">
<h1 align="center">Dokploy</h1> <h1 align="center">Dokploy</h1>
<div> <div>
<img style="object-fit: cover; border-radius:20px;" align="center" width="50%"src="https://raw.githubusercontent.com/Dokploy/docs/main/public/logo.png" > <img style="object-fit: cover; border-radius:20px;" align="center" width="50%"src="https://dokploy.com/og.png" >
</div> </div>
</br>
<div align="center">
<div>Join us on Discord for help, feedback, and discussions!</div>
</br>
<a href="https://discord.gg/ZXwG32bw">
<img src="https://discordapp.com/api/guilds/1234073262418563112/widget.png?style=banner2" alt="Discord Shield"/>
</a>
</div>
</div> </div>
<br /> <br />
Dokploy is a free self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases. Dokploy is a free self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
@ -52,7 +59,7 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
### Premium Supporters 🥇 ### Premium Supporters 🥇
<div style="display: flex; gap: 30px; flex-wrap: wrap;"> <div style="display: flex; gap: 30px; flex-wrap: wrap;">
<a href="https://supafort.com/" target="_blank"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="bc direct logo" width="190"/></a> <a href="https://supafort.com/?ref=dokploy" target="_blank"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="190"/></a>
</div> </div>
<!-- Elite Contributors 🥈 --> <!-- Elite Contributors 🥈 -->
@ -62,13 +69,13 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
### Supporting Members 🥉 ### Supporting Members 🥉
<div style="display: flex; gap: 30px; flex-wrap: wrap;"> <div style="display: flex; gap: 30px; flex-wrap: wrap;">
<a href="https://lightspeed.run/"><img src="https://github.com/lightspeedrun.png" width="60px" alt="Lightspeed.run"/></a> <a href="https://lightspeed.run/?ref=dokploy"><img src="https://github.com/lightspeedrun.png" width="60px" alt="Lightspeed.run"/></a>
</div> </div>
### Community Backers 🤝 ### Community Backers 🤝
<div style="display: flex; gap: 30px; flex-wrap: wrap;"> <div style="display: flex; gap: 30px; flex-wrap: wrap;">
<a href="https://steamsets.com/"><img src="https://avatars.githubusercontent.com/u/111978405?s=200&v=4" width="60px" alt="Lightspeed.run"/></a> <a href="https://steamsets.com/?ref=dokploy"><img src="https://avatars.githubusercontent.com/u/111978405?s=200&v=4" width="60px" alt="Steamsets.com"/></a>
</div> </div>
#### Organizations: #### Organizations:

View File

@ -3,13 +3,14 @@ title: "Overview"
description: "Learn how to use Docker Compose with Dokploy" description: "Learn how to use Docker Compose with Dokploy"
--- ---
import { Callout } from "fumadocs-ui/components/callout";
Dokploy integrates with Docker Compose and Docker Stack to provide flexible deployment solutions. Whether you are developing locally or deploying at scale, Dokploy facilitates application management through these powerful Docker tools. Dokploy integrates with Docker Compose and Docker Stack to provide flexible deployment solutions. Whether you are developing locally or deploying at scale, Dokploy facilitates application management through these powerful Docker tools.
### Configuration Methods ### Configuration Methods
Dokploy provides two methods for creating Docker Compose configurations: Dokploy provides two methods for creating Docker Compose configurations:
- **Docker Compose**: Ideal for standard Docker Compose configurations. - **Docker Compose**: Ideal for standard Docker Compose configurations.
- **Stack**: Geared towards orchestrating applications using Docker Swarm. Note that some Docker Compose features, such as `build`, are not available in this mode. - **Stack**: Geared towards orchestrating applications using Docker Swarm. Note that some Docker Compose features, such as `build`, are not available in this mode.
@ -29,7 +30,6 @@ Monitor each service individually within Dokploy. If your application consists o
Access detailed logs for each service through the Dokploy log viewer, which can help in troubleshooting and ensuring the stability of your services. Access detailed logs for each service through the Dokploy log viewer, which can help in troubleshooting and ensuring the stability of your services.
### Deployments ### Deployments
You can view the last 10 deployments of your application. When you deploy your application in real time, a new deployment record will be created and it will gradually show you how your application is being built. You can view the last 10 deployments of your application. When you deploy your application in real time, a new deployment record will be created and it will gradually show you how your application is being built.
@ -38,7 +38,6 @@ We also offer a button to cancel deployments that are in queue. Note that those
We provide a webhook so that you can trigger your own deployments by pushing to your GitHub, Gitea, GitLab, Bitbucket repository. We provide a webhook so that you can trigger your own deployments by pushing to your GitHub, Gitea, GitLab, Bitbucket repository.
### Advanced ### Advanced
This section provides advanced configuration options for experienced users. It includes tools for custom commands within the container and volumes. This section provides advanced configuration options for experienced users. It includes tools for custom commands within the container and volumes.
@ -46,4 +45,32 @@ This section provides advanced configuration options for experienced users. It i
- **Command**: Dokploy has a defined command to run the Docker Compose file, ensuring complete control through the UI. However, you can append flags or options to the command. - **Command**: Dokploy has a defined command to run the Docker Compose file, ensuring complete control through the UI. However, you can append flags or options to the command.
- **Volumes**: To ensure data persistence across deployments, configure storage volumes for your application. - **Volumes**: To ensure data persistence across deployments, configure storage volumes for your application.
<ImageZoom src="/assets/images/compose/overview.png" width={800} height={630} quality={100} priority alt='home og image' className="rounded-lg" /> <ImageZoom
src="/assets/images/compose/overview.png"
width={800}
height={630}
quality={100}
priority
alt="home og image"
className="rounded-lg"
/>
<Callout title="Volumes">
Docker volumes are a way to persist data generated and used by Docker containers. They are particularly useful for maintaining data between container restarts or for sharing data among different containers.
To bind a volume to the host machine, you can use the following syntax in your docker-compose.yml file, but this way will clean up the volumes when a new deployment is made:
```yaml
volumes:
- "/folder:/path/in/container" ❌
```
It's recommended to use the ../files folder to ensure your data persists between deployments. For example:
```yaml
volumes:
- "../files/my-database:/var/lib/mysql" ✅
- "../files/my-configs:/etc/my-app/config" ✅
```
</Callout>

View File

@ -32,7 +32,8 @@ The following templates are available:
- **Metabase**: Open Source Business Intelligence - **Metabase**: Open Source Business Intelligence
- **Grafana**: Open Source Dashboard for your metrics - **Grafana**: Open Source Dashboard for your metrics
- **Wordpress**: Open Source Content Management System - **Wordpress**: Open Source Content Management System
- **Open WebUI**: Free and Open Source ChatGPT Alternative
- **Teable**: Open Source Airtable Alternative, Developer Friendly, No-code Database Built on Postgres

View File

@ -10,10 +10,10 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"fumadocs-core": "12.2.2", "fumadocs-core": "^12.5.6",
"fumadocs-mdx": "8.2.33", "fumadocs-mdx": "^8.2.34",
"fumadocs-openapi": "^3.1.3", "fumadocs-openapi": "^3.3.0",
"fumadocs-ui": "12.2.2", "fumadocs-ui": "^12.5.6",
"lucide-react": "^0.394.0", "lucide-react": "^0.394.0",
"next": "^14.2.4", "next": "^14.2.4",
"react": "^18.3.1", "react": "^18.3.1",

View File

@ -0,0 +1,97 @@
import { fs, vol } from "memfs";
vi.mock("node:fs", () => ({
...fs,
default: fs,
}));
import type { Admin } from "@/server/api/services/admin";
import { createDefaultServerTraefikConfig } from "@/server/setup/traefik-setup";
import { loadOrCreateConfig } from "@/server/utils/traefik/application";
import type { FileConfig } from "@/server/utils/traefik/file-types";
import { updateServerTraefik } from "@/server/utils/traefik/web-server";
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: Admin = {
createdAt: "",
authId: "",
adminId: "string",
githubAppId: null,
githubAppName: null,
serverIp: null,
certificateType: "none",
host: null,
githubClientId: null,
githubClientSecret: null,
githubInstallationId: null,
githubPrivateKey: null,
githubWebhookSecret: null,
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
};
beforeEach(() => {
vol.reset();
createDefaultServerTraefikConfig();
});
test("Should read the configuration file", () => {
const config: FileConfig = loadOrCreateConfig("dokploy");
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
"dokploy-service-app",
);
});
test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseAdmin,
certificateType: "letsencrypt",
},
"example.com",
);
const config: FileConfig = loadOrCreateConfig("dokploy");
expect(config.http?.routers?.["dokploy-router-app"]?.middlewares).toContain(
"redirect-to-https",
);
});
test("Should change only host when no certificate", () => {
updateServerTraefik(baseAdmin, "example.com");
const config: FileConfig = loadOrCreateConfig("dokploy");
expect(config.http?.routers?.["dokploy-router-app-secure"]).toBeUndefined();
});
test("Should not touch config without host", () => {
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
updateServerTraefik(baseAdmin, null);
const config: FileConfig = loadOrCreateConfig("dokploy");
expect(originalConfig).toEqual(config);
});
test("Should remove websecure if https rollback to http", () => {
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
updateServerTraefik(
{ ...baseAdmin, certificateType: "letsencrypt" },
"example.com",
);
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
const config: FileConfig = loadOrCreateConfig("dokploy");
expect(config.http?.routers?.["dokploy-router-app-secure"]).toBeUndefined();
expect(
config.http?.routers?.["dokploy-router-app"]?.middlewares,
).not.toContain("redirect-to-https");
});

View File

@ -40,6 +40,7 @@ const baseApp: ApplicationNested = {
placementSwarm: null, placementSwarm: null,
ports: [], ports: [],
projectId: "", projectId: "",
publishDirectory: null,
redirects: [], redirects: [],
refreshToken: "", refreshToken: "",
registry: null, registry: null,
@ -54,6 +55,7 @@ const baseApp: ApplicationNested = {
title: null, title: null,
updateConfigSwarm: null, updateConfigSwarm: null,
username: null, username: null,
dockerContextPath: null,
}; };
const baseDomain: Domain = { const baseDomain: Domain = {
@ -88,6 +90,17 @@ test("Web entrypoint on http domain", async () => {
); );
expect(router.middlewares).not.toContain("redirect-to-https"); expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.rule).not.toContain("PathPrefix");
});
test("Web entrypoint on http domain with custom path", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, path: "/foo", https: false },
"web",
);
expect(router.rule).toContain("PathPrefix(`/foo`)");
}); });
test("Web entrypoint on http domain with redirect", async () => { test("Web entrypoint on http domain with redirect", async () => {

View File

@ -46,6 +46,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
<div className="flex flex-col pt-2 relative"> <div className="flex flex-col pt-2 relative">
<div className="flex flex-col gap-6 max-h-[35rem] min-h-[10rem] overflow-y-auto"> <div className="flex flex-col gap-6 max-h-[35rem] min-h-[10rem] overflow-y-auto">
<CodeEditor <CodeEditor
lineWrapping
value={data || "Empty"} value={data || "Empty"}
disabled disabled
className="font-mono" className="font-mono"

View File

@ -144,6 +144,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
<FormLabel>Traefik config</FormLabel> <FormLabel>Traefik config</FormLabel>
<FormControl> <FormControl>
<CodeEditor <CodeEditor
lineWrapping
wrapperClassName="h-[35rem] font-mono" wrapperClassName="h-[35rem] font-mono"
placeholder={`http: placeholder={`http:
routers: routers:

View File

@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@ -23,6 +24,7 @@ enum BuildType {
heroku_buildpacks = "heroku_buildpacks", heroku_buildpacks = "heroku_buildpacks",
paketo_buildpacks = "paketo_buildpacks", paketo_buildpacks = "paketo_buildpacks",
nixpacks = "nixpacks", nixpacks = "nixpacks",
static = "static",
} }
const mySchema = z.discriminatedUnion("buildType", [ const mySchema = z.discriminatedUnion("buildType", [
@ -34,6 +36,7 @@ const mySchema = z.discriminatedUnion("buildType", [
invalid_type_error: "Dockerfile path is required", invalid_type_error: "Dockerfile path is required",
}) })
.min(1, "Dockerfile required"), .min(1, "Dockerfile required"),
dockerContextPath: z.string().nullable().default(""),
}), }),
z.object({ z.object({
buildType: z.literal("heroku_buildpacks"), buildType: z.literal("heroku_buildpacks"),
@ -43,6 +46,10 @@ const mySchema = z.discriminatedUnion("buildType", [
}), }),
z.object({ z.object({
buildType: z.literal("nixpacks"), buildType: z.literal("nixpacks"),
publishDirectory: z.string().optional(),
}),
z.object({
buildType: z.literal("static"),
}), }),
]); ]);
@ -73,17 +80,18 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const buildType = form.watch("buildType"); const buildType = form.watch("buildType");
useEffect(() => { useEffect(() => {
if (data) { if (data) {
// TODO: refactor this
if (data.buildType === "dockerfile") { if (data.buildType === "dockerfile") {
form.reset({ form.reset({
buildType: data.buildType, buildType: data.buildType,
...(data.buildType && { ...(data.buildType && {
dockerfile: data.dockerfile || "", dockerfile: data.dockerfile || "",
dockerContextPath: data.dockerContextPath || "",
}), }),
}); });
} else { } else {
form.reset({ form.reset({
buildType: data.buildType, buildType: data.buildType,
publishDirectory: data.publishDirectory || undefined,
}); });
} }
} }
@ -93,7 +101,11 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
await mutateAsync({ await mutateAsync({
applicationId, applicationId,
buildType: data.buildType, buildType: data.buildType,
publishDirectory:
data.buildType === "nixpacks" ? data.publishDirectory : null,
dockerfile: data.buildType === "dockerfile" ? data.dockerfile : null, dockerfile: data.buildType === "dockerfile" ? data.dockerfile : null,
dockerContextPath:
data.buildType === "dockerfile" ? data.dockerContextPath : null,
}) })
.then(async () => { .then(async () => {
toast.success("Build type saved"); toast.success("Build type saved");
@ -171,6 +183,12 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
Paketo Buildpacks Paketo Buildpacks
</FormLabel> </FormLabel>
</FormItem> </FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="static" />
</FormControl>
<FormLabel className="font-normal">Static</FormLabel>
</FormItem>
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -179,6 +197,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
}} }}
/> />
{buildType === "dockerfile" && ( {buildType === "dockerfile" && (
<>
<FormField <FormField
control={form.control} control={form.control}
name="dockerfile" name="dockerfile"
@ -199,6 +218,60 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
); );
}} }}
/> />
<FormField
control={form.control}
name="dockerContextPath"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Docker Context Path</FormLabel>
<FormControl>
<Input
placeholder={
"Path of your docker context default: ."
}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</>
)}
{buildType === "nixpacks" && (
<FormField
control={form.control}
name="publishDirectory"
render={({ field }) => {
return (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Publish Directory</FormLabel>
<FormDescription>
Allows you to serve a single directory via NGINX after
the build phase. Useful if the final build assets
should be served as a static site.
</FormDescription>
</div>
<FormControl>
<Input
placeholder={"Publish Directory"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)} )}
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isLoading} type="submit">

View File

@ -53,7 +53,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
<div className="flex flex-row items-center gap-2 flex-wrap"> <div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span> <span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<span className="text-muted-foreground"> <span className="break-all text-muted-foreground">
{`${url}/api/deploy/${data?.refreshToken}`} {`${url}/api/deploy/${data?.refreshToken}`}
</span> </span>
<RefreshToken applicationId={applicationId} /> <RefreshToken applicationId={applicationId} />
@ -72,7 +72,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
{deployments?.map((deployment) => ( {deployments?.map((deployment) => (
<div <div
key={deployment.deploymentId} key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4" className="flex items-center justify-between rounded-lg border p-4 gap-2"
> >
<div className="flex flex-col"> <div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground"> <span className="flex items-center gap-4 font-medium capitalize text-foreground">
@ -87,7 +87,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
{deployment.title} {deployment.title}
</span> </span>
{deployment.description && ( {deployment.description && (
<span className="text-sm text-muted-foreground"> <span className="break-all text-sm text-muted-foreground">
{deployment.description} {deployment.description}
</span> </span>
)} )}

View File

@ -114,7 +114,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Password" {...field} /> <Input placeholder="Password" {...field} type="password" />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@ -104,6 +104,7 @@ export const ShowTraefikFile = ({ path }: Props) => {
</FormDescription> </FormDescription>
<FormControl> <FormControl>
<CodeEditor <CodeEditor
lineWrapping
wrapperClassName="h-[35rem] font-mono" wrapperClassName="h-[35rem] font-mono"
placeholder={`http: placeholder={`http:
routers: routers:

View File

@ -55,6 +55,7 @@ interface Props {
export const AddTemplate = ({ projectId }: Props) => { export const AddTemplate = ({ projectId }: Props) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const { data } = api.compose.templates.useQuery(); const { data } = api.compose.templates.useQuery();
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const { data: tags, isLoading: isLoadingTags } = const { data: tags, isLoading: isLoadingTags } =
@ -75,14 +76,14 @@ export const AddTemplate = ({ projectId }: Props) => {
}) || []; }) || [];
return ( return (
<Dialog> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="w-full"> <DialogTrigger className="w-full">
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer space-x-3" className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
> >
<PuzzleIcon className="size-4 text-muted-foreground" /> <PuzzleIcon className="size-4 text-muted-foreground" />
<span>Templates</span> <span>Template</span>
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl p-0"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl p-0">
@ -283,6 +284,7 @@ export const AddTemplate = ({ projectId }: Props) => {
utils.project.one.invalidate({ utils.project.one.invalidate({
projectId, projectId,
}); });
setOpen(false);
}) })
.catch(() => { .catch(() => {
toast.error( toast.error(

View File

@ -9,36 +9,11 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { format } from "date-fns";
import { BadgeCheck } from "lucide-react"; import { BadgeCheck } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { RemoveGithubApp } from "./remove-github-app"; import { RemoveGithubApp } from "./remove-github-app";
export const generateName = () => {
const n1 = ["Blue", "Green", "Red", "Orange", "Violet", "Indigo", "Yellow"];
const n2 = [
"One",
"Two",
"Three",
"Four",
"Five",
"Six",
"Seven",
"Eight",
"Nine",
"Zero",
];
return `Dokploy-${n1[Math.round(Math.random() * (n1.length - 1))]}-${
n2[Math.round(Math.random() * (n2.length - 1))]
}`;
};
function slugify(text: string) {
return text
.toLowerCase()
.replace(/[\s\^&*()+=!]+/g, "-")
.replace(/[\$.,*+~()'"!:@^&]+/g, "")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
}
export const GithubSetup = () => { export const GithubSetup = () => {
const [isOrganization, setIsOrganization] = useState(false); const [isOrganization, setIsOrganization] = useState(false);
@ -52,10 +27,9 @@ export const GithubSetup = () => {
const manifest = JSON.stringify( const manifest = JSON.stringify(
{ {
redirect_url: `${origin}/api/redirect?authId=${data?.authId}`, redirect_url: `${origin}/api/redirect?authId=${data?.authId}`,
name: generateName(), name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}`,
url: origin, url: origin,
hook_attributes: { hook_attributes: {
// JUST FOR TESTING
url: `${url}/api/deploy/github`, url: `${url}/api/deploy/github`,
// url: `${origin}/api/webhook`, // Aquí especificas la URL del endpoint de tu webhook // url: `${origin}/api/webhook`, // Aquí especificas la URL del endpoint de tu webhook
}, },
@ -95,8 +69,8 @@ export const GithubSetup = () => {
</div> </div>
<div className="flex items-end gap-4 flex-wrap"> <div className="flex items-end gap-4 flex-wrap">
<RemoveGithubApp /> <RemoveGithubApp />
{/* <Link <Link
href={`https://github.com/settings/apps/${data?.githubAppName}`} href={`${data?.githubAppName}`}
target="_blank" target="_blank"
className={buttonVariants({ className={buttonVariants({
className: "w-fit", className: "w-fit",
@ -104,7 +78,7 @@ export const GithubSetup = () => {
})} })}
> >
<span className="text-sm">Manage Github App</span> <span className="text-sm">Manage Github App</span>
</Link> */} </Link>
</div> </div>
</div> </div>
) : ( ) : (
@ -119,9 +93,9 @@ export const GithubSetup = () => {
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
<Link <Link
href={`https://github.com/apps/${slugify( href={`${
data.githubAppName, data.githubAppName
)}/installations/new?state=gh_setup:${data?.authId}`} }/installations/new?state=gh_setup:${data?.authId}`}
className={buttonVariants({ className: "w-fit" })} className={buttonVariants({ className: "w-fit" })}
> >
Install Github App Install Github App

View File

@ -22,7 +22,7 @@ export const ShowDestinations = () => {
<CardHeader> <CardHeader>
<CardTitle className="text-xl">SSH Keys</CardTitle> <CardTitle className="text-xl">SSH Keys</CardTitle>
<CardDescription> <CardDescription>
Use SSH to beeing able cloning from private repositories. Use SSH to be able to clone from private repositories.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 pt-4"> <CardContent className="space-y-2 pt-4">

View File

@ -39,6 +39,7 @@ const addPermissions = z.object({
canAccessToTraefikFiles: z.boolean().optional().default(false), canAccessToTraefikFiles: z.boolean().optional().default(false),
canAccessToDocker: z.boolean().optional().default(false), canAccessToDocker: z.boolean().optional().default(false),
canAccessToAPI: z.boolean().optional().default(false), canAccessToAPI: z.boolean().optional().default(false),
canAccessToSSHKeys: z.boolean().optional().default(false),
}); });
type AddPermissions = z.infer<typeof addPermissions>; type AddPermissions = z.infer<typeof addPermissions>;
@ -82,6 +83,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
canAccessToTraefikFiles: data.canAccessToTraefikFiles, canAccessToTraefikFiles: data.canAccessToTraefikFiles,
canAccessToDocker: data.canAccessToDocker, canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI, canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
}); });
} }
}, [form, form.formState.isSubmitSuccessful, form.reset, data]); }, [form, form.formState.isSubmitSuccessful, form.reset, data]);
@ -98,6 +100,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
accesedServices: data.accesedServices || [], accesedServices: data.accesedServices || [],
canAccessToDocker: data.canAccessToDocker, canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI, canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
}) })
.then(async () => { .then(async () => {
toast.success("Permissions updated"); toast.success("Permissions updated");
@ -270,6 +273,26 @@ export const AddUserPermissions = ({ userId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="canAccessToSSHKeys"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to SSH Keys</FormLabel>
<FormDescription>
Allow to users to access to the SSH Keys section
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="accesedProjects" name="accesedProjects"

View File

@ -106,6 +106,7 @@ export const ShowMainTraefikConfig = ({ children }: Props) => {
<FormLabel>Traefik config</FormLabel> <FormLabel>Traefik config</FormLabel>
<FormControl> <FormControl>
<CodeEditor <CodeEditor
lineWrapping
wrapperClassName="h-[35rem] font-mono" wrapperClassName="h-[35rem] font-mono"
placeholder={`providers: placeholder={`providers:
docker: docker:

View File

@ -109,6 +109,7 @@ export const ShowServerTraefikConfig = ({ children }: Props) => {
<FormLabel>Traefik config</FormLabel> <FormLabel>Traefik config</FormLabel>
<FormControl> <FormControl>
<CodeEditor <CodeEditor
lineWrapping
wrapperClassName="h-[35rem] font-mono" wrapperClassName="h-[35rem] font-mono"
placeholder={`http: placeholder={`http:
routers: routers:

View File

@ -79,6 +79,16 @@ export const SettingsLayout = ({ children }: Props) => {
}, },
] ]
: []), : []),
...(user?.canAccessToSSHKeys
? [
{
title: "SSH Keys",
label: "",
icon: KeyRound,
href: "/dashboard/settings/ssh-keys",
},
]
: []),
]} ]}
/> />
</div> </div>

View File

@ -3,6 +3,7 @@ import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml"; import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language"; import { StreamLanguage } from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties"; import { properties } from "@codemirror/legacy-modes/mode/properties";
import { EditorView } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github"; import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror"; import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
@ -10,6 +11,7 @@ interface Props extends ReactCodeMirrorProps {
wrapperClassName?: string; wrapperClassName?: string;
disabled?: boolean; disabled?: boolean;
language?: "yaml" | "json" | "properties"; language?: "yaml" | "json" | "properties";
lineWrapping?: boolean;
} }
export const CodeEditor = ({ export const CodeEditor = ({
@ -36,6 +38,7 @@ export const CodeEditor = ({
: language === "json" : language === "json"
? json() ? json()
: StreamLanguage.define(properties), : StreamLanguage.define(properties),
props.lineWrapping ? EditorView.lineWrapping : [],
]} ]}
{...props} {...props}
editable={!props.disabled} editable={!props.disabled}

View File

@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "publishDirectory" text;

View File

@ -0,0 +1 @@
ALTER TYPE "buildType" ADD VALUE 'static';

View File

@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "dockerContextPath" text;

View File

@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "canAccessToSSHKeys" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -190,6 +190,34 @@
"when": 1721979220929, "when": 1721979220929,
"tag": "0026_known_dormammu", "tag": "0026_known_dormammu",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1722445099203,
"tag": "0027_red_lady_bullseye",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1722503439951,
"tag": "0028_jittery_eternity",
"breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1722578386823,
"tag": "0029_colossal_zodiak",
"breakpoints": true
},
{
"idx": 30,
"version": "6",
"when": 1723608499147,
"tag": "0030_little_kabuki",
"breakpoints": true
} }
] ]
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.5.0", "version": "v0.6.2",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@ -39,6 +39,7 @@
"@codemirror/lang-yaml": "^6.1.1", "@codemirror/lang-yaml": "^6.1.1",
"@codemirror/language": "^6.10.1", "@codemirror/language": "^6.10.1",
"@codemirror/legacy-modes": "6.4.0", "@codemirror/legacy-modes": "6.4.0",
"@codemirror/view": "6.29.0",
"@dokploy/trpc-openapi": "0.0.4", "@dokploy/trpc-openapi": "0.0.4",
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.3.4",
@ -129,7 +130,6 @@
"zod-form-data": "^2.0.2" "zod-form-data": "^2.0.2"
}, },
"devDependencies": { "devDependencies": {
"tsconfig-paths": "4.2.0",
"@biomejs/biome": "1.8.3", "@biomejs/biome": "1.8.3",
"@commitlint/cli": "^19.3.0", "@commitlint/cli": "^19.3.0",
"@commitlint/config-conventional": "^19.2.2", "@commitlint/config-conventional": "^19.2.2",
@ -154,10 +154,12 @@
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.7", "lint-staged": "^15.2.7",
"localtunnel": "2.0.2", "localtunnel": "2.0.2",
"memfs": "^4.11.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"prettier": "^3.2.4", "prettier": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.5.11", "prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tsconfig-paths": "4.2.0",
"tsx": "^4.7.0", "tsx": "^4.7.0",
"typescript": "^5.4.2", "typescript": "^5.4.2",
"vite-tsconfig-paths": "4.3.2", "vite-tsconfig-paths": "4.3.2",

View File

@ -35,7 +35,7 @@ export default async function handler(
.update(admins) .update(admins)
.set({ .set({
githubAppId: data.id, githubAppId: data.id,
githubAppName: data.name, githubAppName: data.html_url,
githubClientId: data.client_id, githubClientId: data.client_id,
githubClientSecret: data.client_secret, githubClientSecret: data.client_secret,
githubWebhookSecret: data.webhook_secret, githubWebhookSecret: data.webhook_secret,

View File

@ -1,9 +1,12 @@
import { ShowDestinations } from "@/components/dashboard/settings/ssh-keys/show-ssh-keys"; import { ShowDestinations } from "@/components/dashboard/settings/ssh-keys/show-ssh-keys";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { SettingsLayout } from "@/components/layouts/settings-layout"; import { SettingsLayout } from "@/components/layouts/settings-layout";
import { appRouter } from "@/server/api/root";
import { validateRequest } from "@/server/auth/auth"; import { validateRequest } from "@/server/auth/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import React, { type ReactElement } from "react"; import React, { type ReactElement } from "react";
import superjson from "superjson";
const Page = () => { const Page = () => {
return ( return (
@ -26,7 +29,7 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>, ctx: GetServerSidePropsContext<{ serviceId: string }>,
) { ) {
const { user, session } = await validateRequest(ctx.req, ctx.res); const { user, session } = await validateRequest(ctx.req, ctx.res);
if (!user || user.rol === "user") { if (!user) {
return { return {
redirect: { redirect: {
permanent: true, permanent: true,
@ -34,8 +37,45 @@ export async function getServerSideProps(
}, },
}; };
} }
const { req, res, resolvedUrl } = ctx;
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
},
transformer: superjson,
});
try {
await helpers.project.all.prefetch();
const auth = await helpers.auth.get.fetch();
if (auth.rol === "user") {
const user = await helpers.user.byAuthId.fetch({
authId: auth.id,
});
if (!user.canAccessToSSHKeys) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
}
return {
props: {
trpcState: helpers.dehydrate(),
},
};
} catch (error) {
return { return {
props: {}, props: {},
}; };
} }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 B

View File

@ -191,6 +191,8 @@ export const applicationRouter = createTRPCRouter({
await updateApplication(input.applicationId, { await updateApplication(input.applicationId, {
buildType: input.buildType, buildType: input.buildType,
dockerfile: input.dockerfile, dockerfile: input.dockerfile,
publishDirectory: input.publishDirectory,
dockerContextPath: input.dockerContextPath,
}); });
return true; return true;

View File

@ -257,7 +257,8 @@ export const composeRouter = createTRPCRouter({
if (mounts && mounts?.length > 0) { if (mounts && mounts?.length > 0) {
for (const mount of mounts) { for (const mount of mounts) {
await createMount({ await createMount({
mountPath: mount.mountPath, filePath: mount.filePath,
mountPath: "",
content: mount.content, content: mount.content,
serviceId: compose.composeId, serviceId: compose.composeId,
serviceType: "compose", serviceType: "compose",

View File

@ -20,6 +20,7 @@ import {
} from "@/server/utils/docker/utils"; } from "@/server/utils/docker/utils";
import { recreateDirectory } from "@/server/utils/filesystem/directory"; import { recreateDirectory } from "@/server/utils/filesystem/directory";
import { sendDockerCleanupNotifications } from "@/server/utils/notifications/docker-cleanup"; import { sendDockerCleanupNotifications } from "@/server/utils/notifications/docker-cleanup";
import { execAsync } from "@/server/utils/process/execAsync";
import { spawnAsync } from "@/server/utils/process/spawnAsync"; import { spawnAsync } from "@/server/utils/process/spawnAsync";
import { import {
readConfig, readConfig,
@ -49,14 +50,10 @@ import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
export const settingsRouter = createTRPCRouter({ export const settingsRouter = createTRPCRouter({
reloadServer: adminProcedure.mutation(async () => { reloadServer: adminProcedure.mutation(async () => {
await spawnAsync("docker", [ const { stdout } = await execAsync(
"service", "docker service inspect dokploy --format '{{.ID}}'",
"update", );
"--force", await execAsync(`docker service update --force ${stdout.trim()}`);
"--image",
getDokployImage(),
"dokploy",
]);
return true; return true;
}), }),
reloadTraefik: adminProcedure.mutation(async () => { reloadTraefik: adminProcedure.mutation(async () => {

View File

@ -34,7 +34,9 @@ export const sshRouter = createTRPCRouter({
}); });
} }
}), }),
remove: adminProcedure.input(apiRemoveSshKey).mutation(async ({ input }) => { remove: protectedProcedure
.input(apiRemoveSshKey)
.mutation(async ({ input }) => {
try { try {
return await removeSSHKeyById(input.sshKeyId); return await removeSSHKeyById(input.sshKeyId);
} catch (error) { } catch (error) {
@ -48,7 +50,7 @@ export const sshRouter = createTRPCRouter({
const sshKey = await findSSHKeyById(input.sshKeyId); const sshKey = await findSSHKeyById(input.sshKeyId);
return sshKey; return sshKey;
}), }),
all: adminProcedure.query(async () => { all: protectedProcedure.query(async () => {
return await db.query.sshKeys.findMany({}); return await db.query.sshKeys.findMany({});
}), }),
generate: protectedProcedure generate: protectedProcedure
@ -56,7 +58,9 @@ export const sshRouter = createTRPCRouter({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return await generateSSHKey(input.type); return await generateSSHKey(input.type);
}), }),
update: adminProcedure.input(apiUpdateSshKey).mutation(async ({ input }) => { update: protectedProcedure
.input(apiUpdateSshKey)
.mutation(async ({ input }) => {
try { try {
return await updateSSHKeyById(input); return await updateSSHKeyById(input);
} catch (error) { } catch (error) {

View File

@ -35,6 +35,7 @@ export const buildType = pgEnum("buildType", [
"heroku_buildpacks", "heroku_buildpacks",
"paketo_buildpacks", "paketo_buildpacks",
"nixpacks", "nixpacks",
"static",
]); ]);
// TODO: refactor this types // TODO: refactor this types
@ -140,6 +141,7 @@ export const applications = pgTable("application", {
}, },
), ),
dockerfile: text("dockerfile"), dockerfile: text("dockerfile"),
dockerContextPath: text("dockerContextPath"),
// Drop // Drop
dropBuildPath: text("dropBuildPath"), dropBuildPath: text("dropBuildPath"),
// Docker swarm json // Docker swarm json
@ -157,6 +159,7 @@ export const applications = pgTable("application", {
.notNull() .notNull()
.default("idle"), .default("idle"),
buildType: buildType("buildType").notNull().default("nixpacks"), buildType: buildType("buildType").notNull().default("nixpacks"),
publishDirectory: text("publishDirectory"),
createdAt: text("createdAt") createdAt: text("createdAt")
.notNull() .notNull()
.$defaultFn(() => new Date().toISOString()), .$defaultFn(() => new Date().toISOString()),
@ -315,7 +318,9 @@ const createSchema = createInsertSchema(applications, {
"heroku_buildpacks", "heroku_buildpacks",
"paketo_buildpacks", "paketo_buildpacks",
"nixpacks", "nixpacks",
"static",
]), ]),
publishDirectory: z.string().optional(),
owner: z.string(), owner: z.string(),
healthCheckSwarm: HealthCheckSwarmSchema.nullable(), healthCheckSwarm: HealthCheckSwarmSchema.nullable(),
restartPolicySwarm: RestartPolicySwarmSchema.nullable(), restartPolicySwarm: RestartPolicySwarmSchema.nullable(),
@ -352,8 +357,10 @@ export const apiSaveBuildType = createSchema
applicationId: true, applicationId: true,
buildType: true, buildType: true,
dockerfile: true, dockerfile: true,
dockerContextPath: true,
}) })
.required(); .required()
.merge(createSchema.pick({ publishDirectory: true }));
export const apiSaveGithubProvider = createSchema export const apiSaveGithubProvider = createSchema
.pick({ .pick({

View File

@ -28,6 +28,7 @@ export const users = pgTable("user", {
.notNull() .notNull()
.$defaultFn(() => new Date().toISOString()), .$defaultFn(() => new Date().toISOString()),
canCreateProjects: boolean("canCreateProjects").notNull().default(false), canCreateProjects: boolean("canCreateProjects").notNull().default(false),
canAccessToSSHKeys: boolean("canAccessToSSHKeys").notNull().default(false),
canCreateServices: boolean("canCreateServices").notNull().default(false), canCreateServices: boolean("canCreateServices").notNull().default(false),
canDeleteProjects: boolean("canDeleteProjects").notNull().default(false), canDeleteProjects: boolean("canDeleteProjects").notNull().default(false),
canDeleteServices: boolean("canDeleteServices").notNull().default(false), canDeleteServices: boolean("canDeleteServices").notNull().default(false),
@ -107,6 +108,7 @@ export const apiAssignPermissions = createSchema
canAccessToTraefikFiles: true, canAccessToTraefikFiles: true,
canAccessToDocker: true, canAccessToDocker: true,
canAccessToAPI: true, canAccessToAPI: true,
canAccessToSSHKeys: true,
}) })
.required(); .required();

View File

@ -36,10 +36,9 @@ export const initializePostgres = async () => {
Ports: [ Ports: [
{ {
TargetPort: 5432, TargetPort: 5432,
...(process.env.NODE_ENV === "development" PublishedPort: process.env.NODE_ENV === "development" ? 5432 : 0,
? { PublishedPort: 5432 }
: {}),
Protocol: "tcp", Protocol: "tcp",
PublishMode: "host",
}, },
], ],
}, },

View File

@ -33,10 +33,9 @@ export const initializeRedis = async () => {
Ports: [ Ports: [
{ {
TargetPort: 6379, TargetPort: 6379,
...(process.env.NODE_ENV === "development" PublishedPort: process.env.NODE_ENV === "development" ? 6379 : 0,
? { PublishedPort: 6379 }
: {}),
Protocol: "tcp", Protocol: "tcp",
PublishMode: "host",
}, },
], ],
}, },

View File

@ -104,8 +104,7 @@ export const createDefaultServerTraefikConfig = () => {
[`${appName}-router-app`]: { [`${appName}-router-app`]: {
rule: `Host(\`${appName}.docker.localhost\`) && PathPrefix(\`/\`)`, rule: `Host(\`${appName}.docker.localhost\`) && PathPrefix(\`/\`)`,
service: `${appName}-service-app`, service: `${appName}-service-app`,
entryPoints: ["web", "websecure"], entryPoints: ["web"],
tls: {},
}, },
}, },
services: { services: {

View File

@ -1,7 +1,10 @@
import type { WriteStream } from "node:fs"; import type { WriteStream } from "node:fs";
import { prepareEnvironmentVariables } from "@/server/utils/docker/utils"; import { prepareEnvironmentVariables } from "@/server/utils/docker/utils";
import type { ApplicationNested } from "."; import type { ApplicationNested } from ".";
import { getBuildAppDirectory } from "../filesystem/directory"; import {
getBuildAppDirectory,
getDockerContextPath,
} from "../filesystem/directory";
import { spawnAsync } from "../process/spawnAsync"; import { spawnAsync } from "../process/spawnAsync";
import { createEnvFile } from "./utils"; import { createEnvFile } from "./utils";
@ -9,22 +12,30 @@ export const buildCustomDocker = async (
application: ApplicationNested, application: ApplicationNested,
writeStream: WriteStream, writeStream: WriteStream,
) => { ) => {
const { appName, env, buildArgs } = application; const { appName, env, publishDirectory, buildArgs } = application;
const dockerFilePath = getBuildAppDirectory(application); const dockerFilePath = getBuildAppDirectory(application);
try { try {
const image = `${appName}`; const image = `${appName}`;
const contextPath = const defaultContextPath =
dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || "."; dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || ".";
const args = prepareEnvironmentVariables(buildArgs); const args = prepareEnvironmentVariables(buildArgs);
const dockerContextPath = getDockerContextPath(application);
const commandArgs = ["build", "-t", image, "-f", dockerFilePath, "."]; const commandArgs = ["build", "-t", image, "-f", dockerFilePath, "."];
for (const arg of args) { for (const arg of args) {
commandArgs.push("--build-arg", arg); commandArgs.push("--build-arg", arg);
} }
/*
Do not generate an environment file when publishDirectory is specified,
as it could be publicly exposed.
*/
if (!publishDirectory) {
createEnvFile(dockerFilePath, env); createEnvFile(dockerFilePath, env);
}
await spawnAsync( await spawnAsync(
"docker", "docker",
commandArgs, commandArgs,
@ -34,7 +45,7 @@ export const buildCustomDocker = async (
} }
}, },
{ {
cwd: contextPath, cwd: dockerContextPath || defaultContextPath,
}, },
); );
} catch (error) { } catch (error) {

View File

@ -15,6 +15,7 @@ import { buildCustomDocker } from "./docker-file";
import { buildHeroku } from "./heroku"; import { buildHeroku } from "./heroku";
import { buildNixpacks } from "./nixpacks"; import { buildNixpacks } from "./nixpacks";
import { buildPaketo } from "./paketo"; import { buildPaketo } from "./paketo";
import { buildStatic } from "./static";
// NIXPACKS codeDirectory = where is the path of the code directory // NIXPACKS codeDirectory = where is the path of the code directory
// HEROKU codeDirectory = where is the path of the code directory // HEROKU codeDirectory = where is the path of the code directory
@ -43,6 +44,8 @@ export const buildApplication = async (
await buildPaketo(application, writeStream); await buildPaketo(application, writeStream);
} else if (buildType === "dockerfile") { } else if (buildType === "dockerfile") {
await buildCustomDocker(application, writeStream); await buildCustomDocker(application, writeStream);
} else if (buildType === "static") {
await buildStatic(application, writeStream);
} }
if (application.registryId) { if (application.registryId) {
@ -51,7 +54,11 @@ export const buildApplication = async (
await mechanizeDockerContainer(application); await mechanizeDockerContainer(application);
writeStream.write("Docker Deployed: ✅"); writeStream.write("Docker Deployed: ✅");
} catch (error) { } catch (error) {
if (error instanceof Error) {
writeStream.write(`Error ❌\n${error?.message}`);
} else {
writeStream.write("Error ❌"); writeStream.write("Error ❌");
}
throw error; throw error;
} finally { } finally {
writeStream.end(); writeStream.end();

View File

@ -1,18 +1,28 @@
import type { WriteStream } from "node:fs"; import type { WriteStream } from "node:fs";
import path from "node:path";
import { buildStatic } from "@/server/utils/builders/static";
import { nanoid } from "nanoid";
import type { ApplicationNested } from "."; import type { ApplicationNested } from ".";
import { prepareEnvironmentVariables } from "../docker/utils"; import { prepareEnvironmentVariables } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory"; import { getBuildAppDirectory } from "../filesystem/directory";
import { spawnAsync } from "../process/spawnAsync"; import { spawnAsync } from "../process/spawnAsync";
// TODO: integrate in the vps sudo chown -R $(whoami) ~/.docker
export const buildNixpacks = async ( export const buildNixpacks = async (
application: ApplicationNested, application: ApplicationNested,
writeStream: WriteStream, writeStream: WriteStream,
) => { ) => {
const { env, appName } = application; const { env, appName, publishDirectory } = application;
const buildAppDirectory = getBuildAppDirectory(application);
const buildAppDirectory = getBuildAppDirectory(application);
const buildContainerId = `${appName}-${nanoid(10)}`;
const envVariables = prepareEnvironmentVariables(env); const envVariables = prepareEnvironmentVariables(env);
const writeToStream = (data: string) => {
if (writeStream.writable) {
writeStream.write(data);
}
};
try { try {
const args = ["build", buildAppDirectory, "--name", appName]; const args = ["build", buildAppDirectory, "--name", appName];
@ -20,13 +30,44 @@ export const buildNixpacks = async (
args.push("--env", env); args.push("--env", env);
} }
await spawnAsync("nixpacks", args, (data) => { if (publishDirectory) {
if (writeStream.writable) { /* No need for any start command, since we'll use nginx later on */
writeStream.write(data); args.push("--no-error-without-start");
}
await spawnAsync("nixpacks", args, writeToStream);
/*
Run the container with the image created by nixpacks,
and copy the artifacts on the host filesystem.
Then, remove the container and create a static build.
*/
if (publishDirectory) {
await spawnAsync(
"docker",
["create", "--name", buildContainerId, appName],
writeToStream,
);
await spawnAsync(
"docker",
[
"cp",
`${buildContainerId}:/app/${publishDirectory}`,
path.join(buildAppDirectory, publishDirectory),
],
writeToStream,
);
await spawnAsync("docker", ["rm", buildContainerId], writeToStream);
await buildStatic(application, writeStream);
} }
});
return true; return true;
} catch (e) { } catch (e) {
await spawnAsync("docker", ["rm", buildContainerId], writeToStream);
throw e; throw e;
} }
}; };

View File

@ -0,0 +1,38 @@
import type { WriteStream } from "node:fs";
import { buildCustomDocker } from "@/server/utils/builders/docker-file";
import type { ApplicationNested } from ".";
import { createFile } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
export const buildStatic = async (
application: ApplicationNested,
writeStream: WriteStream,
) => {
const { publishDirectory } = application;
const buildAppDirectory = getBuildAppDirectory(application);
try {
createFile(
buildAppDirectory,
"Dockerfile",
[
"FROM nginx:alpine",
"WORKDIR /usr/share/nginx/html/",
`COPY ${publishDirectory || "."} .`,
].join("\n"),
);
await buildCustomDocker(
{
...application,
buildType: "dockerfile",
dockerfile: "Dockerfile",
},
writeStream,
);
return true;
} catch (e) {
throw e;
}
};

View File

@ -242,12 +242,7 @@ export const generateConfigContainer = (application: ApplicationNested) => {
? { ? {
RestartPolicy: restartPolicySwarm, RestartPolicy: restartPolicySwarm,
} }
: { : {}),
// if no restartPolicySwarm provided use default
RestartPolicy: {
Condition: "on-failure",
},
}),
...(placementSwarm ...(placementSwarm
? { ? {
Placement: placementSwarm, Placement: placementSwarm,

View File

@ -89,3 +89,12 @@ export const getBuildAppDirectory = (application: Application) => {
return path.join(APPLICATIONS_PATH, appName, "code", buildPath ?? ""); return path.join(APPLICATIONS_PATH, appName, "code", buildPath ?? "");
}; };
export const getDockerContextPath = (application: Application) => {
const { appName, dockerContextPath } = application;
if (!dockerContextPath) {
return null;
}
return path.join(APPLICATIONS_PATH, appName, "code", dockerContextPath);
};

View File

@ -33,7 +33,9 @@ export const cloneGitRepository = async (
const knownHostsPath = path.join(SSH_PATH, "known_hosts"); const knownHostsPath = path.join(SSH_PATH, "known_hosts");
try { try {
if (!isHttpOrHttps(customGitUrl)) {
await addHostToKnownHosts(customGitUrl); await addHostToKnownHosts(customGitUrl);
}
await recreateDirectory(outputPath); await recreateDirectory(outputPath);
// const command = `GIT_SSH_COMMAND="ssh -i ${keyPath} -o UserKnownHostsFile=${knownHostsPath}" git clone --branch ${customGitBranch} --depth 1 ${customGitUrl} ${gitCopyPath} --progress`; // const command = `GIT_SSH_COMMAND="ssh -i ${keyPath} -o UserKnownHostsFile=${knownHostsPath}" git clone --branch ${customGitBranch} --depth 1 ${customGitUrl} ${gitCopyPath} --progress`;
// const { stdout, stderr } = await execAsync(command); // const { stdout, stderr } = await execAsync(command);
@ -56,6 +58,7 @@ export const cloneGitRepository = async (
customGitBranch, customGitBranch,
"--depth", "--depth",
"1", "1",
"--recurse-submodules",
customGitUrl, customGitUrl,
outputPath, outputPath,
"--progress", "--progress",
@ -84,6 +87,11 @@ export const cloneGitRepository = async (
} }
}; };
const isHttpOrHttps = (url: string): boolean => {
const regex = /^https?:\/\//;
return regex.test(url);
};
const addHostToKnownHosts = async (repositoryURL: string) => { const addHostToKnownHosts = async (repositoryURL: string) => {
const { domain, port } = sanitizeRepoPathSSH(repositoryURL); const { domain, port } = sanitizeRepoPathSSH(repositoryURL);
const knownHostsPath = path.join(SSH_PATH, "known_hosts"); const knownHostsPath = path.join(SSH_PATH, "known_hosts");
@ -121,7 +129,7 @@ const sanitizeRepoPathSSH = (input: string) => {
return { return {
user: found.groups?.user ?? "git", user: found.groups?.user ?? "git",
domain: found.groups?.domain, domain: found.groups?.domain,
port: 22, port: Number(found.groups?.port ?? 22),
owner: found.groups?.owner ?? "", owner: found.groups?.owner ?? "",
repo: found.groups?.repo, repo: found.groups?.repo,
get repoPath() { get repoPath() {

View File

@ -77,7 +77,7 @@ export const createRouterConfig = async (
const { host, path, https, uniqueConfigKey } = domain; const { host, path, https, uniqueConfigKey } = domain;
const routerConfig: HttpRouter = { const routerConfig: HttpRouter = {
rule: `Host(\`${host}\`)${path ? ` && PathPrefix(\`${path}\`)` : ""}`, rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`,
service: `${appName}-service-${uniqueConfigKey}`, service: `${appName}-service-${uniqueConfigKey}`,
middlewares: [], middlewares: [],
entryPoints: [entryPoint], entryPoints: [entryPoint],

View File

@ -19,14 +19,20 @@ export const updateServerTraefik = (
const currentRouterConfig = config.http.routers[`${appName}-router-app`]; const currentRouterConfig = config.http.routers[`${appName}-router-app`];
if (currentRouterConfig) { if (currentRouterConfig && newHost) {
if (newHost) {
currentRouterConfig.rule = `Host(\`${newHost}\`)`; currentRouterConfig.rule = `Host(\`${newHost}\`)`;
}
if (admin?.certificateType === "letsencrypt") { if (admin?.certificateType === "letsencrypt") {
currentRouterConfig.tls = { certResolver: "letsencrypt" }; config.http.routers[`${appName}-router-app-secure`] = {
} else if (admin?.certificateType === "none") { ...currentRouterConfig,
currentRouterConfig.tls = undefined; entryPoints: ["websecure"],
tls: { certResolver: "letsencrypt" },
};
currentRouterConfig.middlewares = ["redirect-to-https"];
} else {
delete config.http.routers[`${appName}-router-app-secure`];
currentRouterConfig.middlewares = [];
} }
} }

View File

@ -21,7 +21,7 @@ export function generate(schema: Schema): Template {
const mounts: Template["mounts"] = [ const mounts: Template["mounts"] = [
{ {
mountPath: "./config.toml", filePath: "config.toml",
content: `[app] content: `[app]
address = "0.0.0.0:9000" address = "0.0.0.0:9000"

View File

@ -23,7 +23,7 @@ export function generate(schema: Schema): Template {
const mounts: Template["mounts"] = [ const mounts: Template["mounts"] = [
{ {
mountPath: "./clickhouse/clickhouse-config.xml", filePath: "/clickhouse/clickhouse-config.xml",
content: ` content: `
<clickhouse> <clickhouse>
<logger> <logger>
@ -45,7 +45,7 @@ export function generate(schema: Schema): Template {
`, `,
}, },
{ {
mountPath: "./clickhouse/clickhouse-user-config.xml", filePath: "/clickhouse/clickhouse-user-config.xml",
content: ` content: `
<clickhouse> <clickhouse>
<profiles> <profiles>

View File

@ -0,0 +1,81 @@
version: "3.9"
services:
teable:
image: ghcr.io/teableio/teable:1.3.1-alpha-build.460
restart: always
ports:
- ${TEABLE_PORT}
volumes:
- teable-data:/app/.assets
# you may use a bind-mounted host directory instead,
# so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/teable/data:/app/.assets:rw
environment:
- TZ=${TIMEZONE}
- NEXT_ENV_IMAGES_ALL_REMOTE=true
- PUBLIC_ORIGIN=${PUBLIC_ORIGIN}
- PRISMA_DATABASE_URL=${PRISMA_DATABASE_URL}
- PUBLIC_DATABASE_PROXY=${PUBLIC_DATABASE_PROXY}
- BACKEND_MAIL_HOST=${BACKEND_MAIL_HOST}
- BACKEND_MAIL_PORT=${BACKEND_MAIL_PORT}
- BACKEND_MAIL_SECURE=${BACKEND_MAIL_SECURE}
- BACKEND_MAIL_SENDER=${BACKEND_MAIL_SENDER}
- BACKEND_MAIL_SENDER_NAME=${BACKEND_MAIL_SENDER_NAME}
- BACKEND_MAIL_AUTH_USER=${BACKEND_MAIL_AUTH_USER}
- BACKEND_MAIL_AUTH_PASS=${BACKEND_MAIL_AUTH_PASS}
networks:
- dokploy-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${TEABLE_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${TEABLE_PORT}"
depends_on:
teable-db-migrate:
condition: service_completed_successfully
teable-db:
image: postgres:15.4
restart: always
ports:
- "${TEABLE_DB_PORT}:${POSTGRES_PORT}"
volumes:
- teable-db:/var/lib/postgresql/data
# you may use a bind-mounted host directory instead,
# so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/db/data:/var/lib/postgresql/data:rw
environment:
- TZ=${TIMEZONE}
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
networks:
- dokploy-network
healthcheck:
test:
[
"CMD-SHELL",
"sh -c 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'",
]
interval: 10s
timeout: 3s
retries: 3
teable-db-migrate:
image: ghcr.io/teableio/teable-db-migrate:latest
environment:
- TZ=${TIMEZONE}
- PRISMA_DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
networks:
- dokploy-network
depends_on:
teable-db:
condition: service_healthy
networks:
dokploy-network:
external: true
volumes:
teable-data: {}
teable-db: {}

View File

@ -0,0 +1,48 @@
import {
type Schema,
type Template,
generateHash,
generatePassword,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const password = generatePassword();
const randomDomain = generateRandomDomain(schema);
const publicDbPort = ((min: number, max: number) => {
return Math.round(Math.random() * (max - min) + min);
})(32769, 65534);
const envs = [
`TEABLE_HOST=${randomDomain}`,
"TEABLE_PORT=3000",
`TEABLE_DB_PORT=${publicDbPort}`,
`HASH=${mainServiceHash}`,
"TIMEZONE=UTC",
"# Postgres",
"POSTGRES_HOST=teable-db",
"POSTGRES_PORT=5432",
"POSTGRES_DB=teable",
"POSTGRES_USER=teable",
`POSTGRES_PASSWORD=${password}`,
"# App",
"PUBLIC_ORIGIN=https://${TEABLE_HOST}",
"PRISMA_DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}",
"PUBLIC_DATABASE_PROXY=${TEABLE_HOST}:${TEABLE_DB_PORT}",
"# Need to support sending emails to enable the following configurations",
"# You need to modify the configuration according to the actual situation, otherwise it will not be able to send emails correctly.",
"#BACKEND_MAIL_HOST=smtp.teable.io",
"#BACKEND_MAIL_PORT=465",
"#BACKEND_MAIL_SECURE=true",
"#BACKEND_MAIL_SENDER=noreply.teable.io",
"#BACKEND_MAIL_SENDER_NAME=Teable",
"#BACKEND_MAIL_AUTH_USER=username",
"#BACKEND_MAIL_AUTH_PASS=password",
];
return {
envs,
};
}

View File

@ -393,4 +393,19 @@ export const templates: TemplateData[] = [
tags: ["media system"], tags: ["media system"],
load: () => import("./jellyfin/index").then((m) => m.generate), load: () => import("./jellyfin/index").then((m) => m.generate),
}, },
{
id: "teable",
name: "teable",
version: "v1.3.1-alpha-build.460",
description:
"Teable is a Super fast, Real-time, Professional, Developer friendly, No-code database built on Postgres. It uses a simple, spreadsheet-like interface to create complex enterprise-level database applications. Unlock efficient app development with no-code, free from the hurdles of data security and scalability.",
logo: "teable.png",
links: {
github: "https://github.com/teableio/teable",
website: "https://teable.io/",
docs: "https://help.teable.io/",
},
tags: ["database", "spreadsheet", "low-code", "nocode"],
load: () => import("./teable/index").then((m) => m.generate),
},
]; ];

View File

@ -13,7 +13,7 @@ export interface Schema {
export interface Template { export interface Template {
envs: string[]; envs: string[];
mounts?: { mounts?: {
mountPath: string; filePath: string;
content?: string; content?: string;
}[]; }[];
} }

View File

@ -81,6 +81,7 @@ docker service create \
--network dokploy-network \ --network dokploy-network \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
--mount type=bind,source=/etc/dokploy,target=/etc/dokploy \ --mount type=bind,source=/etc/dokploy,target=/etc/dokploy \
--mount type=volume,source=dokploy-docker-config,target=/root/.docker \
--publish published=3000,target=3000,mode=host \ --publish published=3000,target=3000,mode=host \
--update-parallelism 1 \ --update-parallelism 1 \
--update-order stop-first \ --update-order stop-first \

View File

@ -65,6 +65,7 @@ docker service create \
--network dokploy-network \ --network dokploy-network \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
--mount type=bind,source=/etc/dokploy,target=/etc/dokploy \ --mount type=bind,source=/etc/dokploy,target=/etc/dokploy \
--mount type=volume,source=dokploy-docker-config,target=/root/.docker \
--publish published=3000,target=3000,mode=host \ --publish published=3000,target=3000,mode=host \
--update-parallelism 1 \ --update-parallelism 1 \
--update-order stop-first \ --update-order stop-first \

View File

@ -81,6 +81,7 @@ docker service create \
--network dokploy-network \ --network dokploy-network \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
--mount type=bind,source=/etc/dokploy,target=/etc/dokploy \ --mount type=bind,source=/etc/dokploy,target=/etc/dokploy \
--mount type=volume,source=dokploy-docker-config,target=/root/.docker \
--publish published=3000,target=3000,mode=host \ --publish published=3000,target=3000,mode=host \
--update-parallelism 1 \ --update-parallelism 1 \
--update-order stop-first \ --update-order stop-first \

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 443 KiB

File diff suppressed because it is too large Load Diff