import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; import { Form, useActionData, useParams, type MetaFunction } from "@remix-run/react"; import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/utils"; import { useState } from "react"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { MainHorizontallyCenteredContainer, PageBody, PageContainer, } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Select, SelectItem } from "~/components/primitives/Select"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import type { CreatePrivateLinkConnectionBody } from "@trigger.dev/platform"; import { createPrivateLink, getPrivateLinkRegions } from "~/services/platform.v3.server"; import { requireUserId } from "~/services/session.server"; import { docsPath, OrganizationParamsSchema, organizationPath, v3PrivateConnectionsPath, } from "~/utils/pathBuilder"; import { ArrowTopRightOnSquareIcon, BookOpenIcon, CommandLineIcon, DocumentTextIcon, PencilSquareIcon, SparklesIcon, TrashIcon, } from "@heroicons/react/20/solid"; export const meta: MetaFunction = () => { return [{ title: `Add Private Connection | Trigger.dev` }]; }; export async function loader({ params, request }: LoaderFunctionArgs) { const userId = await requireUserId(request); const { organizationSlug } = OrganizationParamsSchema.parse(params); const canAccess = await canAccessPrivateConnections({ organizationSlug, userId }); if (!canAccess) { return redirect(organizationPath({ slug: organizationSlug })); } const organization = await prisma.organization.findFirst({ where: { slug: organizationSlug, members: { some: { userId } } }, }); if (!organization) { throw new Response(null, { status: 404, statusText: "Organization not found" }); } const [_error, regions] = await tryCatch(getPrivateLinkRegions(organization.id)); const awsAccountIds = env.PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS?.split(",").filter(Boolean) ?? []; return typedjson({ availableRegions: regions?.availableRegions ?? ["us-east-1", "eu-central-1"], activeRegions: regions?.activeRegions ?? [], awsAccountIds, }); } const schema = z.object({ name: z.string().min(1, "Name is required").max(100, "Name must be 100 characters or less"), endpointServiceName: z .string() .min(1, "VPC Endpoint Service name is required") .regex( /^com\.amazonaws\.vpce\..+\.vpce-svc-.+$/, "Must be a valid VPC Endpoint Service name (com.amazonaws.vpce..vpce-svc-*)" ), targetRegion: z.string().min(1, "Region is required"), }); export const action: ActionFunction = async ({ request, params }) => { const userId = await requireUserId(request); const { organizationSlug } = OrganizationParamsSchema.parse(params); const formData = await request.formData(); const submission = parseWithZod(formData, { schema }); if (submission.status !== "success") { return json(submission.reply()); } const organization = await prisma.organization.findFirst({ where: { slug: organizationSlug, members: { some: { userId } } }, }); if (!organization) { return redirectWithErrorMessage( v3PrivateConnectionsPath({ slug: organizationSlug }), request, "Organization not found" ); } // Fetch available regions dynamically (same call the loader makes) const [, fetchedRegions] = await tryCatch(getPrivateLinkRegions(organization.id)); const availableRegions = fetchedRegions?.availableRegions ?? ["us-east-1", "eu-central-1"]; const { targetRegion: selectedRegion, ...rest } = submission.value; if (!availableRegions.includes(selectedRegion)) { return redirectWithErrorMessage( v3PrivateConnectionsPath({ slug: organizationSlug }), request, `Invalid region: ${selectedRegion}` ); } const [error] = await tryCatch( createPrivateLink(organization.id, { ...rest, targetRegion: selectedRegion as CreatePrivateLinkConnectionBody["targetRegion"], }) ); if (error) { return redirectWithErrorMessage( v3PrivateConnectionsPath({ slug: organizationSlug }), request, error.message ); } const message = "Connection created! Provisioning will begin shortly."; return redirectWithSuccessMessage( v3PrivateConnectionsPath({ slug: organizationSlug }), request, message ); }; type SetupMethod = "manual" | "ai" | "terraform" | "docs"; type PortEntry = { port: string; protocol: "TCP" | "UDP" }; const AWS_REGIONS = [ { value: "us-east-1", label: "US East (N. Virginia)" }, { value: "us-east-2", label: "US East (Ohio)" }, { value: "us-west-1", label: "US West (N. California)" }, { value: "us-west-2", label: "US West (Oregon)" }, { value: "af-south-1", label: "Africa (Cape Town)" }, { value: "ap-east-1", label: "Asia Pacific (Hong Kong)" }, { value: "ap-south-1", label: "Asia Pacific (Mumbai)" }, { value: "ap-south-2", label: "Asia Pacific (Hyderabad)" }, { value: "ap-southeast-1", label: "Asia Pacific (Singapore)" }, { value: "ap-southeast-2", label: "Asia Pacific (Sydney)" }, { value: "ap-southeast-3", label: "Asia Pacific (Jakarta)" }, { value: "ap-southeast-4", label: "Asia Pacific (Melbourne)" }, { value: "ap-northeast-1", label: "Asia Pacific (Tokyo)" }, { value: "ap-northeast-2", label: "Asia Pacific (Seoul)" }, { value: "ap-northeast-3", label: "Asia Pacific (Osaka)" }, { value: "ca-central-1", label: "Canada (Central)" }, { value: "ca-west-1", label: "Canada West (Calgary)" }, { value: "eu-central-1", label: "Europe (Frankfurt)" }, { value: "eu-central-2", label: "Europe (Zurich)" }, { value: "eu-west-1", label: "Europe (Ireland)" }, { value: "eu-west-2", label: "Europe (London)" }, { value: "eu-west-3", label: "Europe (Paris)" }, { value: "eu-south-1", label: "Europe (Milan)" }, { value: "eu-south-2", label: "Europe (Spain)" }, { value: "eu-north-1", label: "Europe (Stockholm)" }, { value: "il-central-1", label: "Israel (Tel Aviv)" }, { value: "me-south-1", label: "Middle East (Bahrain)" }, { value: "me-central-1", label: "Middle East (UAE)" }, { value: "sa-east-1", label: "South America (São Paulo)" }, ]; function TerraformWizard({ awsAccountIds }: { awsAccountIds: string[] }) { const [hostname, setHostname] = useState(""); const [ports, setPorts] = useState([{ port: "5432", protocol: "TCP" }]); const [region, setRegion] = useState("us-east-1"); const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]); const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index)); const updatePort = (index: number, field: keyof PortEntry, value: string) => setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p))); const validPorts = ports.filter((p) => p.port !== ""); const terraformScript = `# Trigger.dev Private Networking - Terraform Configuration # Creates an NLB and VPC Endpoint Service for your resource variable "vpc_id" { description = "Your VPC ID" type = string } variable "subnet_ids" { description = "Private subnet IDs in your VPC" type = list(string) } variable "target_ip" { description = "IP address of the target resource" type = string${hostname ? `\n default = "${hostname}"` : ""} } # Network Load Balancer resource "aws_lb" "trigger_privatelink" { name = "trigger-privatelink" internal = true load_balancer_type = "network" subnets = var.subnet_ids } ${validPorts .map( (p, i) => ` resource "aws_lb_target_group" "port_${p.port}" { name = "trigger-pl-${p.port}" port = ${p.port} protocol = "${p.protocol}" vpc_id = var.vpc_id target_type = "ip" health_check { protocol = "TCP" port = ${p.port} } } resource "aws_lb_target_group_attachment" "port_${p.port}" { target_group_arn = aws_lb_target_group.port_${p.port}.arn target_id = var.target_ip port = ${p.port} } resource "aws_lb_listener" "port_${p.port}" { load_balancer_arn = aws_lb.trigger_privatelink.arn port = ${p.port} protocol = "${p.protocol}" default_action { type = "forward" target_group_arn = aws_lb_target_group.port_${p.port}.arn } }` ) .join("\n")} # VPC Endpoint Service resource "aws_vpc_endpoint_service" "trigger_privatelink" { acceptance_required = false network_load_balancer_arns = [aws_lb.trigger_privatelink.arn] # Trigger.dev runs in us-east-1 and eu-central-1. Listing both makes this # service consumable from either region so any of your tasks can connect. supported_regions = ["us-east-1", "eu-central-1"] allowed_principals = [ ${awsAccountIds.map((id) => ` "arn:aws:iam::${id}:root",`).join("\n")} ] } output "endpoint_service_name" { description = "Paste this into the Trigger.dev dashboard" value = aws_vpc_endpoint_service.trigger_privatelink.service_name } `; return (
setHostname(e.target.value)} placeholder="my-database.abc123.us-east-1.rds.amazonaws.com" fullWidth />
{ports.map((entry, index) => (
updatePort(index, "port", e.target.value)} placeholder="Port" className="w-24" /> {ports.length > 1 && ( )}
))}
main.tf
          {terraformScript}
        
); } function AIPromptWizard({ awsAccountIds }: { awsAccountIds: string[] }) { const [hostname, setHostname] = useState(""); const [ports, setPorts] = useState([{ port: "5432", protocol: "TCP" }]); const [region, setRegion] = useState("us-east-1"); const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]); const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index)); const updatePort = (index: number, field: keyof PortEntry, value: string) => setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p))); const validPorts = ports.filter((p) => p.port !== ""); const regionLabel = AWS_REGIONS.find((r) => r.value === region)?.label ?? region; const _portsDescription = validPorts.length > 0 ? validPorts.map((p) => `${p.port} (${p.protocol})`).join(", ") : "5432 (TCP)"; const prompt = `I need to set up AWS PrivateLink so that Trigger.dev can connect to my resource. Please create the following in my AWS account in the ${region} (${regionLabel}) region: 1. A Network Load Balancer (NLB): - Name: trigger-privatelink - Internal: yes - Type: network - Place it in my private subnets 2. For each of the following ports, create a target group, target group attachment, and listener: ${validPorts.length > 0 ? validPorts.map((p) => ` - Port ${p.port} (${p.protocol})`).join("\n") : " - Port 5432 (TCP)"} Each target group should: - Target type: ip - Target IP: ${hostname || ""} - Have a TCP health check on the same port 3. A VPC Endpoint Service: - Acceptance required: no - Attach the NLB created above - Supported regions: us-east-1, eu-central-1 (these are the AWS regions Trigger.dev runs in, so the service must be consumable from both) - Allowed principals: ${awsAccountIds.map((id) => ` - arn:aws:iam::${id}:root`).join("\n") || " - "} After creating everything, give me the VPC Endpoint Service name (it looks like com.amazonaws.vpce..vpce-svc-*) so I can paste it into the Trigger.dev dashboard.`; return (
setHostname(e.target.value)} placeholder="my-database.abc123.us-east-1.rds.amazonaws.com" fullWidth />
{ports.map((entry, index) => (
updatePort(index, "port", e.target.value)} placeholder="Port" className="w-24" /> {ports.length > 1 && ( )}
))}
AI Prompt
          {prompt}
        
); } export default function Page() { const { availableRegions, activeRegions, awsAccountIds } = useTypedLoaderData(); const { organizationSlug } = useParams(); const lastSubmission = useActionData(); const [setupMethod, setSetupMethod] = useState("manual"); const defaultRegion = "us-east-1"; const [form, { name, endpointServiceName, targetRegion }] = useForm({ id: "create-private-connection", lastResult: lastSubmission as any, onValidate({ formData }) { return parseWithZod(formData, { schema }); }, }); return ( Private connection docs
Add Private Connection Connect your AWS resources to Trigger.dev task pods via AWS PrivateLink. You'll need to create a VPC Endpoint Service on your AWS account first.
{/* Setup method cards */}
{/* AI prompt wizard */} {setupMethod === "ai" && (
AI-Assisted Setup Fill in your resource details below and we'll generate a prompt you can paste into Claude, ChatGPT, or any AI assistant with AWS access. After it creates the resources, paste the VPC Endpoint Service name below.
)} {/* Terraform wizard (expandable) */} {setupMethod === "terraform" && (
Terraform Configuration Fill in your resource details below and we'll generate a Terraform script. Run{" "} terraform apply {" "} to create the VPC Endpoint Service, then paste the output service name below.
)} {/* Docs link */} {setupMethod === "docs" && (
Setup Guide {awsAccountIds.length > 0 && ( <> When adding allowed principals to your VPC Endpoint Service, use the following AWS account ARN(s):
{awsAccountIds.map((id) => ( ))}
)} Follow the step-by-step guide in our docs to create a VPC Endpoint Service in the AWS Console, then come back here to add the connection. Open setup guide
)} {/* AWS account ARNs reference */} {setupMethod === "manual" && awsAccountIds.length > 0 && (
Add the following AWS account ARN(s) to your VPC Endpoint Service's allowed principals:
{awsAccountIds.map((id) => ( ))}
)} {/* Connection form (always visible) */}
Connection Details
{name.errors} {endpointServiceName.errors} {targetRegion.errors} {activeRegions.length > 0 && ( Your tasks have recently run in: {activeRegions.join(", ")} )} Cancel } confirmButton={ } />
); }