Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 67 additions & 39 deletions apps/dokploy/components/dashboard/application/build/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,29 +29,6 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";

// Railpack versions from https://github.com/railwayapp/railpack/releases
export const RAILPACK_VERSIONS = [
"0.15.4",
"0.15.3",
"0.15.2",
"0.15.1",
"0.15.0",
"0.14.0",
"0.13.0",
"0.12.0",
"0.11.0",
"0.10.0",
"0.9.2",
"0.9.1",
"0.9.0",
"0.8.0",
"0.7.0",
"0.6.0",
"0.5.0",
"0.4.0",
"0.3.0",
"0.2.2",
] as const;

export enum BuildType {
dockerfile = "dockerfile",
Expand Down Expand Up @@ -91,7 +68,7 @@ const mySchema = z.discriminatedUnion("buildType", [
}),
z.object({
buildType: z.literal(BuildType.railpack),
railpackVersion: z.string().nullable().default("0.15.4"),
railpackVersion: z.string().nullable().default(null),
}),
z.object({
buildType: z.literal(BuildType.static),
Expand Down Expand Up @@ -169,7 +146,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
{ applicationId },
{ enabled: !!applicationId },
);

const form = useForm({
defaultValues: {
buildType: BuildType.nixpacks,
Expand All @@ -178,7 +154,15 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
});

const buildType = form.watch("buildType");
const railpackVersion = form.watch("railpackVersion");

const {
data: railpackVersions,
isLoading: isLoadingRailpackVersions,
isError: isErrorRailpackVersions,
} = api.application.getRailpackVersions.useQuery(undefined, {
enabled: buildType === BuildType.railpack,
staleTime: 1000 * 60 * 60 * 24, // 24 hours
});
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);

useEffect(() => {
Expand All @@ -191,28 +175,54 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
};

form.reset(resetData(typedData));
}
}, [data, form]);

// Check if railpack version is manual (not in the predefined list)
useEffect(() => {
if (railpackVersions?.length) {
if (
data.railpackVersion &&
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
data?.railpackVersion &&
!railpackVersions.includes(data.railpackVersion)
) {
setIsManualRailpackVersion(true);
}
if (!form.getValues("railpackVersion")) {
form.setValue("railpackVersion", railpackVersions[0]);
}
}
}, [data, form]);
}, [railpackVersions, data?.railpackVersion, form]);

useEffect(() => {
if (buildType === BuildType.railpack && isErrorRailpackVersions) {
setIsManualRailpackVersion(true);
}
}, [buildType, isErrorRailpackVersions]);

// Hide builder section when Docker provider is selected
if (data?.sourceType === "docker") {
return null;
}

const onSubmit = async (data: AddTemplate) => {
const normalizedRailpackVersion =
data.buildType === BuildType.railpack
? (data.railpackVersion || railpackVersions?.[0] || "")
.replace(/^v/, "")
.trim()
: null;

if (data.buildType === BuildType.railpack && !normalizedRailpackVersion) {
toast.error("Railpack version is required");
return;
}

await mutateAsync({
applicationId,
buildType: data.buildType,
publishDirectory:
data.buildType === BuildType.nixpacks ? data.publishDirectory : null,
data.buildType === BuildType.nixpacks
? (data.publishDirectory ?? null)
: null,
dockerfile:
data.buildType === BuildType.dockerfile ? data.dockerfile : null,
dockerContextPath:
Expand All @@ -227,7 +237,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === BuildType.static ? data.isStaticSpa : null,
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.15.4"
? normalizedRailpackVersion
: null,
Comment on lines 238 to 241
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Empty string saved as version if API is still loading or failed

If the user switches to Railpack and submits before getRailpackVersions resolves (or if the GitHub API request fails entirely), data.railpackVersion is null (the schema default) and railpackVersions?.[0] is undefined. The expression evaluates to "".replace(/^v/, "")"", which silently saves an empty string as the build version, causing the next build to fail without a clear error.

The Save button should be disabled while versions are loading, or a validation should reject an empty version:

Suggested change
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.15.4"
? (data.railpackVersion || railpackVersions?.[0] || "").replace(/^v/, "")
: null,
? (data.railpackVersion || railpackVersions?.[0] || null)?.replace(/^v/, "") ?? null

Pairing this with disabling the Save button while isLoadingRailpackVersions is true would fully close the gap.

})
.then(async () => {
Expand Down Expand Up @@ -465,7 +475,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
size="sm"
onClick={() => {
setIsManualRailpackVersion(false);
field.onChange("0.15.4");
field.onChange(railpackVersions?.[0] ?? "");
}}
>
Use predefined versions
Expand All @@ -481,21 +491,25 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
field.onChange(value);
}
}}
value={field.value ?? "0.15.4"}
value={field.value ?? railpackVersions?.[0] ?? ""}
>
<SelectTrigger>
<SelectValue placeholder="Select Railpack version" />
<SelectTrigger disabled={isLoadingRailpackVersions}>
{isLoadingRailpackVersions ? (
<span className="text-muted-foreground">Loading versions...</span>
) : (
<SelectValue placeholder="Select Railpack version" />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
<span className="font-medium">
✏️ Manual (Custom Version)
</span>
</SelectItem>
{RAILPACK_VERSIONS.map((version) => (
{railpackVersions?.map((version) => (
<SelectItem key={version} value={version}>
v{version}
{version === "0.15.4" && (
{version === railpackVersions?.[0] && (
<Badge
variant="secondary"
className="ml-2 px-1 text-xs"
Expand All @@ -521,14 +535,28 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
View releases
</a>
</FormDescription>
{isErrorRailpackVersions && !isManualRailpackVersion && (
<p className="text-sm text-destructive">
Failed to load Railpack versions. Switch to manual mode
to set a custom version.
</p>
)}
<FormMessage />
</FormItem>
)}
/>
</>
)}
<div className="flex w-full justify-end">
<Button isLoading={isPending} type="submit">
<Button
isLoading={isPending}
type="submit"
disabled={
buildType === BuildType.railpack &&
!isManualRailpackVersion &&
isLoadingRailpackVersions
}
>
Save
</Button>
</div>
Expand Down
15 changes: 15 additions & 0 deletions apps/dokploy/server/api/routers/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,21 @@ export const applicationRouter = createTRPCRouter({
};
}),

getRailpackVersions: protectedProcedure.query(async () => {
const res = await fetch(
"https://api.github.com/repos/railwayapp/railpack/releases",
{ headers: { Accept: "application/vnd.github+json" } },
Comment on lines +1107 to +1109
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 GitHub API pagination — only first 30 releases returned

The /releases endpoint defaults to per_page=30. Railpack already has more than 20 releases listed in the old hardcoded array, so future releases beyond 30 would be silently omitted. Adding ?per_page=100 would cover the foreseeable future without requiring pagination logic.

Suggested change
const res = await fetch(
"https://api.github.com/repos/railwayapp/railpack/releases",
{ headers: { Accept: "application/vnd.github+json" } },
"https://api.github.com/repos/railwayapp/railpack/releases?per_page=100",

);
if (!res.ok) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to fetch Railpack versions from GitHub",
});
}
const releases = (await res.json()) as Array<{ tag_name: string }>;
return releases.map((r) => r.tag_name.replace(/^v/, ""));
}),
Comment on lines +1106 to +1119
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No server-side caching — GitHub rate limits will be hit quickly

The endpoint makes a fresh GitHub API call on every invocation. GitHub's unauthenticated rate limit is 60 requests/hour per IP address. Because the Dokploy server makes these calls (all users share one IP), a busy instance with multiple users opening the Railpack build tab can exhaust this limit very quickly, causing every subsequent call to fail with a 403/429.

The client-side staleTime: 1000 * 60 * 60 * 24 only helps within a single user's session — it won't help when different users open the page, or when the server restarts and all client caches are cold.

A simple fix is to add a server-side in-memory cache (e.g. a module-level variable with a timestamp) or use a Cache-Control response header from GitHub to drive conditional requests. At minimum, a GITHUB_TOKEN environment variable should be optionally read and forwarded so authenticated rate limits (5 000 req/hour) can be used:

const res = await fetch(
    "https://api.github.com/repos/railwayapp/railpack/releases",
    {
        headers: {
            Accept: "application/vnd.github+json",
            ...(process.env.GITHUB_TOKEN
                ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
                : {}),
        },
    },
);


readLogs: protectedProcedure
.input(
apiFindOneApplication.extend({
Expand Down
12 changes: 11 additions & 1 deletion packages/server/src/db/schema/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,9 +428,19 @@ export const apiSaveBuildType = createSchema
dockerBuildStage: true,
herokuVersion: true,
railpackVersion: true,
publishDirectory: true,
isStaticSpa: true,
})
.required()
.merge(createSchema.pick({ publishDirectory: true, isStaticSpa: true }));
.superRefine((data, ctx) => {
if (data.buildType === "railpack" && !data.railpackVersion?.trim()) {
ctx.addIssue({
code: "custom",
path: ["railpackVersion"],
message: "Railpack version is required",
});
}
});

export const apiSaveGithubProvider = createSchema
.pick({
Expand Down