diff --git a/.changeset/silly-cows-serve.md b/.changeset/silly-cows-serve.md
new file mode 100644
index 0000000000..d655dbed80
--- /dev/null
+++ b/.changeset/silly-cows-serve.md
@@ -0,0 +1,5 @@
+---
+"trigger.dev": patch
+---
+
+Added support for Preview branches in v4 projects
diff --git a/CHANGESETS.md b/CHANGESETS.md
index 12022fa72a..cf66007661 100644
--- a/CHANGESETS.md
+++ b/CHANGESETS.md
@@ -30,28 +30,14 @@ Please follow the best-practice of adding changesets in the same commit as the c
 
 ## Snapshot instructions
 
-!MAKE SURE TO UPDATE THE TAG IN THE INSTRUCTIONS BELOW!
+1. Delete the `.changeset/pre.json` file (if it exists)
 
-1. Add changesets as usual
+2. Do a temporary commit (do NOT push this, you should undo it after)
 
-```sh
-pnpm run changeset:add
-```
+3. Copy the `GITHUB_TOKEN` line from the .env file
 
-2. Create a snapshot version (replace "prerelease" with your tag)
+4. Run `GITHUB_TOKEN=github_pat_12345 ./scripts/publish-prerelease.sh re2`
 
-```sh
-pnpm exec changeset version --snapshot prerelease
-```
+Make sure to replace the token with yours. `re2` is the tag that will be used for the pre-release.
 
-3. Build the packages:
-
-```sh
-pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
-```
-
-4. Publish the snapshot (replace "dev" with your tag)
-
-```sh
-pnpm exec changeset publish --no-git-tag --snapshot --tag prerelease
-```
+5. Undo the commit where you deleted the pre.json file.
diff --git a/apps/webapp/app/assets/icons/ArchiveIcon.tsx b/apps/webapp/app/assets/icons/ArchiveIcon.tsx
new file mode 100644
index 0000000000..1d910ba750
--- /dev/null
+++ b/apps/webapp/app/assets/icons/ArchiveIcon.tsx
@@ -0,0 +1,44 @@
+export function ArchiveIcon({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <g clipPath="url(#clip0_17398_782)">
+        <path
+          d="M19.2293 9C20.1562 9.00001 20.8612 9.83278 20.7088 10.7471L19.2781 19.3291C19.1173 20.2933 18.283 21 17.3055 21H6.69416C5.71662 21 4.88235 20.2933 4.7215 19.3291L3.29084 10.7471C3.13848 9.83289 3.8436 9.00021 4.77033 9H19.2293ZM9.95002 12.5C9.12179 12.5002 8.45002 13.1717 8.45002 14C8.45002 14.8283 9.12179 15.4998 9.95002 15.5H13.95L14.1033 15.4922C14.8597 15.4154 15.45 14.7767 15.45 14C15.45 13.2233 14.8597 12.5846 14.1033 12.5078L13.95 12.5H9.95002Z"
+          fill="currentColor"
+        />
+        <rect x="2" y="3" width="20" height="4" rx="1" fill="currentColor" />
+      </g>
+      <defs>
+        <clipPath id="clip0_17398_782">
+          <rect width="24" height="24" fill="currentColor" />
+        </clipPath>
+      </defs>
+    </svg>
+  );
+}
+
+export function UnarchiveIcon({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <g clipPath="url(#clip0_17398_66731)">
+        <path
+          d="M19.2027 10C20.1385 10 20.8456 10.8478 20.6782 11.7686L19.2984 19.3574C19.1254 20.3084 18.2972 21 17.3306 21H6.66945C5.70287 21 4.87458 20.3084 4.70167 19.3574L3.32179 11.7686C3.15438 10.8478 3.86152 10 4.79738 10H10.9995V16C10.9995 16.5521 11.4475 16.9997 11.9995 17C12.5518 17 12.9995 16.5523 12.9995 16V10H19.2027Z"
+          fill="currentColor"
+        />
+        <rect x="11" y="4" width="2" height="6" fill="currentColor" />
+        <path
+          d="M15.5 6.5L12 3L8.5 6.5"
+          stroke="currentColor"
+          strokeWidth="2"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+        />
+      </g>
+      <defs>
+        <clipPath id="clip0_17398_66731">
+          <rect width="24" height="24" fill="currentColor" />
+        </clipPath>
+      </defs>
+    </svg>
+  );
+}
diff --git a/apps/webapp/app/assets/icons/EnvironmentIcons.tsx b/apps/webapp/app/assets/icons/EnvironmentIcons.tsx
index 02fa27c546..bc74ab10bc 100644
--- a/apps/webapp/app/assets/icons/EnvironmentIcons.tsx
+++ b/apps/webapp/app/assets/icons/EnvironmentIcons.tsx
@@ -75,7 +75,7 @@ export function ProdEnvironmentIcon({ className }: { className?: string }) {
     <svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
       <path
         d="M12.5037 7.33603C12.3174 6.88799 11.6827 6.88799 11.4963 7.33603L10.4338 9.89064L7.6759 10.1117C7.1922 10.1505 6.99606 10.7542 7.36459 11.0698L9.46583 12.8698L8.82387 15.561C8.71128 16.033 9.22477 16.4061 9.63888 16.1532L12 14.711L14.3612 16.1532C14.7753 16.4061 15.2888 16.0331 15.1762 15.561L14.5343 12.8698L16.6355 11.0698C17.004 10.7542 16.8079 10.1505 16.3242 10.1117L13.5663 9.89064L12.5037 7.33603Z"
-        fill="white"
+        fill="currentColor"
       />
       <rect
         x="3"
@@ -151,3 +151,28 @@ export function DeployedEnvironmentIconSmall({ className }: { className?: string
     </svg>
   );
 }
+
+export function PreviewEnvironmentIconSmall({ className }: { className?: string }) {
+  return <BranchEnvironmentIconSmall className={className} />;
+}
+
+export function BranchEnvironmentIconSmall({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path
+        d="M2.23059 7C1.65409 6.99967 1.30545 6.36259 1.61634 5.87695L3.88489 2.33301C4.17195 1.88466 4.82727 1.88472 5.11438 2.33301L7.38294 5.87695C7.69386 6.36265 7.34531 6.99982 6.76868 7L2.23059 7Z"
+        fill="currentColor"
+      />
+      <path
+        d="M3.5 6H5.5V15C5.5 15.5523 5.05228 16 4.5 16C3.94772 16 3.5 15.5523 3.5 15V6Z"
+        fill="currentColor"
+      />
+      <path
+        d="M13.5 7V7.5C13.5 9.15685 12.1569 10.5 10.5 10.5H7.5C5.84315 10.5 4.5 11.8431 4.5 13.5V15"
+        stroke="currentColor"
+        strokeWidth="2"
+      />
+      <circle cx="13.5" cy="5" r="2" stroke="currentColor" strokeWidth="2" />
+    </svg>
+  );
+}
diff --git a/apps/webapp/app/assets/icons/IntegrationIcon.tsx b/apps/webapp/app/assets/icons/IntegrationIcon.tsx
deleted file mode 100644
index fd1839228f..0000000000
--- a/apps/webapp/app/assets/icons/IntegrationIcon.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { LogoIcon } from "~/components/LogoIcon";
-
-export function IntegrationIcon() {
-  return <LogoIcon className="h-3.5 w-3.5 flex-none pb-0.5" />;
-}
diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx
index ea7a20764d..f3a4b3faa5 100644
--- a/apps/webapp/app/components/BlankStatePanels.tsx
+++ b/apps/webapp/app/components/BlankStatePanels.tsx
@@ -18,6 +18,7 @@ import { useProject } from "~/hooks/useProject";
 import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server";
 import {
   docsPath,
+  v3BillingPath,
   v3EnvironmentPath,
   v3EnvironmentVariablesPath,
   v3NewProjectAlertPath,
@@ -36,6 +37,11 @@ import { TextLink } from "./primitives/TextLink";
 import { InitCommandV3, PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands";
 import { StepContentContainer } from "./StepContentContainer";
 import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
+import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
+import { useFeatures } from "~/hooks/useFeatures";
+import { DialogContent, DialogTrigger, Dialog } from "./primitives/Dialog";
+import { V4Badge } from "./V4Badge";
+import { NewBranchPanel } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
 
 export function HasNoTasksDev() {
   return (
@@ -431,7 +437,120 @@ export function NoWaitpointTokens() {
   );
 }
 
-function SwitcherPanel() {
+export function BranchesNoBranchableEnvironment() {
+  const { isManagedCloud } = useFeatures();
+  const organization = useOrganization();
+
+  if (!isManagedCloud) {
+    return (
+      <InfoPanel
+        title="Create a preview environment"
+        icon={BranchEnvironmentIconSmall}
+        iconClassName="text-preview"
+        panelClassName="max-w-full"
+      >
+        <Paragraph spacing variant="small">
+          To add branches you need to have a <InlineCode>RuntimeEnvironment</InlineCode> where{" "}
+          <InlineCode>isBranchableEnvironment</InlineCode> is true. We recommend creating a
+          dedicated one using the "PREVIEW" type.
+        </Paragraph>
+      </InfoPanel>
+    );
+  }
+
+  return (
+    <InfoPanel
+      title="Upgrade to get preview branches"
+      icon={BranchEnvironmentIconSmall}
+      iconClassName="text-preview"
+      panelClassName="max-w-full"
+      accessory={
+        <LinkButton variant="primary/small" to={v3BillingPath(organization)}>
+          Upgrade
+        </LinkButton>
+      }
+    >
+      <Paragraph spacing variant="small">
+        Preview branches in Trigger.dev create isolated environments for testing new features before
+        production.
+      </Paragraph>
+    </InfoPanel>
+  );
+}
+
+export function BranchesNoBranches({
+  parentEnvironment,
+  limits,
+  canUpgrade,
+}: {
+  parentEnvironment: { id: string };
+  limits: { used: number; limit: number };
+  canUpgrade: boolean;
+}) {
+  const organization = useOrganization();
+
+  if (limits.used >= limits.limit) {
+    return (
+      <InfoPanel
+        title="Upgrade to get preview branches"
+        icon={BranchEnvironmentIconSmall}
+        iconClassName="text-preview"
+        panelClassName="max-w-full"
+        accessory={
+          canUpgrade ? (
+            <LinkButton variant="primary/small" to={v3BillingPath(organization)}>
+              Upgrade
+            </LinkButton>
+          ) : (
+            <Feedback
+              button={<Button variant="primary/small">Request more</Button>}
+              defaultValue="help"
+            />
+          )
+        }
+      >
+        <Paragraph spacing variant="small">
+          You've reached the limit ({limits.used}/{limits.limit}) of branches for your plan. Upgrade
+          to get branches.
+        </Paragraph>
+      </InfoPanel>
+    );
+  }
+
+  return (
+    <InfoPanel
+      title="Create your first branch"
+      icon={BranchEnvironmentIconSmall}
+      iconClassName="text-preview"
+      panelClassName="max-w-full"
+      accessory={
+        <NewBranchPanel
+          button={
+            <Button
+              variant="primary/small"
+              LeadingIcon={PlusIcon}
+              leadingIconClassName="text-white"
+            >
+              New branch
+            </Button>
+          }
+          parentEnvironment={parentEnvironment}
+        />
+      }
+    >
+      <Paragraph spacing variant="small">
+        Branches are a way to test new features in isolation before merging them into the main
+        environment.
+      </Paragraph>
+      <Paragraph spacing variant="small">
+        Branches are only available when using <V4Badge inline /> or above. Read our{" "}
+        <TextLink to={docsPath("upgrade-to-v4")}>v4 upgrade guide</TextLink> to learn more.
+      </Paragraph>
+    </InfoPanel>
+  );
+}
+
+export function SwitcherPanel({ title = "Switch to a deployed environment" }: { title?: string }) {
   const organization = useOrganization();
   const project = useProject();
   const environment = useEnvironment();
@@ -439,7 +558,7 @@ function SwitcherPanel() {
   return (
     <div className="flex items-center rounded-md border border-grid-bright bg-background-bright p-3">
       <Paragraph variant="small" className="grow">
-        Switch to a deployed environment
+        {title}
       </Paragraph>
       <EnvironmentSelector
         organization={organization}
diff --git a/apps/webapp/app/components/GitMetadata.tsx b/apps/webapp/app/components/GitMetadata.tsx
new file mode 100644
index 0000000000..1ec7d38dba
--- /dev/null
+++ b/apps/webapp/app/components/GitMetadata.tsx
@@ -0,0 +1,86 @@
+import { GitPullRequestIcon, GitCommitIcon, GitBranchIcon } from "lucide-react";
+import { type GitMetaLinks } from "~/presenters/v3/BranchesPresenter.server";
+import { LinkButton } from "./primitives/Buttons";
+import { SimpleTooltip } from "./primitives/Tooltip";
+
+export function GitMetadata({ git }: { git?: GitMetaLinks | null }) {
+  if (!git) return null;
+  return (
+    <>
+      {git.pullRequestUrl && git.pullRequestNumber && <GitMetadataPullRequest git={git} />}
+      {git.shortSha && <GitMetadataCommit git={git} />}
+      {git.branchUrl && <GitMetadataBranch git={git} />}
+    </>
+  );
+}
+
+export function GitMetadataBranch({
+  git,
+}: {
+  git: Pick<GitMetaLinks, "branchUrl" | "branchName">;
+}) {
+  return (
+    <SimpleTooltip
+      button={
+        <LinkButton
+          variant="minimal/small"
+          LeadingIcon={<GitBranchIcon className="size-4" />}
+          iconSpacing="gap-x-1"
+          to={git.branchUrl}
+          className="pl-1"
+        >
+          {git.branchName}
+        </LinkButton>
+      }
+      content="Jump to GitHub branch"
+    />
+  );
+}
+
+export function GitMetadataCommit({
+  git,
+}: {
+  git: Pick<GitMetaLinks, "commitUrl" | "shortSha" | "commitMessage">;
+}) {
+  return (
+    <SimpleTooltip
+      button={
+        <LinkButton
+          variant="minimal/small"
+          to={git.commitUrl}
+          LeadingIcon={<GitCommitIcon className="size-4" />}
+          iconSpacing="gap-x-1"
+          className="pl-1"
+        >
+          {`${git.shortSha} / ${git.commitMessage}`}
+        </LinkButton>
+      }
+      content="Jump to GitHub commit"
+    />
+  );
+}
+
+export function GitMetadataPullRequest({
+  git,
+}: {
+  git: Pick<GitMetaLinks, "pullRequestUrl" | "pullRequestNumber" | "pullRequestTitle">;
+}) {
+  if (!git.pullRequestUrl || !git.pullRequestNumber) return null;
+
+  return (
+    <SimpleTooltip
+      button={
+        <LinkButton
+          variant="minimal/small"
+          to={git.pullRequestUrl}
+          LeadingIcon={<GitPullRequestIcon className="size-4" />}
+          iconSpacing="gap-x-1"
+          className="pl-1"
+        >
+          #{git.pullRequestNumber} {git.pullRequestTitle}
+        </LinkButton>
+      }
+      content="Jump to GitHub pull request"
+    />
+  );
+}
diff --git a/apps/webapp/app/components/V4Badge.tsx b/apps/webapp/app/components/V4Badge.tsx
new file mode 100644
index 0000000000..c92baabac8
--- /dev/null
+++ b/apps/webapp/app/components/V4Badge.tsx
@@ -0,0 +1,26 @@
+import { cn } from "~/utils/cn";
+import { Badge } from "./primitives/Badge";
+import { SimpleTooltip } from "./primitives/Tooltip";
+
+export function V4Badge({ inline = false, className }: { inline?: boolean; className?: string }) {
+  return (
+    <SimpleTooltip
+      button={
+        <Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
+          V4
+        </Badge>
+      }
+      content="This feature is only available in V4 and above."
+      disableHoverableContent
+    />
+  );
+}
+
+export function V4Title({ children }: { children: React.ReactNode }) {
+  return (
+    <>
+      <span>{children}</span>
+      <V4Badge />
+    </>
+  );
+}
diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx
index 9d3d3bb8b0..3afa861cf3 100644
--- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx
+++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx
@@ -1,4 +1,5 @@
 import {
+  BranchEnvironmentIconSmall,
   DeployedEnvironmentIconSmall,
   DevEnvironmentIconSmall,
   ProdEnvironmentIconSmall,
@@ -6,7 +7,7 @@ import {
 import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
 import { cn } from "~/utils/cn";
 
-type Environment = Pick<RuntimeEnvironment, "type">;
+type Environment = Pick<RuntimeEnvironment, "type"> & { branchName?: string | null };
 
 export function EnvironmentIcon({
   environment,
@@ -15,6 +16,14 @@ export function EnvironmentIcon({
   environment: Environment;
   className?: string;
 }) {
+  if (environment.branchName) {
+    return (
+      <BranchEnvironmentIconSmall
+        className={cn(environmentTextClassName(environment), className)}
+      />
+    );
+  }
+
   switch (environment.type) {
     case "DEVELOPMENT":
       return (
@@ -39,13 +48,15 @@ export function EnvironmentIcon({
 export function EnvironmentCombo({
   environment,
   className,
+  iconClassName,
 }: {
   environment: Environment;
   className?: string;
+  iconClassName?: string;
 }) {
   return (
     <span className={cn("flex items-center gap-1.5 text-sm text-text-bright", className)}>
-      <EnvironmentIcon environment={environment} className="size-[1.125rem]" />
+      <EnvironmentIcon environment={environment} className={cn("size-4.5", iconClassName)} />
       <EnvironmentLabel environment={environment} />
     </span>
   );
@@ -60,12 +71,16 @@ export function EnvironmentLabel({
 }) {
   return (
     <span className={cn(environmentTextClassName(environment), className)}>
-      {environmentFullTitle(environment)}
+      {environment.branchName ? environment.branchName : environmentFullTitle(environment)}
     </span>
   );
 }
 
 export function environmentTitle(environment: Environment, username?: string) {
+  if (environment.branchName) {
+    return environment.branchName;
+  }
+
   switch (environment.type) {
     case "PRODUCTION":
       return "Prod";
@@ -79,6 +94,10 @@ export function environmentTitle(environment: Environment, username?: string) {
 }
 
 export function environmentFullTitle(environment: Environment) {
+  if (environment.branchName) {
+    return environment.branchName;
+  }
+
   switch (environment.type) {
     case "PRODUCTION":
       return "Production";
diff --git a/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx
index c041683e04..130c1c4acc 100644
--- a/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx
+++ b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx
@@ -28,13 +28,7 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
   return (
     <Dialog open={open} onOpenChange={setOpen}>
       <DialogTrigger asChild>
-        <Button
-          variant="small-menu-item"
-          fullWidth
-          textAlignLeft
-          leadingIconClassName="text-success"
-          LeadingIcon={ArrowPathIcon}
-        >
+        <Button variant="minimal/small" textAlignLeft LeadingIcon={ArrowPathIcon}>
           Regenerate…
         </Button>
       </DialogTrigger>
diff --git a/apps/webapp/app/components/navigation/EnvironmentBanner.tsx b/apps/webapp/app/components/navigation/EnvironmentBanner.tsx
new file mode 100644
index 0000000000..2a34b9e434
--- /dev/null
+++ b/apps/webapp/app/components/navigation/EnvironmentBanner.tsx
@@ -0,0 +1,83 @@
+import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
+import { useLocation } from "@remix-run/react";
+import { AnimatePresence, motion } from "framer-motion";
+import { useEnvironment, useOptionalEnvironment } from "~/hooks/useEnvironment";
+import { useOptionalOrganization, useOrganization } from "~/hooks/useOrganizations";
+import { useOptionalProject, useProject } from "~/hooks/useProject";
+import { v3QueuesPath } from "~/utils/pathBuilder";
+import { environmentFullTitle } from "../environments/EnvironmentLabel";
+import { LinkButton } from "../primitives/Buttons";
+import { Icon } from "../primitives/Icon";
+import { Paragraph } from "../primitives/Paragraph";
+
+export function EnvironmentBanner() {
+  const organization = useOptionalOrganization();
+  const project = useOptionalProject();
+  const environment = useOptionalEnvironment();
+
+  const isPaused = organization && project && environment && environment.paused;
+  const isArchived = organization && project && environment && environment.archivedAt;
+
+  return (
+    <AnimatePresence initial={false}>
+      {isArchived ? <ArchivedBranchBanner /> : isPaused ? <PausedBanner /> : null}
+    </AnimatePresence>
+  );
+}
+
+function PausedBanner() {
+  const organization = useOrganization();
+  const project = useProject();
+  const environment = useEnvironment();
+
+  const location = useLocation();
+  const hideButton = location.pathname.endsWith("/queues");
+
+  return (
+    <motion.div
+      className="flex h-10 items-center justify-between overflow-hidden border-y border-amber-400/20 bg-warning/20 py-0 pl-3 pr-2"
+      initial={{ opacity: 0, height: 0 }}
+      animate={{ opacity: 1, height: "2.5rem" }}
+      exit={{ opacity: 0, height: 0 }}
+    >
+      <div className="flex items-center gap-2">
+        <Icon icon={ExclamationCircleIcon} className="h-5 w-5 text-amber-400" />
+        <Paragraph variant="small" className="text-amber-200">
+          {environmentFullTitle(environment)} environment paused. No new runs will be dequeued and
+          executed.
+        </Paragraph>
+      </div>
+      {hideButton ? null : (
+        <div>
+          <LinkButton
+            variant="tertiary/small"
+            to={v3QueuesPath(organization, project, environment)}
+          >
+            Manage
+          </LinkButton>
+        </div>
+      )}
+    </motion.div>
+  );
+}
+
+function ArchivedBranchBanner() {
+  const environment = useEnvironment();
+
+  return (
+    <motion.div
+      className="flex h-10 items-center justify-between overflow-hidden border-y border-amber-400/20 bg-warning/20 py-0 pl-3 pr-2"
+      initial={{ opacity: 0, height: 0 }}
+      animate={{ opacity: 1, height: "2.5rem" }}
+      exit={{ opacity: 0, height: 0 }}
+    >
+      <div className="flex items-center gap-2">
+        <Icon icon={ExclamationCircleIcon} className="h-5 w-5 text-amber-400" />
+        <Paragraph variant="small" className="text-amber-200">
+          "{environment.branchName}" branch is archived and is read-only. No new runs will be
+          dequeued and executed.
+        </Paragraph>
+      </div>
+    </motion.div>
+  );
+}
diff --git a/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx b/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx
deleted file mode 100644
index bc0501a210..0000000000
--- a/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
-import { useLocation } from "@remix-run/react";
-import { AnimatePresence, motion } from "framer-motion";
-import { useOptionalEnvironment } from "~/hooks/useEnvironment";
-import { useOptionalOrganization } from "~/hooks/useOrganizations";
-import { useOptionalProject } from "~/hooks/useProject";
-import { v3QueuesPath } from "~/utils/pathBuilder";
-import { environmentFullTitle } from "../environments/EnvironmentLabel";
-import { LinkButton } from "../primitives/Buttons";
-import { Icon } from "../primitives/Icon";
-import { Paragraph } from "../primitives/Paragraph";
-
-export function EnvironmentPausedBanner() {
-  const organization = useOptionalOrganization();
-  const project = useOptionalProject();
-  const environment = useOptionalEnvironment();
-  const location = useLocation();
-
-  const hideButton = location.pathname.endsWith("/queues");
-
-  return (
-    <AnimatePresence initial={false}>
-      {organization && project && environment && environment.paused ? (
-        <motion.div
-          className="flex h-10 items-center justify-between overflow-hidden border-y border-amber-400/20 bg-warning/20 py-0 pl-3 pr-2"
-          initial={{ opacity: 0, height: 0 }}
-          animate={{ opacity: 1, height: "2.5rem" }}
-          exit={{ opacity: 0, height: 0 }}
-        >
-          <div className="flex items-center gap-2">
-            <Icon icon={ExclamationCircleIcon} className="h-5 w-5 text-amber-400" />
-            <Paragraph variant="small" className="text-amber-200">
-              {environmentFullTitle(environment)} environment paused. No new runs will be dequeued
-              and executed.
-            </Paragraph>
-          </div>
-          {hideButton ? null : (
-            <div>
-              <LinkButton
-                variant="tertiary/small"
-                to={v3QueuesPath(organization, project, environment)}
-              >
-                Manage
-              </LinkButton>
-            </div>
-          )}
-        </motion.div>
-      ) : null}
-    </AnimatePresence>
-  );
-}
-
-export function useShowEnvironmentPausedBanner() {
-  const environment = useOptionalEnvironment();
-  const shouldShow = environment?.paused ?? false;
-  return { shouldShow };
-}
diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
index a56d0ec8ab..def3996f85 100644
--- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
+++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx
@@ -1,19 +1,30 @@
+import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
 import { useNavigation } from "@remix-run/react";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
+import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
+import { useEnvironment } from "~/hooks/useEnvironment";
 import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher";
 import { useFeatures } from "~/hooks/useFeatures";
-import { type MatchedOrganization } from "~/hooks/useOrganizations";
+import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
 import { cn } from "~/utils/cn";
-import { v3BillingPath } from "~/utils/pathBuilder";
+import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
 import { EnvironmentCombo } from "../environments/EnvironmentLabel";
+import { ButtonContent } from "../primitives/Buttons";
+import { Header2 } from "../primitives/Headers";
+import { Paragraph } from "../primitives/Paragraph";
 import {
   Popover,
   PopoverArrowTrigger,
   PopoverContent,
   PopoverMenuItem,
   PopoverSectionHeader,
+  PopoverTrigger,
 } from "../primitives/Popover";
+import { TextLink } from "../primitives/TextLink";
+import { V4Badge } from "../V4Badge";
 import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
+import { Badge } from "../primitives/Badge";
 
 export function EnvironmentSelector({
   organization,
@@ -53,14 +64,36 @@ export function EnvironmentSelector({
         style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
       >
         <div className="flex flex-col gap-1 p-1">
-          {project.environments.map((env) => (
-            <PopoverMenuItem
-              key={env.id}
-              to={urlForEnvironment(env)}
-              title={<EnvironmentCombo environment={env} className="mx-auto grow text-2sm" />}
-              isSelected={env.id === environment.id}
-            />
-          ))}
+          {project.environments
+            .filter((env) => env.branchName === null)
+            .map((env) => {
+              switch (env.isBranchableEnvironment) {
+                case true: {
+                  const branchEnvironments = project.environments.filter(
+                    (e) => e.parentEnvironmentId === env.id
+                  );
+                  return (
+                    <Branches
+                      key={env.id}
+                      parentEnvironment={env}
+                      branchEnvironments={branchEnvironments}
+                      currentEnvironment={environment}
+                    />
+                  );
+                }
+                case false:
+                  return (
+                    <PopoverMenuItem
+                      key={env.id}
+                      to={urlForEnvironment(env)}
+                      title={
+                        <EnvironmentCombo environment={env} className="mx-auto grow text-2sm" />
+                      }
+                      isSelected={env.id === environment.id}
+                    />
+                  );
+              }
+            })}
         </div>
         {!hasStaging && isManagedCloud && (
           <>
@@ -80,6 +113,20 @@ export function EnvironmentSelector({
                 }
                 isSelected={false}
               />
+              <PopoverMenuItem
+                key="preview"
+                to={v3BillingPath(
+                  organization,
+                  "Upgrade to unlock Preview environments for your projects."
+                )}
+                title={
+                  <div className="flex w-full items-center justify-between">
+                    <EnvironmentCombo environment={{ type: "PREVIEW" }} className="text-2sm" />
+                    <span className="text-indigo-500">Upgrade</span>
+                  </div>
+                }
+                isSelected={false}
+              />
             </div>
           </>
         )}
@@ -87,3 +134,147 @@ export function EnvironmentSelector({
     </Popover>
   );
 }
+
+function Branches({
+  parentEnvironment,
+  branchEnvironments,
+  currentEnvironment,
+}: {
+  parentEnvironment: SideMenuEnvironment;
+  branchEnvironments: SideMenuEnvironment[];
+  currentEnvironment: SideMenuEnvironment;
+}) {
+  const organization = useOrganization();
+  const project = useProject();
+  const environment = useEnvironment();
+  const { urlForEnvironment } = useEnvironmentSwitcher();
+  const navigation = useNavigation();
+  const [isMenuOpen, setMenuOpen] = useState(false);
+  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
+
+  // Clear timeout on unmount
+  useEffect(() => {
+    return () => {
+      if (timeoutRef.current) {
+        clearTimeout(timeoutRef.current);
+      }
+    };
+  }, []);
+
+  useEffect(() => {
+    setMenuOpen(false);
+  }, [navigation.location?.pathname]);
+
+  const handleMouseEnter = () => {
+    if (timeoutRef.current) {
+      clearTimeout(timeoutRef.current);
+    }
+    setMenuOpen(true);
+  };
+
+  const handleMouseLeave = () => {
+    // Small delay before closing to allow moving to the content
+    timeoutRef.current = setTimeout(() => {
+      setMenuOpen(false);
+    }, 150);
+  };
+
+  const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null);
+  const state =
+    branchEnvironments.length === 0
+      ? "no-branches"
+      : activeBranches.length === 0
+      ? "no-active-branches"
+      : "has-branches";
+
+  const currentBranchIsArchived = environment.archivedAt !== null;
+
+  return (
+    <Popover onOpenChange={(open) => setMenuOpen(open)} open={isMenuOpen}>
+      <div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className="flex">
+        <PopoverTrigger className="w-full justify-between overflow-hidden focus-custom">
+          <ButtonContent
+            variant="small-menu-item"
+            className="hover:bg-charcoal-750"
+            TrailingIcon={ChevronRightIcon}
+            trailingIconClassName="text-text-dimmed"
+            textAlignLeft
+            fullWidth
+          >
+            <EnvironmentCombo environment={parentEnvironment} className="mx-auto grow text-2sm" />
+          </ButtonContent>
+        </PopoverTrigger>
+        <PopoverContent
+          className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
+          align="start"
+          style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
+          side="right"
+          alignOffset={0}
+          sideOffset={-4}
+          onMouseEnter={handleMouseEnter}
+          onMouseLeave={handleMouseLeave}
+        >
+          <div className="flex flex-col gap-1 p-1">
+            {currentBranchIsArchived && (
+              <PopoverMenuItem
+                key={environment.id}
+                to={urlForEnvironment(environment)}
+                title={
+                  <>
+                    <span className="block w-full text-preview">{environment.branchName}</span>
+                    <Badge variant="extra-small">Archived</Badge>
+                  </>
+                }
+                icon={<BranchEnvironmentIconSmall className="size-4 shrink-0 text-preview" />}
+                isSelected={environment.id === currentEnvironment.id}
+              />
+            )}
+            {state === "has-branches" ? (
+              <>
+                {branchEnvironments
+                  .filter((env) => env.archivedAt === null)
+                  .map((env) => (
+                    <PopoverMenuItem
+                      key={env.id}
+                      to={urlForEnvironment(env)}
+                      title={<span className="block w-full text-preview">{env.branchName}</span>}
+                      icon={<BranchEnvironmentIconSmall className="size-4 shrink-0 text-preview" />}
+                      isSelected={env.id === currentEnvironment.id}
+                    />
+                  ))}
+              </>
+            ) : state === "no-branches" ? (
+              <div className="flex max-w-sm flex-col gap-1 p-2">
+                <div className="flex items-center gap-1">
+                  <BranchEnvironmentIconSmall className="size-4 text-preview" />
+                  <Header2>Create your first branch</Header2>
+                </div>
+                <Paragraph spacing variant="small">
+                  Branches are a way to test new features in isolation before merging them into the
+                  main environment.
+                </Paragraph>
+                <Paragraph variant="small">
+                  Branches are only available when using <V4Badge inline /> or above. Read our{" "}
+                  <TextLink to={docsPath("upgrade-to-v4")}>v4 upgrade guide</TextLink> to learn
+                  more.
+                </Paragraph>
+              </div>
+            ) : (
+              <div className="flex max-w-sm flex-col gap-1 p-2">
+                <Paragraph variant="extra-small">All branches are archived.</Paragraph>
+              </div>
+            )}
+          </div>
+          <div className="border-t border-charcoal-700 p-1">
+            <PopoverMenuItem
+              to={branchesPath(organization, project, environment)}
+              title="Manage branches"
+              icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
+              leadingIconClassName="text-text-dimmed"
+            />
+          </div>
+        </PopoverContent>
+      </div>
+    </Popover>
+  );
+}
diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
index a788e74233..93f2843de5 100644
--- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
+++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx
@@ -21,7 +21,8 @@ import { Icon } from "../primitives/Icon";
 import { Paragraph } from "../primitives/Paragraph";
 import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover";
 import { StepNumber } from "../primitives/StepNumber";
-import { MenuCount, SideMenuItem } from "./SideMenuItem";
+import { SideMenuItem } from "./SideMenuItem";
+import { Badge } from "../primitives/Badge";
 
 export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: boolean }) {
   const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
@@ -109,7 +110,9 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
                     >
                       <div className="flex w-full items-center justify-between">
                         <span className="text-text-bright">Join our Slack…</span>
-                        <MenuCount count="PRO" />
+                        <Badge variant="extra-small" className="uppercase">
+                          Pro
+                        </Badge>
                       </div>
                     </Button>
                   </DialogTrigger>
diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
index f4d67bfac4..694ac87560 100644
--- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
+++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
@@ -21,6 +21,7 @@ import { SideMenuHeader } from "./SideMenuHeader";
 import { SideMenuItem } from "./SideMenuItem";
 import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
 import { Paragraph } from "../primitives/Paragraph";
+import { Badge } from "../primitives/Badge";
 
 export function OrganizationSettingsSideMenu({
   organization,
@@ -69,9 +70,9 @@ export function OrganizationSettingsSideMenu({
               to={v3BillingPath(organization)}
               data-action="billing"
               badge={
-                currentPlan?.v3Subscription?.isPaying
-                  ? currentPlan?.v3Subscription?.plan?.title
-                  : undefined
+                currentPlan?.v3Subscription?.isPaying ? (
+                  <Badge variant="extra-small">{currentPlan?.v3Subscription?.plan?.title}</Badge>
+                ) : undefined
               }
             />
           )}
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx
index 4dab36078e..ca79b07d64 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -38,6 +38,7 @@ import { cn } from "~/utils/cn";
 import {
   accountPath,
   adminPath,
+  branchesPath,
   logoutPath,
   newOrganizationPath,
   newProjectPath,
@@ -84,6 +85,8 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
 import { SideMenuHeader } from "./SideMenuHeader";
 import { SideMenuItem } from "./SideMenuItem";
 import { SideMenuSection } from "./SideMenuSection";
+import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
+import { V4Badge } from "../V4Badge";
 
 type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
 export type SideMenuProject = Pick<
@@ -263,6 +266,7 @@ export function SideMenu({
               icon={WaitpointTokenIcon}
               activeIconColor="text-sky-500"
               to={v3WaitpointTokensPath(organization, project, environment)}
+              badge={<V4Badge />}
             />
           </SideMenuSection>
 
@@ -288,6 +292,14 @@ export function SideMenu({
               to={v3ProjectAlertsPath(organization, project, environment)}
               data-action="alerts"
             />
+            <SideMenuItem
+              name="Preview branches"
+              icon={BranchEnvironmentIconSmall}
+              activeIconColor="text-preview"
+              to={branchesPath(organization, project, environment)}
+              data-action="preview-branches"
+              badge={<V4Badge />}
+            />
             <SideMenuItem
               name="Project settings"
               icon={Cog8ToothIcon}
diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx
index 3cff04afda..1f89368829 100644
--- a/apps/webapp/app/components/navigation/SideMenuItem.tsx
+++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx
@@ -1,4 +1,4 @@
-import { type AnchorHTMLAttributes } from "react";
+import { type AnchorHTMLAttributes, type ReactNode } from "react";
 import { usePathName } from "~/hooks/usePathName";
 import { cn } from "~/utils/cn";
 import { LinkButton } from "../primitives/Buttons";
@@ -22,7 +22,7 @@ export function SideMenuItem({
   trailingIconClassName?: string;
   name: string;
   to: string;
-  badge?: string;
+  badge?: ReactNode;
   target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
 }) {
   const pathName = usePathName();
@@ -46,18 +46,8 @@ export function SideMenuItem({
     >
       <div className="flex w-full items-center justify-between">
         {name}
-        <div className="flex items-center gap-1">
-          {badge !== undefined && <MenuCount count={badge} />}
-        </div>
+        <div className="flex items-center gap-1">{badge !== undefined && badge}</div>
       </div>
     </LinkButton>
   );
 }
-
-export function MenuCount({ count }: { count: number | string }) {
-  return (
-    <div className="rounded border border-charcoal-650 bg-background-dimmed/70 px-1.5 py-1 text-xxs uppercase tracking-wider text-text-dimmed">
-      {count}
-    </div>
-  );
-}
diff --git a/apps/webapp/app/components/primitives/Accordion.tsx b/apps/webapp/app/components/primitives/Accordion.tsx
new file mode 100644
index 0000000000..beb61b2012
--- /dev/null
+++ b/apps/webapp/app/components/primitives/Accordion.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import * as React from "react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDown } from "lucide-react";
+import { cn } from "~/utils/cn";
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+  React.ElementRef<typeof AccordionPrimitive.Item>,
+  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
+>(({ className, ...props }, ref) => (
+  <AccordionPrimitive.Item
+    ref={ref}
+    className={cn("group rounded border border-grid-bright  transition-colors", className)}
+    {...props}
+  />
+));
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+  React.ElementRef<typeof AccordionPrimitive.Trigger>,
+  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+  <AccordionPrimitive.Header className="flex">
+    <AccordionPrimitive.Trigger
+      ref={ref}
+      className={cn(
+        "flex flex-1 items-center justify-between px-3 py-2 text-sm text-text-bright transition-all group-hover:bg-grid-bright [&[data-state=open]>svg]:rotate-180",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
+    </AccordionPrimitive.Trigger>
+  </AccordionPrimitive.Header>
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+  React.ElementRef<typeof AccordionPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+  <AccordionPrimitive.Content
+    ref={ref}
+    className="overflow-hidden border-t border-grid-bright px-3 text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
+    {...props}
+  >
+    <div className={cn("py-3", className)}>{children}</div>
+  </AccordionPrimitive.Content>
+));
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx
index ed94d46f95..bafd772b0a 100644
--- a/apps/webapp/app/components/primitives/Buttons.tsx
+++ b/apps/webapp/app/components/primitives/Buttons.tsx
@@ -299,9 +299,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
     if (props.shortcut) {
       useShortcutKeys({
         shortcut: props.shortcut,
-        action: () => {
+        action: (e) => {
           if (innerRef.current) {
             innerRef.current.click();
+            e.preventDefault();
+            e.stopPropagation();
           }
         },
         disabled,
diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx
index 767aa3fab4..7855e241e3 100644
--- a/apps/webapp/app/components/primitives/PageHeader.tsx
+++ b/apps/webapp/app/components/primitives/PageHeader.tsx
@@ -5,7 +5,7 @@ import { UpgradePrompt, useShowUpgradePrompt } from "../billing/UpgradePrompt";
 import { BreadcrumbIcon } from "./BreadcrumbIcon";
 import { Header2 } from "./Headers";
 import { LoadingBarDivider } from "./LoadingBarDivider";
-import { EnvironmentPausedBanner } from "../navigation/EnvironmentPausedBanner";
+import { EnvironmentBanner } from "../navigation/EnvironmentBanner";
 
 type WithChildren = {
   children: React.ReactNode;
@@ -25,11 +25,7 @@ export function NavBar({ children }: WithChildren) {
         <div className="flex w-full items-center justify-between pl-3 pr-2">{children}</div>
         <LoadingBarDivider isLoading={isLoading} />
       </div>
-      {showUpgradePrompt.shouldShow && organization ? (
-        <UpgradePrompt />
-      ) : (
-        <EnvironmentPausedBanner />
-      )}
+      {showUpgradePrompt.shouldShow && organization ? <UpgradePrompt /> : <EnvironmentBanner />}
     </div>
   );
 }
diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx
index ce7d72eb0b..22156927f3 100644
--- a/apps/webapp/app/components/primitives/Popover.tsx
+++ b/apps/webapp/app/components/primitives/Popover.tsx
@@ -59,6 +59,7 @@ function PopoverMenuItem({
   isSelected,
   variant = { variant: "small-menu-item" },
   leadingIconClassName,
+  className,
 }: {
   to: string;
   icon?: RenderIcon;
@@ -66,6 +67,7 @@ function PopoverMenuItem({
   isSelected?: boolean;
   variant?: ButtonContentPropsType;
   leadingIconClassName?: string;
+  className?: string;
 }) {
   return (
     <LinkButton
@@ -78,7 +80,8 @@ function PopoverMenuItem({
       TrailingIcon={isSelected ? CheckIcon : undefined}
       className={cn(
         "group-hover:bg-charcoal-700",
-        isSelected ? "bg-charcoal-750 group-hover:bg-charcoal-600/50" : undefined
+        isSelected ? "bg-charcoal-750 group-hover:bg-charcoal-600/50" : undefined,
+        className
       )}
     >
       {title}
diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx
index db889586cc..207217504d 100644
--- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx
+++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx
@@ -131,6 +131,12 @@ function ReplayForm({
           dropdownIcon
           variant="tertiary/medium"
           className="w-fit pl-1"
+          filter={{
+            keys: [
+              (item) => item.type.replace(/\//g, " ").replace(/_/g, " "),
+              (item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "",
+            ],
+          }}
           text={(value) => {
             const env = environments.find((env) => env.id === value)!;
             return (
diff --git a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts
index 9c0d02a7b6..5c9aa2059b 100644
--- a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts
+++ b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts
@@ -1,7 +1,5 @@
 import { type Path, useMatches } from "@remix-run/react";
-import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server";
-import { useEnvironment } from "./useEnvironment";
-import { useEnvironments } from "./useEnvironments";
+import { type RuntimeEnvironment } from "@trigger.dev/database";
 import { useOptimisticLocation } from "./useOptimisticLocation";
 
 /**
@@ -12,7 +10,7 @@ export function useEnvironmentSwitcher() {
   const matches = useMatches();
   const location = useOptimisticLocation();
 
-  const urlForEnvironment = (newEnvironment: MinimumEnvironment) => {
+  const urlForEnvironment = (newEnvironment: Pick<RuntimeEnvironment, "id" | "slug">) => {
     return routeForEnvironmentSwitch({
       location,
       matchId: matches[matches.length - 1].id,
@@ -86,7 +84,8 @@ export function routeForEnvironmentSwitch({
  * Replace the /env/<slug>/ in the path so it's /env/<environmentSlug>
  */
 function replaceEnvInPath(path: string, environmentSlug: string) {
-  return path.replace(/env\/([a-z0-9-]+)/, `env/${environmentSlug}`);
+  //allow anything except /
+  return path.replace(/env\/([^/]+)/, `env/${environmentSlug}`);
 }
 
 function fullPath(location: Path) {
diff --git a/apps/webapp/app/models/api-key.server.ts b/apps/webapp/app/models/api-key.server.ts
index c70fd7510f..86609cc01d 100644
--- a/apps/webapp/app/models/api-key.server.ts
+++ b/apps/webapp/app/models/api-key.server.ts
@@ -84,7 +84,7 @@ export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
   return `pk_${envSlug(envType)}_${apiKeyId(20)}`;
 }
 
-export type EnvSlug = "dev" | "stg" | "prod" | "prev";
+export type EnvSlug = "dev" | "stg" | "prod" | "preview";
 
 export function envSlug(environmentType: RuntimeEnvironment["type"]): EnvSlug {
   switch (environmentType) {
@@ -98,11 +98,11 @@ export function envSlug(environmentType: RuntimeEnvironment["type"]): EnvSlug {
       return "stg";
     }
     case "PREVIEW": {
-      return "prev";
+      return "preview";
     }
   }
 }
 
 export function isEnvSlug(maybeSlug: string): maybeSlug is EnvSlug {
-  return ["dev", "stg", "prod", "prev"].includes(maybeSlug);
+  return ["dev", "stg", "prod", "preview"].includes(maybeSlug);
 }
diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts
index c2e05248be..86ae5d371d 100644
--- a/apps/webapp/app/models/member.server.ts
+++ b/apps/webapp/app/models/member.server.ts
@@ -174,7 +174,14 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
 
     // 3. Create an environment for each project
     for (const project of invite.organization.projects) {
-      await createEnvironment(invite.organization, project, "DEVELOPMENT", member, tx);
+      await createEnvironment({
+        organization: invite.organization,
+        project,
+        type: "DEVELOPMENT",
+        isBranchableEnvironment: false,
+        member,
+        prismaClient: tx,
+      });
     }
 
     // 4. Check for other invites
diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts
index b9cd102d79..61dc99ecd7 100644
--- a/apps/webapp/app/models/organization.server.ts
+++ b/apps/webapp/app/models/organization.server.ts
@@ -6,12 +6,12 @@ import type {
   User,
 } from "@trigger.dev/database";
 import { customAlphabet } from "nanoid";
-import slug from "slug";
-import { prisma, PrismaClientOrTransaction } from "~/db.server";
 import { generate } from "random-words";
-import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
+import slug from "slug";
+import { prisma, type PrismaClientOrTransaction } from "~/db.server";
 import { env } from "~/env.server";
 import { featuresForUrl } from "~/features.server";
+import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
 
 export type { Organization };
 
@@ -76,13 +76,21 @@ export async function createOrganization(
   return { ...organization };
 }
 
-export async function createEnvironment(
-  organization: Pick<Organization, "id" | "maximumConcurrencyLimit">,
-  project: Pick<Project, "id">,
-  type: RuntimeEnvironment["type"],
-  member?: OrgMember,
-  prismaClient: PrismaClientOrTransaction = prisma
-) {
+export async function createEnvironment({
+  organization,
+  project,
+  type,
+  isBranchableEnvironment = false,
+  member,
+  prismaClient = prisma,
+}: {
+  organization: Pick<Organization, "id" | "maximumConcurrencyLimit">;
+  project: Pick<Project, "id">;
+  type: RuntimeEnvironment["type"];
+  isBranchableEnvironment?: boolean;
+  member?: OrgMember;
+  prismaClient?: PrismaClientOrTransaction;
+}) {
   const slug = envSlug(type);
   const apiKey = createApiKeyForEnv(type);
   const pkApiKey = createPkApiKeyForEnv(type);
@@ -108,6 +116,7 @@ export async function createEnvironment(
       },
       orgMember: member ? { connect: { id: member.id } } : undefined,
       type,
+      isBranchableEnvironment,
     },
   });
 }
diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts
index 581c08ed7f..3e4dc2cf8d 100644
--- a/apps/webapp/app/models/project.server.ts
+++ b/apps/webapp/app/models/project.server.ts
@@ -88,10 +88,21 @@ export async function createProject(
   });
 
   // Create the dev and prod environments
-  await createEnvironment(organization, project, "PRODUCTION");
+  await createEnvironment({
+    organization,
+    project,
+    type: "PRODUCTION",
+    isBranchableEnvironment: false,
+  });
 
   for (const member of project.organization.members) {
-    await createEnvironment(organization, project, "DEVELOPMENT", member);
+    await createEnvironment({
+      organization,
+      project,
+      type: "DEVELOPMENT",
+      isBranchableEnvironment: false,
+      member,
+    });
   }
 
   await projectCreated(organization, project);
diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts
index d9f616f6cf..80820fa910 100644
--- a/apps/webapp/app/models/runtimeEnvironment.server.ts
+++ b/apps/webapp/app/models/runtimeEnvironment.server.ts
@@ -1,12 +1,15 @@
 import type { AuthenticatedEnvironment } from "@internal/run-engine";
 import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@trigger.dev/database";
 import { prisma } from "~/db.server";
+import { logger } from "~/services/logger.server";
 import { getUsername } from "~/utils/username";
+import { sanitizeBranchName } from "~/v3/gitBranch";
 
 export type { RuntimeEnvironment };
 
 export async function findEnvironmentByApiKey(
-  apiKey: string
+  apiKey: string,
+  branchName: string | undefined
 ): Promise<AuthenticatedEnvironment | null> {
   const environment = await prisma.runtimeEnvironment.findFirst({
     where: {
@@ -16,6 +19,14 @@ export async function findEnvironmentByApiKey(
       project: true,
       organization: true,
       orgMember: true,
+      childEnvironments: branchName
+        ? {
+            where: {
+              branchName: sanitizeBranchName(branchName),
+              archivedAt: null,
+            },
+          }
+        : undefined,
     },
   });
 
@@ -24,11 +35,37 @@ export async function findEnvironmentByApiKey(
     return null;
   }
 
+  if (environment.type === "PREVIEW") {
+    if (!branchName) {
+      logger.error("findEnvironmentByApiKey(): Preview env with no branch name provided", {
+        environmentId: environment.id,
+      });
+      return null;
+    }
+
+    const childEnvironment = environment?.childEnvironments.at(0);
+
+    if (childEnvironment) {
+      return {
+        ...childEnvironment,
+        apiKey: environment.apiKey,
+        orgMember: environment.orgMember,
+        organization: environment.organization,
+        project: environment.project,
+      };
+    }
+
+    //A branch was specified but no child environment was found
+    return null;
+  }
+
   return environment;
 }
 
+/** @deprecated We don't use public api keys anymore */
 export async function findEnvironmentByPublicApiKey(
-  apiKey: string
+  apiKey: string,
+  branchName: string | undefined
 ): Promise<AuthenticatedEnvironment | null> {
   const environment = await prisma.runtimeEnvironment.findFirst({
     where: {
@@ -49,7 +86,9 @@ export async function findEnvironmentByPublicApiKey(
   return environment;
 }
 
-export async function findEnvironmentById(id: string): Promise<AuthenticatedEnvironment | null> {
+export async function findEnvironmentById(
+  id: string
+): Promise<(AuthenticatedEnvironment & { parentEnvironment: { apiKey: string } | null }) | null> {
   const environment = await prisma.runtimeEnvironment.findFirst({
     where: {
       id,
@@ -58,6 +97,11 @@ export async function findEnvironmentById(id: string): Promise<AuthenticatedEnvi
       project: true,
       organization: true,
       orgMember: true,
+      parentEnvironment: {
+        select: {
+          apiKey: true,
+        },
+      },
     },
   });
 
diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts
index 415a371e84..5949aebc5d 100644
--- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts
+++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts
@@ -1,4 +1,4 @@
-import { type PrismaClient } from "@trigger.dev/database";
+import { RuntimeEnvironment, type PrismaClient } from "@trigger.dev/database";
 import { redirect } from "remix-typedjson";
 import { prisma } from "~/db.server";
 import { logger } from "~/services/logger.server";
@@ -76,6 +76,10 @@ export class OrganizationsPresenter {
             type: true,
             slug: true,
             paused: true,
+            isBranchableEnvironment: true,
+            branchName: true,
+            parentEnvironmentId: true,
+            archivedAt: true,
             orgMember: {
               select: {
                 userId: true,
@@ -172,7 +176,21 @@ export class OrganizationsPresenter {
     user: UserFromSession;
     projectId: string;
     environmentSlug: string | undefined;
-    environments: MinimumEnvironment[];
+    environments: (Pick<
+      RuntimeEnvironment,
+      | "id"
+      | "slug"
+      | "type"
+      | "branchName"
+      | "paused"
+      | "parentEnvironmentId"
+      | "isBranchableEnvironment"
+      | "archivedAt"
+    > & {
+      orgMember: null | {
+        userId: string | undefined;
+      };
+    })[];
   }) {
     if (environmentSlug) {
       const env = environments.find((e) => e.slug === environmentSlug);
diff --git a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts
index e269f3fc2c..1ff560586a 100644
--- a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts
@@ -1,7 +1,7 @@
+import { type RuntimeEnvironment } from "@trigger.dev/database";
 import { type PrismaClient, prisma } from "~/db.server";
 import { type Project } from "~/models/project.server";
 import { type User } from "~/models/user.server";
-import { sortEnvironments } from "~/utils/environmentSort";
 
 export class ApiKeysPresenter {
   #prismaClient: PrismaClient;
@@ -10,12 +10,19 @@ export class ApiKeysPresenter {
     this.#prismaClient = prismaClient;
   }
 
-  public async call({ userId, projectSlug }: { userId: User["id"]; projectSlug: Project["slug"] }) {
-    const environments = await this.#prismaClient.runtimeEnvironment.findMany({
+  public async call({
+    userId,
+    projectSlug,
+    environmentSlug,
+  }: {
+    userId: User["id"];
+    projectSlug: Project["slug"];
+    environmentSlug: RuntimeEnvironment["slug"];
+  }) {
+    const environment = await this.#prismaClient.runtimeEnvironment.findFirst({
       select: {
         id: true,
         apiKey: true,
-        pkApiKey: true,
         type: true,
         slug: true,
         updatedAt: true,
@@ -24,13 +31,11 @@ export class ApiKeysPresenter {
             userId: true,
           },
         },
-        backgroundWorkers: {
+        branchName: true,
+        parentEnvironment: {
           select: {
-            version: true,
-          },
-          take: 1,
-          orderBy: {
-            version: "desc",
+            id: true,
+            apiKey: true,
           },
         },
       },
@@ -45,30 +50,25 @@ export class ApiKeysPresenter {
             },
           },
         },
+        slug: environmentSlug,
+        orgMember:
+          environmentSlug === "dev"
+            ? {
+                userId,
+              }
+            : undefined,
       },
     });
 
-    //filter out environments the only development ones belong to the current user
-    const filtered = environments.filter((environment) => {
-      if (environment.type === "DEVELOPMENT") {
-        return environment.orgMember?.userId === userId;
-      }
-      return true;
-    });
+    if (!environment) {
+      throw new Error("Environment not found");
+    }
 
     return {
-      environments: sortEnvironments(
-        filtered.map((environment) => ({
-          id: environment.id,
-          apiKey: environment.apiKey,
-          pkApiKey: environment.pkApiKey,
-          type: environment.type,
-          slug: environment.slug,
-          updatedAt: environment.updatedAt,
-          latestVersion: environment.backgroundWorkers.at(0)?.version,
-        }))
-      ),
-      hasStaging: environments.some((environment) => environment.type === "STAGING"),
+      environment: {
+        ...environment,
+        apiKey: environment?.parentEnvironment?.apiKey ?? environment?.apiKey,
+      },
     };
   }
 }
diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts
new file mode 100644
index 0000000000..9951fe4b40
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts
@@ -0,0 +1,243 @@
+import { GitMeta } from "@trigger.dev/core/v3";
+import { type z } from "zod";
+import { Prisma, type PrismaClient, prisma } from "~/db.server";
+import { type Project } from "~/models/project.server";
+import { type User } from "~/models/user.server";
+import { type BranchesOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
+import { checkBranchLimit } from "~/services/upsertBranch.server";
+
+type Result = Awaited<ReturnType<BranchesPresenter["call"]>>;
+export type Branch = Result["branches"][number];
+
+const BRANCHES_PER_PAGE = 25;
+
+type Options = z.infer<typeof BranchesOptions>;
+
+export type GitMetaLinks = {
+  /** The cleaned repository URL without any username/password */
+  repositoryUrl: string;
+  /** The branch name */
+  branchName: string;
+  /** Link to the specific branch */
+  branchUrl: string;
+  /** Link to the specific commit */
+  commitUrl: string;
+  /** Link to the pull request (if available) */
+  pullRequestUrl?: string;
+  /** The pull request number (if available) */
+  pullRequestNumber?: number;
+  /** The pull request title (if available) */
+  pullRequestTitle?: string;
+  /** Link to compare this branch with main */
+  compareUrl: string;
+  /** Shortened commit SHA (first 7 characters) */
+  shortSha: string;
+  /** Whether the branch has uncommitted changes */
+  isDirty: boolean;
+  /** The commit message */
+  commitMessage: string;
+  /** The commit author */
+  commitAuthor: string;
+};
+
+export class BranchesPresenter {
+  #prismaClient: PrismaClient;
+
+  constructor(prismaClient: PrismaClient = prisma) {
+    this.#prismaClient = prismaClient;
+  }
+
+  public async call({
+    userId,
+    projectSlug,
+    showArchived = false,
+    search,
+    page = 1,
+  }: {
+    userId: User["id"];
+    projectSlug: Project["slug"];
+  } & Options) {
+    const project = await this.#prismaClient.project.findFirst({
+      select: {
+        id: true,
+        organizationId: true,
+      },
+      where: {
+        slug: projectSlug,
+        organization: {
+          members: {
+            some: {
+              userId,
+            },
+          },
+        },
+      },
+    });
+
+    if (!project) {
+      throw new Error("Project not found");
+    }
+
+    const branchableEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
+      select: {
+        id: true,
+      },
+      where: {
+        projectId: project.id,
+        isBranchableEnvironment: true,
+      },
+    });
+
+    const hasFilters = !!showArchived || (search !== undefined && search !== "");
+
+    if (!branchableEnvironment) {
+      return {
+        branchableEnvironment: null,
+        currentPage: page,
+        totalPages: 0,
+        hasBranches: false,
+        branches: [],
+        hasFilters: false,
+        limits: {
+          used: 0,
+          limit: 0,
+          isAtLimit: true,
+        },
+      };
+    }
+
+    const visibleCount = await this.#prismaClient.runtimeEnvironment.count({
+      where: {
+        projectId: project.id,
+        branchName: search
+          ? {
+              contains: search,
+              mode: "insensitive",
+            }
+          : {
+              not: null,
+            },
+        ...(showArchived ? {} : { archivedAt: null }),
+      },
+    });
+
+    // Limits
+    const limits = await checkBranchLimit(this.#prismaClient, project.organizationId, project.id);
+
+    const branches = await this.#prismaClient.runtimeEnvironment.findMany({
+      select: {
+        id: true,
+        slug: true,
+        branchName: true,
+        type: true,
+        archivedAt: true,
+        createdAt: true,
+        git: true,
+      },
+      where: {
+        projectId: project.id,
+        branchName: search
+          ? {
+              contains: search,
+              mode: "insensitive",
+            }
+          : {
+              not: null,
+            },
+        ...(showArchived ? {} : { archivedAt: null }),
+      },
+      orderBy: {
+        branchName: "asc",
+      },
+      skip: (page - 1) * BRANCHES_PER_PAGE,
+      take: BRANCHES_PER_PAGE,
+    });
+
+    const totalBranches = await this.#prismaClient.runtimeEnvironment.count({
+      where: {
+        projectId: project.id,
+        branchName: {
+          not: null,
+        },
+      },
+    });
+
+    return {
+      branchableEnvironment,
+      currentPage: page,
+      totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE),
+      hasBranches: totalBranches > 0,
+      branches: branches.flatMap((branch) => {
+        if (branch.branchName === null) {
+          return [];
+        }
+
+        const git = processGitMetadata(branch.git);
+
+        return [
+          {
+            ...branch,
+            branchName: branch.branchName,
+            git,
+          } as const,
+        ];
+      }),
+      hasFilters,
+      limits,
+    };
+  }
+}
+
+export function processGitMetadata(data: Prisma.JsonValue): GitMetaLinks | null {
+  if (!data) return null;
+
+  const parsed = GitMeta.safeParse(data);
+  if (!parsed.success) {
+    return null;
+  }
+
+  if (!parsed.data.remoteUrl) {
+    return null;
+  }
+
+  // Clean the remote URL by removing any username/password and ensuring it's a proper GitHub URL
+  const cleanRemoteUrl = (() => {
+    try {
+      const url = new URL(parsed.data.remoteUrl);
+      // Remove any username/password from the URL
+      url.username = "";
+      url.password = "";
+      // Ensure we're using https
+      url.protocol = "https:";
+      // Remove any trailing .git
+      return url.toString().replace(/\.git$/, "");
+    } catch (e) {
+      // If URL parsing fails, try to clean it manually
+      return parsed.data.remoteUrl
+        .replace(/^git@github\.com:/, "https://github.com/")
+        .replace(/^https?:\/\/[^@]+@/, "https://")
+        .replace(/\.git$/, "");
+    }
+  })();
+
+  if (!parsed.data.commitRef || !parsed.data.commitSha) return null;
+
+  const shortSha = parsed.data.commitSha.slice(0, 7);
+
+  return {
+    repositoryUrl: cleanRemoteUrl,
+    branchName: parsed.data.commitRef,
+    branchUrl: `${cleanRemoteUrl}/tree/${parsed.data.commitRef}`,
+    commitUrl: `${cleanRemoteUrl}/commit/${parsed.data.commitSha}`,
+    pullRequestUrl: parsed.data.pullRequestNumber
+      ? `${cleanRemoteUrl}/pull/${parsed.data.pullRequestNumber}`
+      : undefined,
+    pullRequestNumber: parsed.data.pullRequestNumber,
+    pullRequestTitle: parsed.data.pullRequestTitle,
+    compareUrl: `${cleanRemoteUrl}/compare/main...${parsed.data.commitRef}`,
+    shortSha,
+    isDirty: parsed.data.dirty ?? false,
+    commitMessage: parsed.data.commitMessage ?? "",
+    commitAuthor: parsed.data.commitAuthorName ?? "",
+  };
+}
diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts
index b044cbe070..c63bb3f5b0 100644
--- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts
@@ -1,9 +1,14 @@
-import { type WorkerDeploymentStatus, type WorkerInstanceGroupType } from "@trigger.dev/database";
+import {
+  Prisma,
+  type WorkerDeploymentStatus,
+  type WorkerInstanceGroupType,
+} from "@trigger.dev/database";
 import { sqlDatabaseSchema, type PrismaClient, prisma } from "~/db.server";
 import { type Organization } from "~/models/organization.server";
 import { type Project } from "~/models/project.server";
 import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
 import { type User } from "~/models/user.server";
+import { processGitMetadata } from "./BranchesPresenter.server";
 
 const pageSize = 20;
 
@@ -102,6 +107,7 @@ export class DeploymentListPresenter {
         userDisplayName: string | null;
         userAvatarUrl: string | null;
         type: WorkerInstanceGroupType;
+        git: Prisma.JsonValue | null;
       }[]
     >`
     SELECT
@@ -117,7 +123,8 @@ export class DeploymentListPresenter {
   u."avatarUrl" AS "userAvatarUrl",
   wd."builtAt",
   wd."deployedAt",
-  wd."type"
+  wd."type",
+  wd."git"
 FROM
   ${sqlDatabaseSchema}."WorkerDeployment" as wd
 INNER JOIN
@@ -164,6 +171,7 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
                 avatarUrl: deployment.userAvatarUrl,
               }
             : undefined,
+          git: processGitMetadata(deployment.git),
         };
       }),
     };
diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts
index 7c29aecbe4..b8e2c00a68 100644
--- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts
@@ -10,6 +10,7 @@ import { type Project } from "~/models/project.server";
 import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
 import { type User } from "~/models/user.server";
 import { getUsername } from "~/utils/username";
+import { processGitMetadata } from "./BranchesPresenter.server";
 
 export type ErrorData = {
   name: string;
@@ -98,6 +99,7 @@ export class DeploymentPresenter {
         builtAt: true,
         deployedAt: true,
         createdAt: true,
+        git: true,
         promotions: {
           select: {
             label: true,
@@ -162,6 +164,7 @@ export class DeploymentPresenter {
         errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
         isBuilt: !!deployment.builtAt,
         type: deployment.type,
+        git: processGitMetadata(deployment.git),
       },
     };
   }
diff --git a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts
index a1d9573c98..5b0881b6d1 100644
--- a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts
@@ -50,6 +50,7 @@ export class EditSchedulePresenter {
                 },
               },
             },
+            branchName: true,
           },
         },
       },
@@ -85,11 +86,17 @@ export class EditSchedulePresenter {
         })
       : [];
 
-    const possibleEnvironments = filterOrphanedEnvironments(project.environments).map(
-      (environment) => {
-        return displayableEnvironment(environment, userId);
-      }
-    );
+    const possibleEnvironments = filterOrphanedEnvironments(project.environments)
+      .map((environment) => {
+        return {
+          ...displayableEnvironment(environment, userId),
+          branchName: environment.branchName ?? undefined,
+        };
+      })
+      .filter((env) => {
+        if (env.type === "PREVIEW" && !env.branchName) return false;
+        return true;
+      });
 
     return {
       possibleTasks: possibleTasks.map((task) => task.slug).sort(),
diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts
index f03121ae83..730591f4eb 100644
--- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts
@@ -71,6 +71,8 @@ export class EnvironmentVariablesPresenter {
       select: {
         id: true,
         type: true,
+        isBranchableEnvironment: true,
+        branchName: true,
         orgMember: {
           select: {
             userId: true,
@@ -81,6 +83,7 @@ export class EnvironmentVariablesPresenter {
         project: {
           slug: projectSlug,
         },
+        archivedAt: null,
       },
     });
 
@@ -109,7 +112,7 @@ export class EnvironmentVariablesPresenter {
               {
                 id: environmentVariable.id,
                 key: environmentVariable.key,
-                environment: { type: env.type, id: env.id },
+                environment: { type: env.type, id: env.id, branchName: env.branchName },
                 value: isSecret ? "" : val.value,
                 isSecret,
               },
@@ -120,6 +123,8 @@ export class EnvironmentVariablesPresenter {
       environments: sortedEnvironments.map((environment) => ({
         id: environment.id,
         type: environment.type,
+        isBranchableEnvironment: environment.isBranchableEnvironment,
+        branchName: environment.branchName,
       })),
       hasStaging: environments.some((environment) => environment.type === "STAGING"),
     };
diff --git a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts
index 7279156391..bd210351ca 100644
--- a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts
@@ -35,6 +35,7 @@ export type ScheduleListItem = {
     id: string;
     type: RuntimeEnvironmentType;
     userName?: string;
+    branchName?: string;
   }[];
 };
 export type ScheduleList = Awaited<ReturnType<ScheduleListPresenter["call"]>>;
@@ -67,6 +68,7 @@ export class ScheduleListPresenter extends BasePresenter {
             id: true,
             type: true,
             slug: true,
+            branchName: true,
             orgMember: {
               select: {
                 user: {
@@ -269,7 +271,10 @@ export class ScheduleListPresenter extends BasePresenter {
             );
           }
 
-          return displayableEnvironment(environment, userId);
+          return {
+            ...displayableEnvironment(environment, userId),
+            branchName: environment.branchName ?? undefined,
+          };
         }),
       };
     });
diff --git a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts
index cd157651be..9491e85130 100644
--- a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts
@@ -53,6 +53,7 @@ export class ViewSchedulePresenter {
                     },
                   },
                 },
+                branchName: true,
               },
             },
           },
@@ -91,7 +92,10 @@ export class ViewSchedulePresenter {
         runs,
         environments: schedule.instances.map((instance) => {
           const environment = instance.environment;
-          return displayableEnvironment(environment, userId);
+          return {
+            ...displayableEnvironment(environment, userId),
+            branchName: environment.branchName ?? undefined,
+          };
         }),
       },
     };
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx
index 01c296fb22..acbf29c4f3 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx
@@ -1,33 +1,41 @@
-import { ArrowUpCircleIcon, BookOpenIcon, InformationCircleIcon } from "@heroicons/react/20/solid";
+import { BookOpenIcon } from "@heroicons/react/20/solid";
 import { type MetaFunction } from "@remix-run/react";
 import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
 import { typedjson, useTypedLoaderData } from "remix-typedjson";
 import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
+import { CodeBlock } from "~/components/code/CodeBlock";
 import { InlineCode } from "~/components/code/InlineCode";
-import { EnvironmentCombo, environmentFullTitle } from "~/components/environments/EnvironmentLabel";
+import {
+  EnvironmentCombo,
+  environmentFullTitle,
+  environmentTextClassName,
+} from "~/components/environments/EnvironmentLabel";
 import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal";
-import { PageBody, PageContainer } from "~/components/layout/AppLayout";
+import {
+  MainHorizontallyCenteredContainer,
+  PageBody,
+  PageContainer,
+} from "~/components/layout/AppLayout";
+import {
+  Accordion,
+  AccordionContent,
+  AccordionItem,
+  AccordionTrigger,
+} from "~/components/primitives/Accordion";
 import { LinkButton } from "~/components/primitives/Buttons";
+import { Callout } from "~/components/primitives/Callout";
 import { ClipboardField } from "~/components/primitives/ClipboardField";
-import { DateTime } from "~/components/primitives/DateTime";
-import { InfoPanel } from "~/components/primitives/InfoPanel";
+import { Header2 } from "~/components/primitives/Headers";
+import { Hint } from "~/components/primitives/Hint";
+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 * as Property from "~/components/primitives/PropertyTable";
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableCellMenu,
-  TableHeader,
-  TableHeaderCell,
-  TableRow,
-} from "~/components/primitives/Table";
-import { TextLink } from "~/components/primitives/TextLink";
 import { useOrganization } from "~/hooks/useOrganizations";
 import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
 import { requireUserId } from "~/services/session.server";
-import { docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder";
+import { cn } from "~/utils/cn";
+import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder";
 
 export const meta: MetaFunction = () => {
   return [
@@ -39,18 +47,18 @@ export const meta: MetaFunction = () => {
 
 export const loader = async ({ request, params }: LoaderFunctionArgs) => {
   const userId = await requireUserId(request);
-  const { projectParam } = ProjectParamSchema.parse(params);
+  const { projectParam, envParam } = EnvironmentParamSchema.parse(params);
 
   try {
     const presenter = new ApiKeysPresenter();
-    const { environments, hasStaging } = await presenter.call({
+    const { environment } = await presenter.call({
       userId,
       projectSlug: projectParam,
+      environmentSlug: envParam,
     });
 
     return typedjson({
-      environments,
-      hasStaging,
+      environment,
     });
   } catch (error) {
     console.error(error);
@@ -62,9 +70,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
 };
 
 export default function Page() {
-  const { environments, hasStaging } = useTypedLoaderData<typeof loader>();
+  const { environment } = useTypedLoaderData<typeof loader>();
   const organization = useOrganization();
 
+  if (!environment) {
+    throw new Response(undefined, {
+      status: 404,
+      statusText: "Environment not found",
+    });
+  }
+
+  let envBlock = `TRIGGER_SECRET_KEY="${environment.apiKey}"`;
+  if (environment.branchName) {
+    envBlock += `\nTRIGGER_PREVIEW_BRANCH="${environment.branchName}"`;
+  }
+
   return (
     <PageContainer>
       <NavBar>
@@ -72,12 +92,10 @@ export default function Page() {
         <PageAccessories>
           <AdminDebugTooltip>
             <Property.Table>
-              {environments.map((environment) => (
-                <Property.Item key={environment.id}>
-                  <Property.Label>{environment.slug}</Property.Label>
-                  <Property.Value>{environment.id}</Property.Value>
-                </Property.Item>
-              ))}
+              <Property.Item key={environment.id}>
+                <Property.Label>{environment.slug}</Property.Label>
+                <Property.Value>{environment.id}</Property.Value>
+              </Property.Item>
             </Property.Table>
           </AdminDebugTooltip>
 
@@ -90,83 +108,86 @@ export default function Page() {
           </LinkButton>
         </PageAccessories>
       </NavBar>
-      <PageBody scrollable={false}>
-        <div className="flex flex-col">
-          <Table containerClassName="border-t-0">
-            <TableHeader>
-              <TableRow>
-                <TableHeaderCell>Environment</TableHeaderCell>
-                <TableHeaderCell>Secret key</TableHeaderCell>
-                <TableHeaderCell>Key generated</TableHeaderCell>
-                <TableHeaderCell>Latest version</TableHeaderCell>
-                <TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
-              </TableRow>
-            </TableHeader>
-            <TableBody>
-              {environments.map((environment) => (
-                <TableRow key={environment.id}>
-                  <TableCell>
-                    <EnvironmentCombo environment={environment} />
-                  </TableCell>
-                  <TableCell>
-                    <ClipboardField
-                      className="w-full max-w-none"
-                      secure={`tr_${environment.apiKey.split("_")[1]}_••••••••`}
-                      value={environment.apiKey}
-                      variant={"secondary/small"}
-                    />
-                  </TableCell>
-                  <TableCell>
-                    <DateTime date={environment.updatedAt} />
-                  </TableCell>
-                  <TableCell>{environment.latestVersion ?? "–"}</TableCell>
-                  <TableCellMenu
-                    isSticky
-                    popoverContent={
-                      <RegenerateApiKeyModal
-                        id={environment.id}
-                        title={environmentFullTitle(environment)}
-                      />
-                    }
-                  ></TableCellMenu>
-                </TableRow>
-              ))}
-              {!hasStaging && (
-                <TableRow>
-                  <TableCell>
-                    <EnvironmentCombo environment={{ type: "STAGING" }} />
-                  </TableCell>
-                  <TableCell>
-                    <LinkButton
-                      to={v3BillingPath(
-                        organization,
-                        "Upgrade to unlock a Staging environment for your projects."
-                      )}
-                      variant="secondary/small"
-                      LeadingIcon={ArrowUpCircleIcon}
-                      leadingIconClassName="text-indigo-500"
-                    >
-                      Upgrade to get staging environment
-                    </LinkButton>
-                  </TableCell>
-                  <TableCell></TableCell>
-                  <TableCell></TableCell>
-                  <TableCell></TableCell>
-                </TableRow>
+      <PageBody>
+        <MainHorizontallyCenteredContainer>
+          <div className="mb-3 border-b border-grid-dimmed pb-1">
+            <Header2
+              className={cn(
+                "inline-flex items-center gap-1 font-normal",
+                environmentTextClassName(environment)
               )}
-            </TableBody>
-          </Table>
+            >
+              <EnvironmentCombo
+                environment={environment}
+                className="text-base"
+                iconClassName="size-5"
+              />
+              API keys
+            </Header2>
+          </div>
+          <div className="flex flex-col gap-6">
+            <InputGroup fullWidth>
+              <div className="flex w-full items-center justify-between">
+                <Label>Secret key</Label>
+                <RegenerateApiKeyModal
+                  id={environment.parentEnvironment?.id ?? environment.id}
+                  title={environmentFullTitle(environment)}
+                />
+              </div>
+              <ClipboardField
+                className="w-full max-w-none"
+                secure={`tr_${environment.apiKey.split("_")[1]}_••••••••`}
+                value={environment.apiKey}
+                variant={"secondary/small"}
+              />
+              <Hint>
+                Set this as your <InlineCode variant="extra-small">TRIGGER_SECRET_KEY</InlineCode>{" "}
+                env var in your backend.
+              </Hint>
+            </InputGroup>
+            {environment.branchName && (
+              <InputGroup fullWidth>
+                <Label>Branch name</Label>
+                <ClipboardField
+                  className="w-full max-w-none"
+                  value={environment.branchName}
+                  variant={"secondary/small"}
+                />
+                <Hint>
+                  Set this as your{" "}
+                  <InlineCode variant="extra-small">TRIGGER_PREVIEW_BRANCH</InlineCode> env var in
+                  your backend.
+                </Hint>
+              </InputGroup>
+            )}
+            {environment.type === "DEVELOPMENT" && (
+              <Callout variant="info">
+                Every team member gets their own dev Secret key. Make sure you're using the one
+                above otherwise you will trigger runs on your team member's machine.
+              </Callout>
+            )}
 
-          <div className="flex flex-wrap justify-between">
-            <InfoPanel icon={InformationCircleIcon} variant="minimal" panelClassName="max-w-fit">
-              <Paragraph variant="small">
-                Set your <TextLink to={docsPath("apikeys")}>Secret keys</TextLink> in your backend
-                by adding <InlineCode>TRIGGER_SECRET_KEY</InlineCode> env var in order to{" "}
-                <TextLink to={docsPath("v3/triggering")}>trigger tasks</TextLink>.
-              </Paragraph>
-            </InfoPanel>
+            <Accordion type="single" collapsible>
+              <AccordionItem value="item-1">
+                <AccordionTrigger>How to set these environment variables</AccordionTrigger>
+                <AccordionContent>
+                  <div className="flex flex-col gap-2">
+                    <div>
+                      You need to set these environment variables in your backend. This allows the
+                      SDK to authenticate with Trigger.dev.
+                    </div>
+                    <CodeBlock
+                      language="javascript"
+                      code={envBlock}
+                      showOpenInModal={false}
+                      showLineNumbers={false}
+                    />
+                  </div>
+                </AccordionContent>
+              </AccordionItem>
+            </Accordion>
           </div>
-        </div>
+        </MainHorizontallyCenteredContainer>
       </PageBody>
     </PageContainer>
   );
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx
new file mode 100644
index 0000000000..7726e6a9b1
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx
@@ -0,0 +1,613 @@
+import { conform, useForm } from "@conform-to/react";
+import { parse } from "@conform-to/zod";
+import {
+  ArrowRightIcon,
+  ArrowUpCircleIcon,
+  CheckIcon,
+  MagnifyingGlassIcon,
+  PlusIcon,
+} from "@heroicons/react/20/solid";
+import { BookOpenIcon } from "@heroicons/react/24/solid";
+import { DialogClose } from "@radix-ui/react-dialog";
+import { Form, useActionData, useLocation, useSearchParams } from "@remix-run/react";
+import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { GitMeta } from "@trigger.dev/core/v3";
+import { useCallback, useEffect, useState } from "react";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { z } from "zod";
+import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
+import { BranchesNoBranchableEnvironment, BranchesNoBranches } from "~/components/BlankStatePanels";
+import { Feedback } from "~/components/Feedback";
+import { GitMetadata } from "~/components/GitMetadata";
+import { V4Title } from "~/components/V4Badge";
+import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
+import { InlineCode } from "~/components/code/InlineCode";
+import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { Badge } from "~/components/primitives/Badge";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
+import { CopyableText } from "~/components/primitives/CopyableText";
+import { DateTime } from "~/components/primitives/DateTime";
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTrigger,
+} from "~/components/primitives/Dialog";
+import { Fieldset } from "~/components/primitives/Fieldset";
+import { FormButtons } from "~/components/primitives/FormButtons";
+import { FormError } from "~/components/primitives/FormError";
+import { Header3 } from "~/components/primitives/Headers";
+import { Hint } from "~/components/primitives/Hint";
+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 { PaginationControls } from "~/components/primitives/Pagination";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { PopoverMenuItem } from "~/components/primitives/Popover";
+import * as Property from "~/components/primitives/PropertyTable";
+import { Switch } from "~/components/primitives/Switch";
+import {
+  Table,
+  TableBlankRow,
+  TableBody,
+  TableCell,
+  TableCellMenu,
+  TableHeader,
+  TableHeaderCell,
+  TableRow,
+} from "~/components/primitives/Table";
+import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
+import { useEnvironment } from "~/hooks/useEnvironment";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { useThrottle } from "~/hooks/useThrottle";
+import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
+import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server";
+import { logger } from "~/services/logger.server";
+import { requireUserId } from "~/services/session.server";
+import { UpsertBranchService } from "~/services/upsertBranch.server";
+import { cn } from "~/utils/cn";
+import { branchesPath, docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder";
+import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
+import { ArchiveButton } from "../resources.branches.archive";
+
+export const BranchesOptions = z.object({
+  search: z.string().optional(),
+  showArchived: z.preprocess((val) => val === "true" || val === true, z.boolean()).optional(),
+  page: z.preprocess((val) => Number(val), z.number()).optional(),
+});
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+  const userId = await requireUserId(request);
+  const { projectParam } = ProjectParamSchema.parse(params);
+
+  const searchParams = new URL(request.url).searchParams;
+  const parsedSearchParams = BranchesOptions.safeParse(Object.fromEntries(searchParams));
+  const options = parsedSearchParams.success ? parsedSearchParams.data : {};
+
+  try {
+    const presenter = new BranchesPresenter();
+    const result = await presenter.call({
+      userId,
+      projectSlug: projectParam,
+      ...options,
+    });
+
+    return typedjson(result);
+  } catch (error) {
+    logger.error("Error loading preview branches page", { error });
+    throw new Response(undefined, {
+      status: 400,
+      statusText: "Something went wrong, if this problem persists please contact support.",
+    });
+  }
+};
+
+export const CreateBranchOptions = z.object({
+  parentEnvironmentId: z.string(),
+  branchName: z.string().min(1),
+  git: GitMeta.optional(),
+});
+
+export type CreateBranchOptions = z.infer<typeof CreateBranchOptions>;
+
+export const schema = CreateBranchOptions.and(
+  z.object({
+    failurePath: z.string(),
+  })
+);
+
+export async function action({ request }: ActionFunctionArgs) {
+  const userId = await requireUserId(request);
+
+  const formData = await request.formData();
+  const submission = parse(formData, { schema });
+
+  if (!submission.value) {
+    return redirectWithErrorMessage("/", request, "Invalid form data");
+  }
+
+  const upsertBranchService = new UpsertBranchService();
+  const result = await upsertBranchService.call(userId, submission.value);
+
+  if (result.success) {
+    if (result.alreadyExisted) {
+      submission.error = {
+        branchName: [
+          `Branch "${result.branch.branchName}" already exists. You can archive it and create a new one with the same name.`,
+        ],
+      };
+      return json(submission);
+    }
+
+    return redirectWithSuccessMessage(
+      `${branchesPath(result.organization, result.project, result.branch)}?dialogClosed=true`,
+      request,
+      `Branch "${result.branch.branchName}" created`
+    );
+  }
+
+  submission.error = { branchName: [result.error] };
+  return json(submission);
+}
+
+export default function Page() {
+  const {
+    branchableEnvironment,
+    branches,
+    hasFilters,
+    limits,
+    currentPage,
+    totalPages,
+    hasBranches,
+  } = useTypedLoaderData<typeof loader>();
+  const organization = useOrganization();
+  const project = useProject();
+  const environment = useEnvironment();
+
+  const plan = useCurrentPlan();
+  const requiresUpgrade =
+    plan?.v3Subscription?.plan &&
+    limits.used >= plan.v3Subscription.plan.limits.branches.number &&
+    !plan.v3Subscription.plan.limits.branches.canExceed;
+  const canUpgrade =
+    plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.branches.canExceed;
+
+  if (!branchableEnvironment) {
+    return (
+      <PageContainer>
+        <NavBar>
+          <PageTitle title={<V4Title>Preview branches</V4Title>} />
+        </NavBar>
+        <PageBody>
+          <MainCenteredContainer className="max-w-md">
+            <BranchesNoBranchableEnvironment />
+          </MainCenteredContainer>
+        </PageBody>
+      </PageContainer>
+    );
+  }
+
+  return (
+    <PageContainer>
+      <NavBar>
+        <PageTitle title={<V4Title>Preview branches</V4Title>} />
+        <PageAccessories>
+          <AdminDebugTooltip>
+            <Property.Table>
+              {branches.map((branch) => (
+                <Property.Item key={branch.id}>
+                  <Property.Label>{branch.branchName}</Property.Label>
+                  <Property.Value>{branch.id}</Property.Value>
+                </Property.Item>
+              ))}
+            </Property.Table>
+          </AdminDebugTooltip>
+
+          <LinkButton
+            variant={"docs/small"}
+            LeadingIcon={BookOpenIcon}
+            to={docsPath("deployment/preview-branches")}
+          >
+            Branches docs
+          </LinkButton>
+
+          {limits.isAtLimit ? (
+            <UpgradePanel limits={limits} canUpgrade={canUpgrade ?? false} />
+          ) : (
+            <NewBranchPanel
+              button={
+                <Button
+                  variant="primary/small"
+                  shortcut={{ key: "n" }}
+                  LeadingIcon={PlusIcon}
+                  leadingIconClassName="text-white"
+                  fullWidth
+                  textAlignLeft
+                >
+                  New branch
+                </Button>
+              }
+              parentEnvironment={branchableEnvironment}
+            />
+          )}
+        </PageAccessories>
+      </NavBar>
+      <PageBody scrollable={false}>
+        <div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
+          {!hasBranches ? (
+            <MainCenteredContainer className="max-w-md">
+              <BranchesNoBranches
+                parentEnvironment={branchableEnvironment}
+                limits={limits}
+                canUpgrade={canUpgrade ?? false}
+              />
+            </MainCenteredContainer>
+          ) : (
+            <>
+              <div className="flex items-center justify-between gap-x-2 p-2">
+                <BranchFilters />
+                <div className="flex items-center justify-end gap-x-2">
+                  <PaginationControls
+                    currentPage={currentPage}
+                    totalPages={totalPages}
+                    showPageNumbers={false}
+                  />
+                </div>
+              </div>
+
+              <div
+                className={cn(
+                  "grid max-h-full min-h-full overflow-x-auto",
+                  totalPages > 1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]"
+                )}
+              >
+                <Table>
+                  <TableHeader>
+                    <TableRow>
+                      <TableHeaderCell>Branch</TableHeaderCell>
+                      <TableHeaderCell>Created</TableHeaderCell>
+                      <TableHeaderCell>Git</TableHeaderCell>
+                      <TableHeaderCell>Archived</TableHeaderCell>
+                      <TableHeaderCell>
+                        <span className="sr-only">Actions</span>
+                      </TableHeaderCell>
+                    </TableRow>
+                  </TableHeader>
+                  <TableBody>
+                    {branches.length === 0 ? (
+                      <TableBlankRow colSpan={5}>
+                        <Paragraph>There are no matches for your filters</Paragraph>
+                      </TableBlankRow>
+                    ) : (
+                      branches.map((branch) => {
+                        const path = branchesPath(organization, project, branch);
+                        const cellClass = branch.archivedAt ? "opacity-50" : "";
+                        const isSelected = branch.id === environment.id;
+
+                        return (
+                          <TableRow key={branch.id}>
+                            <TableCell isTabbableCell className={cellClass}>
+                              <div className="flex items-center gap-1">
+                                <BranchEnvironmentIconSmall
+                                  className={cn("size-4", isSelected && "text-preview")}
+                                />
+                                <CopyableText
+                                  value={branch.branchName ?? ""}
+                                  className={cn(isSelected && "text-preview")}
+                                />
+                                {isSelected && <Badge variant="extra-small">Current</Badge>}
+                              </div>
+                            </TableCell>
+                            <TableCell className={cellClass}>
+                              <DateTime date={branch.createdAt} />
+                            </TableCell>
+                            <TableCell className={cellClass}>
+                              <div className="-ml-1 flex items-center">
+                                <GitMetadata git={branch.git} />
+                              </div>
+                            </TableCell>
+                            <TableCell className={cellClass}>
+                              {branch.archivedAt ? (
+                                <CheckIcon className="size-4 text-charcoal-400" />
+                              ) : (
+                                "–"
+                              )}
+                            </TableCell>
+                            <TableCellMenu
+                              className="pl-32"
+                              isSticky
+                              hiddenButtons={
+                                isSelected ? null : (
+                                  <PopoverMenuItem to={path} title="Switch to branch" />
+                                )
+                              }
+                              popoverContent={
+                                !isSelected || !branch.archivedAt ? (
+                                  <>
+                                    {isSelected ? null : (
+                                      <PopoverMenuItem
+                                        to={path}
+                                        icon={ArrowRightIcon}
+                                        leadingIconClassName="text-blue-500"
+                                        title="Switch to branch"
+                                      />
+                                    )}
+                                    {!branch.archivedAt ? (
+                                      <ArchiveButton environment={branch} />
+                                    ) : null}
+                                  </>
+                                ) : null
+                              }
+                            />
+                          </TableRow>
+                        );
+                      })
+                    )}
+                  </TableBody>
+                </Table>
+                <div
+                  className={cn(
+                    "flex min-h-full",
+                    totalPages > 1 && "justify-end border-t border-grid-dimmed px-2 py-3"
+                  )}
+                >
+                  <PaginationControls currentPage={currentPage} totalPages={totalPages} />
+                </div>
+              </div>
+
+              <div className="flex w-full items-start justify-between">
+                <div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
+                  <SimpleTooltip
+                    button={
+                      <div className="size-6">
+                        <svg className="h-full w-full -rotate-90 overflow-visible">
+                          <circle
+                            className="fill-none stroke-grid-bright"
+                            strokeWidth="4"
+                            r="10"
+                            cx="12"
+                            cy="12"
+                          />
+                          <circle
+                            className={`fill-none ${
+                              requiresUpgrade ? "stroke-error" : "stroke-success"
+                            }`}
+                            strokeWidth="4"
+                            r="10"
+                            cx="12"
+                            cy="12"
+                            strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
+                            strokeDashoffset="0"
+                            strokeLinecap="round"
+                          />
+                        </svg>
+                      </div>
+                    }
+                    content={`${Math.round((limits.used / limits.limit) * 100)}%`}
+                  />
+                  <div className="flex w-full items-center justify-between gap-6">
+                    {requiresUpgrade ? (
+                      <Header3 className="text-error">
+                        You've used all {limits.limit} of your branches. Archive one or upgrade your
+                        plan to enable more.
+                      </Header3>
+                    ) : (
+                      <div className="flex items-center gap-1">
+                        <Header3>
+                          You've used {limits.used}/{limits.limit} of your branches
+                        </Header3>
+                        <InfoIconTooltip content="Archived branches don't count towards your limit." />
+                      </div>
+                    )}
+
+                    {canUpgrade ? (
+                      <LinkButton
+                        to={v3BillingPath(organization)}
+                        variant="secondary/small"
+                        LeadingIcon={ArrowUpCircleIcon}
+                        leadingIconClassName="text-indigo-500"
+                      >
+                        Upgrade
+                      </LinkButton>
+                    ) : (
+                      <Feedback
+                        button={<Button variant="secondary/small">Request more</Button>}
+                        defaultValue="help"
+                      />
+                    )}
+                  </div>
+                </div>
+              </div>
+            </>
+          )}
+        </div>
+      </PageBody>
+    </PageContainer>
+  );
+}
+
+export function BranchFilters() {
+  const [searchParams, setSearchParams] = useSearchParams();
+  const { search, showArchived, page } = BranchesOptions.parse(
+    Object.fromEntries(searchParams.entries())
+  );
+
+  const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
+    setSearchParams((s) => {
+      if (value) {
+        searchParams.set(filterType, value);
+      } else {
+        searchParams.delete(filterType);
+      }
+      searchParams.delete("page");
+      return searchParams;
+    });
+  }, []);
+
+  const handleArchivedChange = useCallback((checked: boolean) => {
+    handleFilterChange("showArchived", checked ? "true" : undefined);
+  }, []);
+
+  const handleSearchChange = useThrottle((value: string) => {
+    handleFilterChange("search", value.length === 0 ? undefined : value);
+  }, 300);
+
+  return (
+    <div className="flex w-full gap-2">
+      <Input
+        name="search"
+        placeholder="Search branch name"
+        icon={MagnifyingGlassIcon}
+        variant="tertiary"
+        className="grow"
+        defaultValue={search}
+        onChange={(e) => handleSearchChange(e.target.value)}
+      />
+
+      <Switch
+        checked={showArchived ?? false}
+        onCheckedChange={handleArchivedChange}
+        label="Show archived"
+        variant="small"
+      />
+    </div>
+  );
+}
+
+function UpgradePanel({
+  limits,
+  canUpgrade,
+}: {
+  limits: {
+    used: number;
+    limit: number;
+  };
+  canUpgrade: boolean;
+}) {
+  const organization = useOrganization();
+
+  return (
+    <Dialog>
+      <DialogTrigger asChild>
+        <Button
+          LeadingIcon={PlusIcon}
+          leadingIconClassName="text-white"
+          variant="primary/small"
+          shortcut={{ key: "n" }}
+        >
+          New branch
+        </Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>You've exceeded your limit</DialogHeader>
+        <div className="mt-2">
+          <Paragraph spacing>
+            You've used {limits.used}/{limits.limit} of your branches.
+          </Paragraph>
+          <Paragraph>You can archive one or upgrade your plan for more.</Paragraph>
+        </div>
+        <DialogFooter>
+          {canUpgrade ? (
+            <LinkButton variant="primary/small" to={v3BillingPath(organization)}>
+              Upgrade
+            </LinkButton>
+          ) : (
+            <Feedback
+              button={<Button variant="primary/small">Request more</Button>}
+              defaultValue="help"
+            />
+          )}
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+export function NewBranchPanel({
+  button,
+  parentEnvironment,
+}: {
+  button: React.ReactNode;
+  parentEnvironment: { id: string };
+}) {
+  const lastSubmission = useActionData<typeof action>();
+  const location = useLocation();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [isOpen, setIsOpen] = useState(false);
+
+  const [form, { parentEnvironmentId, branchName, failurePath }] = useForm({
+    id: "create-branch",
+    lastSubmission: lastSubmission as any,
+    onValidate({ formData }) {
+      return parse(formData, { schema });
+    },
+    shouldRevalidate: "onInput",
+  });
+
+  useEffect(() => {
+    if (searchParams.has("dialogClosed")) {
+      setSearchParams((s) => {
+        s.delete("dialogClosed");
+        return s;
+      });
+      setIsOpen(false);
+    }
+  }, [searchParams, setSearchParams]);
+
+  return (
+    <Dialog open={isOpen} onOpenChange={setIsOpen}>
+      <DialogTrigger asChild>{button}</DialogTrigger>
+      <DialogContent>
+        <DialogHeader>New branch</DialogHeader>
+        <div className="mt-2 flex flex-col gap-4">
+          <Form method="post" {...form.props} className="w-full">
+            <Fieldset className="max-w-full gap-y-3">
+              <input
+                value={parentEnvironment.id}
+                {...conform.input(parentEnvironmentId, { type: "hidden" })}
+              />
+              <input
+                value={location.pathname}
+                {...conform.input(failurePath, { type: "hidden" })}
+              />
+              <InputGroup className="max-w-full">
+                <Label>Branch name</Label>
+                <Input {...conform.input(branchName)} />
+                <Hint>
+                  Must not contain: spaces <InlineCode variant="extra-small">~</InlineCode>{" "}
+                  <InlineCode variant="extra-small">^</InlineCode>{" "}
+                  <InlineCode variant="extra-small">:</InlineCode>{" "}
+                  <InlineCode variant="extra-small">?</InlineCode>{" "}
+                  <InlineCode variant="extra-small">*</InlineCode>{" "}
+                  <InlineCode variant="extra-small">{"["}</InlineCode>{" "}
+                  <InlineCode variant="extra-small">\</InlineCode>{" "}
+                  <InlineCode variant="extra-small">//</InlineCode>{" "}
+                  <InlineCode variant="extra-small">..</InlineCode>{" "}
+                  <InlineCode variant="extra-small">{"@{"}</InlineCode>{" "}
+                  <InlineCode variant="extra-small">.lock</InlineCode>
+                </Hint>
+                <FormError id={branchName.errorId}>{branchName.error}</FormError>
+              </InputGroup>
+              <FormError>{form.error}</FormError>
+              <FormButtons
+                confirmButton={
+                  <Button type="submit" variant="primary/medium">
+                    Create branch
+                  </Button>
+                }
+                cancelButton={
+                  <DialogClose asChild>
+                    <Button variant="tertiary/medium">Cancel</Button>
+                  </DialogClose>
+                }
+              />
+            </Fieldset>
+          </Form>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx
index 1ee6599513..a245e8d9d8 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx
@@ -2,6 +2,7 @@ import { Link, useLocation } from "@remix-run/react";
 import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
 import { typedjson, useTypedLoaderData } from "remix-typedjson";
 import { ExitIcon } from "~/assets/icons/ExitIcon";
+import { GitMetadata } from "~/components/GitMetadata";
 import { UserAvatar } from "~/components/UserProfilePhoto";
 import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
 import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
@@ -198,6 +199,15 @@ export default function Page() {
                   )}
                 </Property.Value>
               </Property.Item>
+
+              <Property.Item>
+                <Property.Label>Git</Property.Label>
+                <Property.Value>
+                  <div className="-ml-1 mt-0.5 flex flex-col">
+                    <GitMetadata git={deployment.git} />
+                  </div>
+                </Property.Value>
+              </Property.Item>
               <Property.Item>
                 <Property.Label>Deployed by</Property.Label>
                 <Property.Value>
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx
index ef8ababe36..701e6843f9 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx
@@ -53,6 +53,7 @@ import { createSearchParams } from "~/utils/searchParams";
 import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus";
 import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
 import { useEffect } from "react";
+import { GitMetadata } from "~/components/GitMetadata";
 
 export const meta: MetaFunction = () => {
   return [
@@ -193,6 +194,7 @@ export default function Page() {
                       <TableHeaderCell>Tasks</TableHeaderCell>
                       <TableHeaderCell>Deployed at</TableHeaderCell>
                       <TableHeaderCell>Deployed by</TableHeaderCell>
+                      <TableHeaderCell>Git</TableHeaderCell>
                       <TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
                     </TableRow>
                   </TableHeader>
@@ -256,6 +258,11 @@ export default function Page() {
                                 "–"
                               )}
                             </TableCell>
+                            <TableCell isSelected={isSelected}>
+                              <div className="-ml-1 flex items-center">
+                                <GitMetadata git={deployment.git} />
+                              </div>
+                            </TableCell>
                             <DeploymentActionsCell
                               deployment={deployment}
                               path={path}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx
index 17c55867c0..c52942a8ac 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx
@@ -1,5 +1,5 @@
 import {
-  FieldConfig,
+  type FieldConfig,
   list,
   requestIntent,
   useFieldList,
@@ -9,9 +9,9 @@ import {
 import { parse } from "@conform-to/zod";
 import { LockClosedIcon, LockOpenIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
 import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
-import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
+import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
 import dotenv from "dotenv";
-import { RefObject, useCallback, useEffect, useRef, useState } from "react";
+import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
 import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
 import { z } from "zod";
 import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
@@ -51,6 +51,7 @@ import {
 } from "~/utils/pathBuilder";
 import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
 import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
+import { Select, SelectItem } from "~/components/primitives/Select";
 
 export const loader = async ({ request, params }: LoaderFunctionArgs) => {
   const userId = await requireUserId(request);
@@ -186,6 +187,15 @@ export default function Page() {
   const organization = useOrganization();
   const project = useProject();
   const environment = useEnvironment();
+  const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState<Set<string>>(new Set());
+  const [selectedBranchId, setSelectedBranchId] = useState<string | undefined>(undefined);
+
+  const branchEnvironments = environments.filter((env) => env.branchName);
+  const nonBranchEnvironments = environments.filter((env) => !env.branchName);
+  const selectedEnvironments = environments.filter((env) => selectedEnvironmentIds.has(env.id));
+  const previewIsSelected = selectedEnvironments.some(
+    (env) => env.branchName !== null || env.type === "PREVIEW"
+  );
 
   const isLoading = navigation.state !== "idle" && navigation.formMethod === "post";
 
@@ -202,6 +212,45 @@ export default function Page() {
     },
   });
 
+  const handleEnvironmentChange = (
+    environmentId: string,
+    isChecked: boolean,
+    environmentType?: string
+  ) => {
+    setSelectedEnvironmentIds((prev) => {
+      const newSet = new Set(prev);
+
+      if (isChecked) {
+        if (environmentType === "PREVIEW") {
+          // If PREVIEW is checked, clear all other selections including branches
+          newSet.clear();
+
+          newSet.add(environmentId);
+        } else {
+          // If a non-PREVIEW environment is checked, remove PREVIEW if it's selected
+          const previewEnv = environments.find((env) => env.type === "PREVIEW");
+          if (previewEnv) {
+            newSet.delete(previewEnv.id);
+          }
+          newSet.add(environmentId);
+          setSelectedBranchId(undefined);
+        }
+      } else {
+        newSet.delete(environmentId);
+      }
+
+      return newSet;
+    });
+  };
+
+  const handleBranchChange = (branchId: string) => {
+    if (branchId === "all") {
+      setSelectedBranchId(undefined);
+    } else {
+      setSelectedBranchId(branchId);
+    }
+  };
+
   const [revealAll, setRevealAll] = useState(true);
 
   useEffect(() => {
@@ -223,36 +272,70 @@ export default function Page() {
           <Fieldset className="max-h-[70vh] overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
             <InputGroup fullWidth>
               <Label>Environments</Label>
+              {selectedBranchId ? (
+                <input type="hidden" name="environmentIds" value={selectedBranchId} />
+              ) : (
+                Array.from(selectedEnvironmentIds).map((id) => (
+                  <input key={id} type="hidden" name="environmentIds" value={id} />
+                ))
+              )}
               <div className="flex items-center gap-2">
-                {environments.map((environment) => (
+                {nonBranchEnvironments.map((environment) => (
                   <CheckboxWithLabel
                     key={environment.id}
                     id={environment.id}
                     value={environment.id}
-                    name="environmentIds"
-                    type="radio"
+                    defaultChecked={selectedEnvironmentIds.has(environment.id)}
+                    onChange={(isChecked) =>
+                      handleEnvironmentChange(environment.id, isChecked, environment.type)
+                    }
                     label={<EnvironmentLabel environment={environment} className="text-sm" />}
                     variant="button"
                   />
                 ))}
                 {!hasStaging && (
-                  <TooltipProvider>
-                    <Tooltip>
-                      <TooltipTrigger>
-                        <TextLink
-                          to={v3BillingPath(organization)}
-                          className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-charcoal-600 py-2.5 pl-3 pr-4 transition hover:border-charcoal-500 hover:bg-charcoal-850"
-                        >
-                          <LockClosedIcon className="size-4 text-charcoal-500" />
-                          <EnvironmentLabel environment={{ type: "STAGING" }} className="text-sm" />
-                        </TextLink>
-                      </TooltipTrigger>
-                      <TooltipContent className="flex items-center gap-2">
-                        <LockOpenIcon className="size-4 text-indigo-500" />
-                        Upgrade your plan to add a Staging environment.
-                      </TooltipContent>
-                    </Tooltip>
-                  </TooltipProvider>
+                  <>
+                    <TooltipProvider>
+                      <Tooltip>
+                        <TooltipTrigger>
+                          <TextLink
+                            to={v3BillingPath(organization)}
+                            className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-charcoal-600 py-2.5 pl-3 pr-4 transition hover:border-charcoal-500 hover:bg-charcoal-850"
+                          >
+                            <LockClosedIcon className="size-4 text-charcoal-500" />
+                            <EnvironmentLabel
+                              environment={{ type: "STAGING" }}
+                              className="text-sm"
+                            />
+                          </TextLink>
+                        </TooltipTrigger>
+                        <TooltipContent className="flex items-center gap-2">
+                          <LockOpenIcon className="size-4 text-indigo-500" />
+                          Upgrade your plan to add a Staging environment.
+                        </TooltipContent>
+                      </Tooltip>
+                    </TooltipProvider>
+                    <TooltipProvider>
+                      <Tooltip>
+                        <TooltipTrigger>
+                          <TextLink
+                            to={v3BillingPath(organization)}
+                            className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-charcoal-600 py-2.5 pl-3 pr-4 transition hover:border-charcoal-500 hover:bg-charcoal-850"
+                          >
+                            <LockClosedIcon className="size-4 text-charcoal-500" />
+                            <EnvironmentLabel
+                              environment={{ type: "PREVIEW" }}
+                              className="text-sm"
+                            />
+                          </TextLink>
+                        </TooltipTrigger>
+                        <TooltipContent className="flex items-center gap-2">
+                          <LockOpenIcon className="size-4 text-indigo-500" />
+                          Upgrade your plan to add Preview branches.
+                        </TooltipContent>
+                      </Tooltip>
+                    </TooltipProvider>
+                  </>
                 )}
               </div>
               <FormError id={environmentIds.errorId}>{environmentIds.error}</FormError>
@@ -261,6 +344,49 @@ export default function Page() {
                 file when running locally.
               </Hint>
             </InputGroup>
+
+            {previewIsSelected && (
+              <InputGroup fullWidth>
+                <Label>Select branch</Label>
+                <div className="flex items-center gap-1">
+                  <Select
+                    variant="tertiary/medium"
+                    value={selectedBranchId ?? "all"}
+                    setValue={handleBranchChange}
+                    placeholder="All branches"
+                    items={[{ id: "all", branchName: "All branches" }, ...branchEnvironments]}
+                    className="w-fit min-w-52"
+                    filter={{
+                      keys: [
+                        (item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "",
+                      ],
+                    }}
+                    text={(val) =>
+                      val ? branchEnvironments.find((b) => b.id === val)?.branchName : null
+                    }
+                    dropdownIcon
+                  >
+                    {(matches) =>
+                      matches?.map((env) => (
+                        <SelectItem key={env.id} value={env.id}>
+                          {env.branchName}
+                        </SelectItem>
+                      ))
+                    }
+                  </Select>
+                  {selectedBranchId !== "all" && selectedBranchId !== undefined && (
+                    <Button
+                      variant="minimal/medium"
+                      type="button"
+                      onClick={() => setSelectedBranchId(undefined)}
+                      LeadingIcon={XMarkIcon}
+                    />
+                  )}
+                </div>
+                <Hint>Select a branch to override variables in the Preview environment.</Hint>
+              </InputGroup>
+            )}
+
             <InputGroup className="w-auto">
               <Switch
                 name="isSecret"
@@ -461,24 +587,30 @@ function VariableField({
   return (
     <fieldset ref={ref}>
       <FieldLayout>
-        <Input
-          id={`${formId}-${baseFieldName}.key`}
-          name={`${baseFieldName}.key`}
-          placeholder="e.g. CLIENT_KEY"
-          value={value.key}
-          onChange={(e) => onChange({ ...value, key: e.currentTarget.value })}
-          autoFocus={index === 0}
-          onPaste={onPaste}
-        />
-        <div className={cn("flex items-center justify-between gap-1")}>
+        <div className="space-y-2">
           <Input
-            id={`${formId}-${baseFieldName}.value`}
-            name={`${baseFieldName}.value`}
-            type={showValue ? "text" : "password"}
-            placeholder="Not set"
-            value={value.value}
-            onChange={(e) => onChange({ ...value, value: e.currentTarget.value })}
+            id={`${formId}-${baseFieldName}.key`}
+            name={`${baseFieldName}.key`}
+            placeholder="e.g. CLIENT_KEY"
+            value={value.key}
+            onChange={(e) => onChange({ ...value, key: e.currentTarget.value })}
+            autoFocus={index === 0}
+            onPaste={onPaste}
           />
+          <FormError id={fields.key.errorId}>{fields.key.error}</FormError>
+        </div>
+        <div className={cn("flex items-start gap-1")}>
+          <div className="grow space-y-2">
+            <Input
+              id={`${formId}-${baseFieldName}.value`}
+              name={`${baseFieldName}.value`}
+              type={showValue ? "text" : "password"}
+              placeholder="Not set"
+              value={value.value}
+              onChange={(e) => onChange({ ...value, value: e.currentTarget.value })}
+            />
+            <FormError id={fields.value.errorId}>{fields.value.error}</FormError>
+          </div>
           {showDeleteButton && (
             <Button
               variant="minimal/medium"
@@ -489,10 +621,6 @@ function VariableField({
           )}
         </div>
       </FieldLayout>
-      <div className="space-y-2">
-        <FormError id={fields.key.errorId}>{fields.key.error}</FormError>
-        <FormError id={fields.value.errorId}>{fields.value.error}</FormError>
-      </div>
     </fieldset>
   );
 }
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx
index 1258c58d4d..edef3d3060 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx
@@ -4,6 +4,7 @@ import {
   BookOpenIcon,
   InformationCircleIcon,
   LockClosedIcon,
+  MagnifyingGlassIcon,
   PencilSquareIcon,
   PlusIcon,
   TrashIcon,
@@ -18,7 +19,6 @@ import {
 import { useMemo, useState } from "react";
 import { typedjson, useTypedLoaderData } from "remix-typedjson";
 import { z } from "zod";
-import { InlineCode } from "~/components/code/InlineCode";
 import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
 import { PageBody, PageContainer } from "~/components/layout/AppLayout";
 import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -34,6 +34,7 @@ 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 { Switch } from "~/components/primitives/Switch";
 import {
   Table,
@@ -47,6 +48,7 @@ import {
 import { SimpleTooltip } from "~/components/primitives/Tooltip";
 import { prisma } from "~/db.server";
 import { useEnvironment } from "~/hooks/useEnvironment";
+import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
 import { useOrganization } from "~/hooks/useOrganizations";
 import { useProject } from "~/hooks/useProject";
 import { redirectWithSuccessMessage } from "~/models/message.server";
@@ -67,6 +69,7 @@ import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/enviro
 import {
   DeleteEnvironmentVariableValue,
   EditEnvironmentVariableValue,
+  EnvironmentVariable,
 } from "~/v3/environmentVariables/repository";
 
 export const meta: MetaFunction = () => {
@@ -198,6 +201,11 @@ export default function Page() {
   const organization = useOrganization();
   const project = useProject();
   const environment = useEnvironment();
+  const { filterText, setFilterText, filteredItems } =
+    useFuzzyFilter<EnvironmentVariableWithSetValues>({
+      items: environmentVariables,
+      keys: ["key", "value"],
+    });
 
   // Add isFirst and isLast to each environment variable
   // They're set based on if they're the first or last time that `key` has been seen in the list
@@ -206,7 +214,7 @@ export default function Page() {
     const keyOccurrences = new Map<string, number>();
 
     // First pass: count total occurrences of each key
-    environmentVariables.forEach((variable) => {
+    filteredItems.forEach((variable) => {
       keyOccurrences.set(variable.key, (keyOccurrences.get(variable.key) || 0) + 1);
     });
 
@@ -214,7 +222,7 @@ export default function Page() {
     const seenKeys = new Set<string>();
     const currentOccurrences = new Map<string, number>();
 
-    return environmentVariables.map((variable) => {
+    return filteredItems.map((variable) => {
       // Track current occurrence number for this key
       const currentCount = (currentOccurrences.get(variable.key) || 0) + 1;
       currentOccurrences.set(variable.key, currentCount);
@@ -234,7 +242,7 @@ export default function Page() {
         occurences: totalOccurrences,
       };
     });
-  }, [environmentVariables]);
+  }, [filteredItems]);
 
   return (
     <PageContainer>
@@ -253,24 +261,35 @@ export default function Page() {
       <PageBody scrollable={false}>
         <div className={cn("flex h-full flex-col")}>
           {environmentVariables.length > 0 && (
-            <div className="flex items-center justify-end gap-2 px-2 py-2">
-              <Switch
-                variant="small"
-                label="Reveal values"
-                checked={revealAll}
-                onCheckedChange={(e) => setRevealAll(e.valueOf())}
+            <div className="flex items-center justify-between gap-2 px-2 py-2">
+              <Input
+                placeholder="Search variables"
+                variant="tertiary"
+                icon={MagnifyingGlassIcon}
+                fullWidth={true}
+                value={filterText}
+                onChange={(e) => setFilterText(e.target.value)}
+                autoFocus
               />
-              <LinkButton
-                to={v3NewEnvironmentVariablesPath(organization, project, environment)}
-                variant="primary/small"
-                LeadingIcon={PlusIcon}
-                shortcut={{ key: "n" }}
-              >
-                Add new
-              </LinkButton>
+              <div className="flex items-center justify-end gap-2">
+                <Switch
+                  variant="small"
+                  label="Reveal values"
+                  checked={revealAll}
+                  onCheckedChange={(e) => setRevealAll(e.valueOf())}
+                />
+                <LinkButton
+                  to={v3NewEnvironmentVariablesPath(organization, project, environment)}
+                  variant="primary/small"
+                  LeadingIcon={PlusIcon}
+                  shortcut={{ key: "n" }}
+                >
+                  Add new
+                </LinkButton>
+              </div>
             </div>
           )}
-          <Table containerClassName={cn(environmentVariables.length === 0 && "border-t-0")}>
+          <Table containerClassName={cn(filteredItems.length === 0 && "border-t-0")}>
             <TableHeader>
               <TableRow>
                 <TableHeaderCell className="w-[25%]">Key</TableHeaderCell>
@@ -354,17 +373,23 @@ export default function Page() {
               ) : (
                 <TableRow>
                   <TableCell colSpan={4}>
-                    <div className="flex flex-col items-center justify-center gap-y-4 py-8">
-                      <Header2>You haven't set any environment variables yet.</Header2>
-                      <LinkButton
-                        to={v3NewEnvironmentVariablesPath(organization, project, environment)}
-                        variant="primary/medium"
-                        LeadingIcon={PlusIcon}
-                        shortcut={{ key: "n" }}
-                      >
-                        Add new
-                      </LinkButton>
-                    </div>
+                    {environmentVariables.length === 0 ? (
+                      <div className="flex flex-col items-center justify-center gap-y-4 py-8">
+                        <Header2>You haven't set any environment variables yet.</Header2>
+                        <LinkButton
+                          to={v3NewEnvironmentVariablesPath(organization, project, environment)}
+                          variant="primary/medium"
+                          LeadingIcon={PlusIcon}
+                          shortcut={{ key: "n" }}
+                        >
+                          Add new
+                        </LinkButton>
+                      </div>
+                    ) : (
+                      <div className="flex flex-col items-center justify-center gap-y-4 py-8">
+                        <Paragraph>No variables match your search.</Paragraph>
+                      </div>
+                    )}
                   </TableCell>
                 </TableRow>
               )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx
index 24446a7eea..8ab4b24ba4 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx
@@ -168,6 +168,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
 
   const redirectPath = `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues?page=${page}`;
 
+  if (environment.archivedAt) {
+    return redirectWithErrorMessage(redirectPath, request, "This branch is archived");
+  }
+
   switch (action) {
     case "environment-pause":
       const pauseService = new PauseEnvironmentService();
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx
index 24d9cbe670..db6f641f5d 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx
@@ -1,6 +1,6 @@
 import { conform, useForm } from "@conform-to/react";
 import { parse } from "@conform-to/zod";
-import { FolderIcon } from "@heroicons/react/20/solid";
+import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
 import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
 import { type ActionFunction, json } from "@remix-run/server-runtime";
 import { z } from "zod";
@@ -272,7 +272,7 @@ export default function Page() {
                     <Input
                       {...conform.input(projectSlug, { type: "text" })}
                       placeholder="Your project slug"
-                      icon="warning"
+                      icon={ExclamationTriangleIcon}
                     />
                     <FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
                     <FormError>{deleteForm.error}</FormError>
@@ -287,7 +287,7 @@ export default function Page() {
                       <Button
                         type="submit"
                         variant={"danger/small"}
-                        LeadingIcon={isDeleteLoading ? "spinner-white" : "trash-can"}
+                        LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
                         leadingIconClassName="text-white"
                         disabled={isDeleteLoading}
                       >
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
index abdfcda4c3..c9d59a126b 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx
@@ -7,7 +7,7 @@ import { type TaskRunStatus } from "@trigger.dev/database";
 import { useCallback, useEffect, useRef, useState } from "react";
 import { typedjson, useTypedLoaderData } from "remix-typedjson";
 import { JSONEditor } from "~/components/code/JSONEditor";
-import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
+import { EnvironmentCombo, EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
 import { Button } from "~/components/primitives/Buttons";
 import { Callout } from "~/components/primitives/Callout";
 import { DateField } from "~/components/primitives/DateField";
@@ -115,6 +115,10 @@ export const action: ActionFunction = async ({ request, params }) => {
     return redirectBackWithErrorMessage(request, "Environment not found");
   }
 
+  if (environment.archivedAt) {
+    return redirectBackWithErrorMessage(request, "Can't run a test on an archived environment");
+  }
+
   const testService = new TestTaskService();
   try {
     const run = await testService.call(environment, submission.value);
@@ -336,7 +340,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
           <Paragraph variant="small" className="whitespace-nowrap">
             This test will run in
           </Paragraph>
-          <EnvironmentLabel environment={environment} className="text-sm" />
+          <EnvironmentCombo environment={environment} className="gap-0.5" />
         </div>
         <Button
           type="submit"
@@ -536,7 +540,7 @@ function ScheduledTaskForm({
           <Paragraph variant="small" className="whitespace-nowrap">
             This test will run in
           </Paragraph>
-          <EnvironmentLabel environment={environment} className="text-sm" />
+          <EnvironmentCombo environment={environment} className="gap-0.5" />
         </div>
         <Button
           type="submit"
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx
index 7e32f08244..b3233abb85 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx
@@ -32,6 +32,7 @@ import {
   WaitpointSearchParamsSchema,
   WaitpointTokenFilters,
 } from "~/components/runs/v3/WaitpointTokenFilters";
+import { V4Title } from "~/components/V4Badge";
 import { useEnvironment } from "~/hooks/useEnvironment";
 import { useOrganization } from "~/hooks/useOrganizations";
 import { useProject } from "~/hooks/useProject";
@@ -115,7 +116,7 @@ export default function Page() {
   return (
     <PageContainer>
       <NavBar>
-        <PageTitle title="Waitpoint Tokens" />
+        <PageTitle title={<V4Title>Waitpoint Tokens</V4Title>} />
         <PageAccessories>
           <AdminDebugTooltip />
           <LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/wait")}>
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx
index 74cbb11de4..df9ee24be9 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx
@@ -1,10 +1,11 @@
 import { Outlet } from "@remix-run/react";
-import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
 import { prisma } from "~/db.server";
+import { redirectWithErrorMessage } from "~/models/message.server";
 import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
 import { logger } from "~/services/logger.server";
 import { requireUser } from "~/services/session.server";
-import { EnvironmentParamSchema } from "~/utils/pathBuilder";
+import { EnvironmentParamSchema, v3ProjectPath } from "~/utils/pathBuilder";
 
 export const loader = async ({ request, params }: LoaderFunctionArgs) => {
   const user = await requireUser(request);
@@ -47,10 +48,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
 
   const environments = project.environments.filter((env) => env.slug === envParam);
   if (environments.length === 0) {
-    throw new Response("Environment not Found", {
-      status: 404,
-      statusText: "Environment not found",
-    });
+    return redirect(v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }));
   }
 
   let environmentId: string | undefined = undefined;
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx
index 5a32667a26..8a22bdefc9 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx
@@ -12,7 +12,6 @@ import { requireUser } from "~/services/session.server";
 import { telemetry } from "~/services/telemetry.server";
 import { organizationPath } from "~/utils/pathBuilder";
 import { isEnvironmentPauseResumeFormSubmission } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route";
-import { logger } from "~/services/logger.server";
 
 const ParamsSchema = z.object({
   organizationSlug: z.string(),
diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts
index 8483058f32..6a8628f752 100644
--- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts
+++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.environments.staging.ts
@@ -1,9 +1,14 @@
-import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
+import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
+import {
+  type RuntimeEnvironment,
+  type Organization,
+  type Project,
+  type RuntimeEnvironmentType,
+} from "@trigger.dev/database";
 import { z } from "zod";
 import { prisma } from "~/db.server";
 import { createEnvironment } from "~/models/organization.server";
 import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
-import { marqs } from "~/v3/marqs/index.server";
 import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server";
 
 const ParamsSchema = z.object({
@@ -55,16 +60,39 @@ export async function action({ request, params }: ActionFunctionArgs) {
   let created = 0;
 
   for (const project of organization.projects) {
-    const stagingEnvironment = project.environments.find((env) => env.type === "STAGING");
+    const stagingResult = await upsertEnvironment(organization, project, "STAGING", false);
+    if (stagingResult.status === "created") {
+      created++;
+    }
 
-    if (!stagingEnvironment) {
-      const staging = await createEnvironment(organization, project, "STAGING");
-      await updateEnvConcurrencyLimits({ ...staging, organization, project });
+    const previewResult = await upsertEnvironment(organization, project, "PREVIEW", true);
+    if (previewResult.status === "created") {
       created++;
-    } else {
-      await updateEnvConcurrencyLimits({ ...stagingEnvironment, organization, project });
     }
   }
 
   return json({ success: true, created, total: organization.projects.length });
 }
+
+async function upsertEnvironment(
+  organization: Organization,
+  project: Project & { environments: RuntimeEnvironment[] },
+  type: RuntimeEnvironmentType,
+  isBranchableEnvironment: boolean
+) {
+  const existingEnvironment = project.environments.find((env) => env.type === type);
+
+  if (!existingEnvironment) {
+    const newEnvironment = await createEnvironment({
+      organization,
+      project,
+      type,
+      isBranchableEnvironment,
+    });
+    await updateEnvConcurrencyLimits({ ...newEnvironment, organization, project });
+    return { status: "created", environment: newEnvironment };
+  } else {
+    await updateEnvConcurrencyLimits({ ...existingEnvironment, organization, project });
+    return { status: "updated", environment: existingEnvironment };
+  }
+}
diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts
index ad92e0a995..cb5adeaf0d 100644
--- a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts
+++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.ts
@@ -1,6 +1,6 @@
-import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { json } from "@remix-run/server-runtime";
-import { GetProjectEnvResponse } from "@trigger.dev/core/v3";
+import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { type GetProjectEnvResponse } from "@trigger.dev/core/v3";
+import { type RuntimeEnvironment } from "@trigger.dev/database";
 import { z } from "zod";
 import { prisma } from "~/db.server";
 import { env as processEnv } from "~/env.server";
@@ -9,9 +9,11 @@ import { authenticateApiRequestWithPersonalAccessToken } from "~/services/person
 
 const ParamsSchema = z.object({
   projectRef: z.string(),
-  env: z.enum(["dev", "staging", "prod"]),
+  env: z.enum(["dev", "staging", "prod", "preview"]),
 });
 
+type ParamsSchema = z.infer<typeof ParamsSchema>;
+
 export async function loader({ request, params }: LoaderFunctionArgs) {
   logger.info("projects get env", { url: request.url });
 
@@ -29,61 +31,34 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
 
   const { projectRef, env } = parsedParams.data;
 
-  const project =
-    env === "dev"
-      ? await prisma.project.findUnique({
-          where: {
-            externalRef: projectRef,
-            organization: {
-              members: {
-                some: {
-                  userId: authenticationResult.userId,
-                },
-              },
-            },
-          },
-          include: {
-            environments: {
-              where: {
-                orgMember: {
-                  userId: authenticationResult.userId,
-                },
-              },
-            },
-          },
-        })
-      : await prisma.project.findUnique({
-          where: {
-            externalRef: projectRef,
-            organization: {
-              members: {
-                some: {
-                  userId: authenticationResult.userId,
-                },
-              },
-            },
+  const project = await prisma.project.findFirst({
+    where: {
+      externalRef: projectRef,
+      organization: {
+        members: {
+          some: {
+            userId: authenticationResult.userId,
           },
-          include: {
-            environments: {
-              where: {
-                slug: env === "prod" ? "prod" : "stg",
-              },
-            },
-          },
-        });
+        },
+      },
+    },
+  });
 
   if (!project) {
     return json({ error: "Project not found" }, { status: 404 });
   }
 
-  if (!project.environments.length) {
-    return json(
-      { error: `Environment "${env}" not found or is unsupported for this project.` },
-      { status: 404 }
-    );
+  const envResult = await getEnvironmentFromEnv({
+    projectId: project.id,
+    userId: authenticationResult.userId,
+    env,
+  });
+
+  if (!envResult.success) {
+    return json({ error: envResult.error }, { status: 404 });
   }
 
-  const runtimeEnv = project.environments[0];
+  const runtimeEnv = envResult.environment;
 
   const result: GetProjectEnvResponse = {
     apiKey: runtimeEnv.apiKey,
@@ -94,3 +69,79 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
 
   return json(result);
 }
+
+async function getEnvironmentFromEnv({
+  projectId,
+  userId,
+  env,
+}: {
+  projectId: string;
+  userId: string;
+  env: ParamsSchema["env"];
+}): Promise<
+  | {
+      success: true;
+      environment: RuntimeEnvironment;
+    }
+  | {
+      success: false;
+      error: string;
+    }
+> {
+  if (env === "dev") {
+    const environment = await prisma.runtimeEnvironment.findFirst({
+      where: {
+        projectId,
+        orgMember: {
+          userId: userId,
+        },
+      },
+    });
+
+    if (!environment) {
+      return {
+        success: false,
+        error: "Dev environment not found",
+      };
+    }
+
+    return {
+      success: true,
+      environment,
+    };
+  }
+
+  let slug: "stg" | "prod" | "preview" = "prod";
+  switch (env) {
+    case "staging":
+      slug = "stg";
+      break;
+    case "prod":
+      slug = "prod";
+      break;
+    case "preview":
+      slug = "preview";
+      break;
+    default:
+      break;
+  }
+
+  const environment = await prisma.runtimeEnvironment.findFirst({
+    where: {
+      projectId,
+      slug,
+    },
+  });
+
+  if (!environment) {
+    return {
+      success: false,
+      error: `${env === "staging" ? "Staging" : "Production"} environment not found`,
+    };
+  }
+
+  return {
+    success: true,
+    environment,
+  };
+}
diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts
new file mode 100644
index 0000000000..76147979c0
--- /dev/null
+++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.archive.ts
@@ -0,0 +1,86 @@
+import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
+import { tryCatch } from "@trigger.dev/core";
+import { z } from "zod";
+import { prisma } from "~/db.server";
+import { ArchiveBranchService } from "~/services/archiveBranch.server";
+import { logger } from "~/services/logger.server";
+import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
+
+const ParamsSchema = z.object({
+  projectRef: z.string(),
+});
+
+const BodySchema = z.object({
+  branch: z.string(),
+});
+
+export async function action({ request, params }: ActionFunctionArgs) {
+  if (request.method !== "POST") {
+    return json({ error: "Method not allowed" }, { status: 405 });
+  }
+
+  logger.info("Archive branch", { url: request.url, params });
+
+  const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
+  if (!authenticationResult) {
+    return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
+  }
+
+  const parsedParams = ParamsSchema.safeParse(params);
+
+  if (!parsedParams.success) {
+    return json({ error: "Invalid Params" }, { status: 400 });
+  }
+
+  const { projectRef } = parsedParams.data;
+
+  const [error, body] = await tryCatch(request.json());
+  if (error) {
+    return json({ error: error.message }, { status: 400 });
+  }
+
+  const parsed = BodySchema.safeParse(body);
+  if (!parsed.success) {
+    return json({ error: parsed.error.message }, { status: 400 });
+  }
+
+  const environments = await prisma.runtimeEnvironment.findMany({
+    select: {
+      id: true,
+      archivedAt: true,
+    },
+    where: {
+      organization: {
+        members: {
+          some: {
+            userId: authenticationResult.userId,
+          },
+        },
+      },
+      project: {
+        externalRef: projectRef,
+      },
+      branchName: parsed.data.branch,
+    },
+  });
+
+  if (environments.length === 0) {
+    return json({ error: "Branch not found" }, { status: 404 });
+  }
+
+  const environment = environments.find((env) => env.archivedAt === null);
+  if (!environment) {
+    return json({ error: "Branch already archived" }, { status: 400 });
+  }
+
+  const service = new ArchiveBranchService();
+  const result = await service.call(authenticationResult.userId, {
+    environmentId: environment.id,
+  });
+
+  if (result.success) {
+    return json(result);
+  } else {
+    return json(result, { status: 400 });
+  }
+}
diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts
new file mode 100644
index 0000000000..21654580bf
--- /dev/null
+++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.branches.ts
@@ -0,0 +1,95 @@
+import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
+import { tryCatch, UpsertBranchRequestBody } from "@trigger.dev/core/v3";
+import { z } from "zod";
+import { prisma } from "~/db.server";
+import { logger } from "~/services/logger.server";
+import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
+import { UpsertBranchService } from "~/services/upsertBranch.server";
+
+const ParamsSchema = z.object({
+  projectRef: z.string(),
+});
+
+type ParamsSchema = z.infer<typeof ParamsSchema>;
+
+export async function action({ request, params }: ActionFunctionArgs) {
+  if (request.method !== "POST") {
+    return json({ error: "Method not allowed" }, { status: 405 });
+  }
+
+  logger.info("project upsert branch", { url: request.url });
+
+  const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
+  if (!authenticationResult) {
+    return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
+  }
+
+  const parsedParams = ParamsSchema.safeParse(params);
+
+  if (!parsedParams.success) {
+    return json({ error: "Invalid Params" }, { status: 400 });
+  }
+
+  const { projectRef } = parsedParams.data;
+
+  const project = await prisma.project.findFirst({
+    select: {
+      id: true,
+    },
+    where: {
+      externalRef: projectRef,
+      organization: {
+        members: {
+          some: {
+            userId: authenticationResult.userId,
+          },
+        },
+      },
+    },
+  });
+  if (!project) {
+    return json({ error: "Project not found" }, { status: 404 });
+  }
+
+  const [error, body] = await tryCatch(request.json());
+  if (error) {
+    return json({ error: error.message }, { status: 400 });
+  }
+
+  const parsed = UpsertBranchRequestBody.safeParse(body);
+  if (!parsed.success) {
+    return json({ error: parsed.error.message }, { status: 400 });
+  }
+
+  const previewEnvironment = await prisma.runtimeEnvironment.findFirst({
+    select: {
+      id: true,
+    },
+    where: {
+      projectId: project.id,
+      slug: "preview",
+    },
+  });
+
+  if (!previewEnvironment) {
+    return json(
+      { error: "You don't have preview branches setup. Go to the dashboard to enable them." },
+      { status: 400 }
+    );
+  }
+
+  const { branch, env, git } = parsed.data;
+
+  const service = new UpsertBranchService();
+  const result = await service.call(authenticationResult.userId, {
+    branchName: branch,
+    parentEnvironmentId: previewEnvironment.id,
+    git,
+  });
+
+  if (!result.success) {
+    return json({ error: result.error }, { status: 400 });
+  }
+
+  return json(result.branch);
+}
diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts
index 2b63697990..7682f6bbbe 100644
--- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts
+++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.$name.ts
@@ -125,7 +125,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
 
   const variables = await repository.getEnvironmentWithRedactedSecrets(
     environment.project.id,
-    environment.id
+    environment.id,
+    environment.parentEnvironmentId ?? undefined
   );
 
   const environmentVariable = variables.find((v) => v.key === parsedParams.data.name);
diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts
index a90eac3b4a..a44b036abf 100644
--- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts
+++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.import.ts
@@ -5,6 +5,7 @@ import { z } from "zod";
 import {
   authenticateProjectApiKeyOrPersonalAccessToken,
   authenticatedEnvironmentForAuthentication,
+  branchNameFromRequest,
 } from "~/services/apiAuth.server";
 import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
 
@@ -29,7 +30,8 @@ export async function action({ params, request }: ActionFunctionArgs) {
   const environment = await authenticatedEnvironmentForAuthentication(
     authenticationResult,
     parsedParams.data.projectRef,
-    parsedParams.data.slug
+    parsedParams.data.slug,
+    branchNameFromRequest(request)
   );
 
   const repository = new EnvironmentVariablesRepository();
diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts
index eae0e586e7..6fb1cfba1d 100644
--- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts
+++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts
@@ -82,7 +82,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
 
   const variables = await repository.getEnvironmentWithRedactedSecrets(
     environment.project.id,
-    environment.id
+    environment.id,
+    environment.parentEnvironmentId ?? undefined
   );
 
   return json(
diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts
index 4ea3960729..151c182e16 100644
--- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts
+++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.ts
@@ -1,4 +1,4 @@
-import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
+import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
 import { z } from "zod";
 import { prisma } from "~/db.server";
 import { authenticateApiRequest } from "~/services/apiAuth.server";
@@ -17,13 +17,10 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
 
   // Next authenticate the request
   const authenticationResult = await authenticateApiRequest(request);
-
   if (!authenticationResult) {
     return json({ error: "Invalid or Missing API key" }, { status: 401 });
   }
 
-  const authenticatedEnv = authenticationResult.environment;
-
   const { projectRef } = parsedParams.data;
 
   const project = await prisma.project.findFirst({
@@ -31,7 +28,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
       externalRef: projectRef,
       environments: {
         some: {
-          id: authenticatedEnv.id,
+          id: authenticationResult.environment.id,
         },
       },
     },
@@ -41,7 +38,23 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
     return json({ error: "Project not found" }, { status: 404 });
   }
 
-  const variables = await resolveVariablesForEnvironment(authenticatedEnv);
+  const envVarEnvironment = await prisma.runtimeEnvironment.findFirst({
+    where: {
+      id: authenticationResult.environment.id,
+    },
+    include: {
+      parentEnvironment: true,
+    },
+  });
+
+  if (!envVarEnvironment) {
+    return json({ error: "Environment not found" }, { status: 404 });
+  }
+
+  const variables = await resolveVariablesForEnvironment(
+    envVarEnvironment,
+    envVarEnvironment.parentEnvironment ?? undefined
+  );
 
   return json({
     variables: variables.reduce((acc: Record<string, string>, variable) => {
diff --git a/apps/webapp/app/routes/resources.branches.archive.tsx b/apps/webapp/app/routes/resources.branches.archive.tsx
new file mode 100644
index 0000000000..6658738ce0
--- /dev/null
+++ b/apps/webapp/app/routes/resources.branches.archive.tsx
@@ -0,0 +1,127 @@
+import { conform, useForm } from "@conform-to/react";
+import { parse } from "@conform-to/zod";
+import { DialogClose } from "@radix-ui/react-dialog";
+import { Form, useActionData, useFetcher, useLocation } from "@remix-run/react";
+import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { ArchiveIcon } from "~/assets/icons/ArchiveIcon";
+import { Button } from "~/components/primitives/Buttons";
+import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
+import { FormButtons } from "~/components/primitives/FormButtons";
+import { FormError } from "~/components/primitives/FormError";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
+import { ArchiveBranchService } from "~/services/archiveBranch.server";
+import { requireUserId } from "~/services/session.server";
+import { branchesPath, v3EnvironmentPath } from "~/utils/pathBuilder";
+
+const ArchiveBranchOptions = z.object({
+  environmentId: z.string(),
+});
+
+const schema = ArchiveBranchOptions.and(
+  z.object({
+    redirectPath: z.string(),
+  })
+);
+
+export async function action({ request }: ActionFunctionArgs) {
+  const userId = await requireUserId(request);
+
+  const formData = await request.formData();
+  const submission = parse(formData, { schema });
+
+  if (!submission.value) {
+    return redirectWithErrorMessage("/", request, "Invalid form data");
+  }
+
+  const archiveBranchService = new ArchiveBranchService();
+
+  const result = await archiveBranchService.call(userId, submission.value);
+
+  if (result.success) {
+    return redirectWithSuccessMessage(
+      branchesPath(result.organization, result.project, result.branch),
+      request,
+      `Branch "${result.branch.branchName}" archived`
+    );
+  }
+
+  return redirectWithErrorMessage(submission.value.redirectPath, request, result.error);
+}
+
+export function ArchiveButton({
+  environment,
+}: {
+  environment: { id: string; branchName: string };
+}) {
+  const lastSubmission = useActionData<typeof action>();
+  const location = useLocation();
+
+  const [form, { environmentId, redirectPath }] = useForm({
+    id: "archive-branch",
+    lastSubmission: lastSubmission as any,
+    onValidate({ formData }) {
+      return parse(formData, { schema });
+    },
+    shouldRevalidate: "onInput",
+  });
+
+  return (
+    <Dialog>
+      <DialogTrigger asChild>
+        <Button
+          variant="small-menu-item"
+          LeadingIcon={ArchiveIcon}
+          leadingIconClassName="text-error"
+          fullWidth
+          textAlignLeft
+          className="w-full px-1.5 py-[0.9rem]"
+        >
+          Archive branch
+        </Button>
+      </DialogTrigger>
+      <DialogContent>
+        <DialogHeader>Archive "{environment.branchName}"</DialogHeader>
+        <div className="mt-2 flex flex-col gap-4">
+          <Form
+            method="post"
+            action="/resources/branches/archive"
+            {...form.props}
+            className="w-full"
+          >
+            <input value={environment.id} {...conform.input(environmentId, { type: "hidden" })} />
+            <input
+              value={`${location.pathname}${location.search}`}
+              {...conform.input(redirectPath, { type: "hidden" })}
+            />
+            <Paragraph spacing>
+              This will <span className="text-text-bright">permanently</span> make this branch{" "}
+              <span className="text-text-bright">read-only</span>. You won't be able to trigger
+              runs, execute runs, or use the API for this branch.
+            </Paragraph>
+            <Paragraph spacing>
+              You will still be able to view the branch and its associated runs.
+            </Paragraph>
+            <Paragraph spacing>
+              Once archived you can create a new branch with the same name.
+            </Paragraph>
+            <FormError>{form.error}</FormError>
+            <FormButtons
+              confirmButton={
+                <Button LeadingIcon={ArchiveIcon} type="submit" variant="danger/medium">
+                  Archive branch
+                </Button>
+              }
+              cancelButton={
+                <DialogClose asChild>
+                  <Button variant="tertiary/medium">Cancel</Button>
+                </DialogClose>
+              }
+            />
+          </Form>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx
index 1d96293d0d..4871a2d0b7 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx
@@ -195,6 +195,10 @@ const pricingDefinitions = {
     content:
       "Realtime allows you to send the live status and data from your runs to your frontend. This is the number of simultaneous Realtime connections that can be made.",
   },
+  branches: {
+    title: "Branches",
+    content: "The number of preview branches that can be active (you can archive old ones).",
+  },
 };
 
 type PricingPlansProps = {
@@ -495,6 +499,7 @@ export function TierFree({
             </FeatureItem>
             <TeamMembers limits={plan.limits} />
             <Environments limits={plan.limits} />
+            <Branches limits={plan.limits} />
             <Schedules limits={plan.limits} />
             <LogRetention limits={plan.limits} />
             <SupportLevel limits={plan.limits} />
@@ -609,7 +614,9 @@ export function TierHobby({
             tasks
           </DefinitionTip>
         </FeatureItem>
-        <TeamMembers limits={plan.limits} /> <Environments limits={plan.limits} />
+        <TeamMembers limits={plan.limits} />
+        <Environments limits={plan.limits} />
+        <Branches limits={plan.limits} />
         <Schedules limits={plan.limits} />
         <LogRetention limits={plan.limits} />
         <SupportLevel limits={plan.limits} />
@@ -726,6 +733,7 @@ export function TierPro({
         </FeatureItem>
         <TeamMembers limits={plan.limits} />
         <Environments limits={plan.limits} />
+        <Branches limits={plan.limits} />
         <Schedules limits={plan.limits} />
         <LogRetention limits={plan.limits} />
         <SupportLevel limits={plan.limits} />
@@ -938,7 +946,7 @@ function TeamMembers({ limits }: { limits: Limits }) {
 function Environments({ limits }: { limits: Limits }) {
   return (
     <FeatureItem checked>
-      {limits.hasStagingEnvironment ? "Dev, Staging and Prod" : "Dev and Prod"}{" "}
+      {limits.hasStagingEnvironment ? "Dev, Preview and Prod" : "Dev and Prod"}{" "}
       <DefinitionTip
         title={pricingDefinitions.environment.title}
         content={pricingDefinitions.environment.content}
@@ -1018,3 +1026,18 @@ function RealtimeConnecurrency({ limits }: { limits: Limits }) {
     </FeatureItem>
   );
 }
+
+function Branches({ limits }: { limits: Limits }) {
+  return (
+    <FeatureItem checked={limits.branches.number > 0}>
+      {limits.branches.number}
+      {limits.branches.canExceed ? "+ " : " "}
+      <DefinitionTip
+        title={pricingDefinitions.branches.title}
+        content={pricingDefinitions.branches.content}
+      >
+        preview branches
+      </DefinitionTip>
+    </FeatureItem>
+  );
+}
diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
index 0fcaf0981f..431cc9f34c 100644
--- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
+++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
@@ -32,6 +32,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
               id: true,
               type: true,
               slug: true,
+              branchName: true,
               orgMember: {
                 select: {
                   user: true,
@@ -39,6 +40,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
               },
             },
             where: {
+              archivedAt: null,
               OR: [
                 {
                   type: {
@@ -72,10 +74,21 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
   return typedjson({
     payload: await prettyPrintPacket(run.payload, run.payloadType),
     payloadType: run.payloadType,
-    environment: displayableEnvironment(environment, userId),
+    environment: {
+      ...displayableEnvironment(environment, userId),
+      branchName: environment.branchName ?? undefined,
+    },
     environments: sortEnvironments(
-      run.project.environments.map((environment) => displayableEnvironment(environment, userId))
-    ),
+      run.project.environments.map((environment) => {
+        return {
+          ...displayableEnvironment(environment, userId),
+          branchName: environment.branchName ?? undefined,
+        };
+      })
+    ).filter((env) => {
+      if (env.type === "PREVIEW" && !env.branchName) return false;
+      return true;
+    }),
   });
 }
 
diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts
index 03c5f128ee..bed78809b6 100644
--- a/apps/webapp/app/services/apiAuth.server.ts
+++ b/apps/webapp/app/services/apiAuth.server.ts
@@ -1,23 +1,23 @@
 import { json } from "@remix-run/server-runtime";
-import { Prettify } from "@trigger.dev/core";
+import { type Prettify } from "@trigger.dev/core";
 import { SignJWT, errors, jwtVerify } from "jose";
 import { z } from "zod";
 import { prisma } from "~/db.server";
 import { env } from "~/env.server";
 import { findProjectByRef } from "~/models/project.server";
 import {
-  RuntimeEnvironment,
   findEnvironmentByApiKey,
   findEnvironmentByPublicApiKey,
 } from "~/models/runtimeEnvironment.server";
+import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
 import { logger } from "./logger.server";
 import {
-  PersonalAccessTokenAuthenticationResult,
+  type PersonalAccessTokenAuthenticationResult,
   authenticateApiRequestWithPersonalAccessToken,
   isPersonalAccessToken,
 } from "./personalAccessToken.server";
 import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
-import { RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
+import { sanitizeBranchName } from "~/v3/gitBranch";
 
 const ClaimsSchema = z.object({
   scopes: z.array(z.string()).optional(),
@@ -57,13 +57,13 @@ export async function authenticateApiRequest(
   request: Request,
   options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
 ): Promise<ApiAuthenticationResultSuccess | undefined> {
-  const apiKey = getApiKeyFromRequest(request);
+  const { apiKey, branchName } = getApiKeyFromRequest(request);
 
   if (!apiKey) {
     return;
   }
 
-  const authentication = await authenticateApiKey(apiKey, options);
+  const authentication = await authenticateApiKey(apiKey, { ...options, branchName });
 
   return authentication;
 }
@@ -76,7 +76,7 @@ export async function authenticateApiRequestWithFailure(
   request: Request,
   options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
 ): Promise<ApiAuthenticationResult> {
-  const apiKey = getApiKeyFromRequest(request);
+  const { apiKey, branchName } = getApiKeyFromRequest(request);
 
   if (!apiKey) {
     return {
@@ -85,7 +85,7 @@ export async function authenticateApiRequestWithFailure(
     };
   }
 
-  const authentication = await authenticateApiKeyWithFailure(apiKey, options);
+  const authentication = await authenticateApiKeyWithFailure(apiKey, { ...options, branchName });
 
   return authentication;
 }
@@ -95,7 +95,7 @@ export async function authenticateApiRequestWithFailure(
  */
 export async function authenticateApiKey(
   apiKey: string,
-  options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
+  options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {}
 ): Promise<ApiAuthenticationResultSuccess | undefined> {
   const result = getApiKeyResult(apiKey);
 
@@ -113,7 +113,7 @@ export async function authenticateApiKey(
 
   switch (result.type) {
     case "PUBLIC": {
-      const environment = await findEnvironmentByPublicApiKey(result.apiKey);
+      const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName);
       if (!environment) {
         return;
       }
@@ -125,7 +125,7 @@ export async function authenticateApiKey(
       };
     }
     case "PRIVATE": {
-      const environment = await findEnvironmentByApiKey(result.apiKey);
+      const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName);
       if (!environment) {
         return;
       }
@@ -160,9 +160,9 @@ export async function authenticateApiKey(
  * This method is the same as `authenticateApiKey` but it returns a failure result instead of undefined.
  * It should be used from now on to ensure that the API key is always validated and provide a failure result.
  */
-export async function authenticateApiKeyWithFailure(
+async function authenticateApiKeyWithFailure(
   apiKey: string,
-  options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
+  options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {}
 ): Promise<ApiAuthenticationResult> {
   const result = getApiKeyResult(apiKey);
 
@@ -189,7 +189,7 @@ export async function authenticateApiKeyWithFailure(
 
   switch (result.type) {
     case "PUBLIC": {
-      const environment = await findEnvironmentByPublicApiKey(result.apiKey);
+      const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName);
       if (!environment) {
         return {
           ok: false,
@@ -204,7 +204,7 @@ export async function authenticateApiKeyWithFailure(
       };
     }
     case "PRIVATE": {
-      const environment = await findEnvironmentByApiKey(result.apiKey);
+      const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName);
       if (!environment) {
         return {
           ok: false,
@@ -254,19 +254,29 @@ export async function authenticateAuthorizationHeader(
   return authenticateApiKey(apiKey, { allowPublicKey, allowJWT });
 }
 
-export function isPublicApiKey(key: string) {
+function isPublicApiKey(key: string) {
   return key.startsWith("pk_");
 }
 
-export function isSecretApiKey(key: string) {
+function isSecretApiKey(key: string) {
   return key.startsWith("tr_");
 }
 
-export function getApiKeyFromRequest(request: Request) {
-  return getApiKeyFromHeader(request.headers.get("Authorization"));
+export function branchNameFromRequest(request: Request): string | undefined {
+  return request.headers.get("x-trigger-branch") ?? undefined;
 }
 
-export function getApiKeyFromHeader(authorization?: string | null) {
+function getApiKeyFromRequest(request: Request): {
+  apiKey: string | undefined;
+  branchName: string | undefined;
+} {
+  const apiKey = getApiKeyFromHeader(request.headers.get("Authorization"));
+  const branchName = branchNameFromRequest(request);
+
+  return { apiKey, branchName };
+}
+
+function getApiKeyFromHeader(authorization?: string | null) {
   if (typeof authorization !== "string" || !authorization) {
     return;
   }
@@ -275,7 +285,7 @@ export function getApiKeyFromHeader(authorization?: string | null) {
   return apiKey;
 }
 
-export function getApiKeyResult(apiKey: string): {
+function getApiKeyResult(apiKey: string): {
   apiKey: string;
   type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
 } {
@@ -302,7 +312,7 @@ export type DualAuthenticationResult =
 export async function authenticateProjectApiKeyOrPersonalAccessToken(
   request: Request
 ): Promise<DualAuthenticationResult | undefined> {
-  const apiKey = getApiKeyFromRequest(request);
+  const { apiKey, branchName } = getApiKeyFromRequest(request);
   if (!apiKey) {
     return;
   }
@@ -320,7 +330,7 @@ export async function authenticateProjectApiKeyOrPersonalAccessToken(
     };
   }
 
-  const result = await authenticateApiKey(apiKey, { allowPublicKey: false });
+  const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName });
 
   if (!result) {
     return;
@@ -335,7 +345,8 @@ export async function authenticateProjectApiKeyOrPersonalAccessToken(
 export async function authenticatedEnvironmentForAuthentication(
   auth: DualAuthenticationResult,
   projectRef: string,
-  slug: string
+  slug: string,
+  branch?: string
 ): Promise<AuthenticatedEnvironment> {
   if (slug === "staging") {
     slug = "stg";
@@ -357,7 +368,7 @@ export async function authenticatedEnvironmentForAuthentication(
         );
       }
 
-      if (auth.result.environment.slug !== slug) {
+      if (auth.result.environment.slug !== slug && auth.result.environment.branchName !== branch) {
         throw json(
           {
             error:
@@ -386,22 +397,53 @@ export async function authenticatedEnvironmentForAuthentication(
         throw json({ error: "Project not found" }, { status: 404 });
       }
 
+      if (!branch) {
+        const environment = await prisma.runtimeEnvironment.findFirst({
+          where: {
+            projectId: project.id,
+            slug: slug,
+          },
+          include: {
+            project: true,
+            organization: true,
+          },
+        });
+
+        if (!environment) {
+          throw json({ error: "Environment not found" }, { status: 404 });
+        }
+
+        return environment;
+      }
+
       const environment = await prisma.runtimeEnvironment.findFirst({
         where: {
           projectId: project.id,
           slug: slug,
+          branchName: sanitizeBranchName(branch),
+          archivedAt: null,
         },
         include: {
           project: true,
           organization: true,
+          parentEnvironment: true,
         },
       });
 
       if (!environment) {
-        throw json({ error: "Environment not found" }, { status: 404 });
+        throw json({ error: "Branch not found" }, { status: 404 });
       }
 
-      return environment;
+      if (!environment.parentEnvironment) {
+        throw json({ error: "Branch not associated with a preview environment" }, { status: 400 });
+      }
+
+      return {
+        ...environment,
+        apiKey: environment.parentEnvironment.apiKey,
+        organization: environment.organization,
+        project: environment.project,
+      };
     }
   }
 }
diff --git a/apps/webapp/app/services/archiveBranch.server.ts b/apps/webapp/app/services/archiveBranch.server.ts
new file mode 100644
index 0000000000..e0ff0e1174
--- /dev/null
+++ b/apps/webapp/app/services/archiveBranch.server.ts
@@ -0,0 +1,72 @@
+import { type PrismaClient } from "@trigger.dev/database";
+import { prisma } from "~/db.server";
+import { logger } from "./logger.server";
+import { nanoid } from "nanoid";
+
+export class ArchiveBranchService {
+  #prismaClient: PrismaClient;
+
+  constructor(prismaClient: PrismaClient = prisma) {
+    this.#prismaClient = prismaClient;
+  }
+
+  public async call(userId: string, { environmentId }: { environmentId: string }) {
+    try {
+      const environment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({
+        where: {
+          id: environmentId,
+          organization: {
+            members: {
+              some: {
+                userId: userId,
+              },
+            },
+          },
+        },
+        include: {
+          organization: {
+            select: {
+              id: true,
+              slug: true,
+              maximumConcurrencyLimit: true,
+            },
+          },
+          project: {
+            select: {
+              id: true,
+              slug: true,
+            },
+          },
+        },
+      });
+
+      if (!environment.parentEnvironmentId) {
+        return {
+          success: false as const,
+          error: "This isn't a branch, and cannot be archived.",
+        };
+      }
+
+      const slug = `${environment.slug}-${nanoid(6)}`;
+      const shortcode = slug;
+
+      const updatedBranch = await this.#prismaClient.runtimeEnvironment.update({
+        where: { id: environmentId },
+        data: { archivedAt: new Date(), slug, shortcode },
+      });
+
+      return {
+        success: true as const,
+        branch: updatedBranch,
+        organization: environment.organization,
+        project: environment.project,
+      };
+    } catch (e) {
+      logger.error("ArchiveBranchService error", { environmentId, error: e });
+      return {
+        success: false as const,
+        error: "Failed to archive branch",
+      };
+    }
+  }
+}
diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts
index c4638fbff1..f12270c1e6 100644
--- a/apps/webapp/app/services/platform.v3.server.ts
+++ b/apps/webapp/app/services/platform.v3.server.ts
@@ -346,13 +346,25 @@ export async function getEntitlement(organizationId: string) {
 }
 
 export async function projectCreated(organization: Organization, project: Project) {
-  if (project.version === "V2" || !isCloud()) {
-    await createEnvironment(organization, project, "STAGING");
+  if (!isCloud()) {
+    await createEnvironment({ organization, project, type: "STAGING" });
+    await createEnvironment({
+      organization,
+      project,
+      type: "PREVIEW",
+      isBranchableEnvironment: true,
+    });
   } else {
     //staging is only available on certain plans
     const plan = await getCurrentPlan(organization.id);
     if (plan?.v3Subscription.plan?.limits.hasStagingEnvironment) {
-      await createEnvironment(organization, project, "STAGING");
+      await createEnvironment({ organization, project, type: "STAGING" });
+      await createEnvironment({
+        organization,
+        project,
+        type: "PREVIEW",
+        isBranchableEnvironment: true,
+      });
     }
   }
 }
diff --git a/apps/webapp/app/services/realtime/jwtAuth.server.ts b/apps/webapp/app/services/realtime/jwtAuth.server.ts
index 9a6082f588..d5950d99d3 100644
--- a/apps/webapp/app/services/realtime/jwtAuth.server.ts
+++ b/apps/webapp/app/services/realtime/jwtAuth.server.ts
@@ -33,7 +33,10 @@ export async function validatePublicJwtKey(token: string): Promise<ValidatePubli
     return { ok: false, error: "Invalid Public Access Token, environment not found." };
   }
 
-  const result = await validateJWT(token, environment.apiKey);
+  const result = await validateJWT(
+    token,
+    environment.parentEnvironment?.apiKey ?? environment.apiKey
+  );
 
   if (!result.ok) {
     switch (result.code) {
diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts
new file mode 100644
index 0000000000..4aaeb66349
--- /dev/null
+++ b/apps/webapp/app/services/upsertBranch.server.ts
@@ -0,0 +1,169 @@
+import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database";
+import slug from "slug";
+import { prisma } from "~/db.server";
+import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server";
+import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
+import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch";
+import { logger } from "./logger.server";
+import { getLimit } from "./platform.v3.server";
+
+export class UpsertBranchService {
+  #prismaClient: PrismaClient;
+
+  constructor(prismaClient: PrismaClient = prisma) {
+    this.#prismaClient = prismaClient;
+  }
+
+  public async call(userId: string, { parentEnvironmentId, branchName, git }: CreateBranchOptions) {
+    const sanitizedBranchName = sanitizeBranchName(branchName);
+    if (!sanitizedBranchName) {
+      return {
+        success: false as const,
+        error: "Branch name has an invalid format",
+      };
+    }
+
+    if (!isValidGitBranchName(sanitizedBranchName)) {
+      return {
+        success: false as const,
+        error: "Invalid branch name, contains disallowed character sequences",
+      };
+    }
+
+    try {
+      const parentEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
+        where: {
+          id: parentEnvironmentId,
+          organization: {
+            members: {
+              some: {
+                userId: userId,
+              },
+            },
+          },
+        },
+        include: {
+          organization: {
+            select: {
+              id: true,
+              slug: true,
+              maximumConcurrencyLimit: true,
+            },
+          },
+          project: {
+            select: {
+              id: true,
+              slug: true,
+            },
+          },
+        },
+      });
+
+      if (!parentEnvironment) {
+        return {
+          success: false as const,
+          error: "You don't have preview branches setup. Go to the dashboard to enable them.",
+        };
+      }
+
+      if (!parentEnvironment.isBranchableEnvironment) {
+        return {
+          success: false as const,
+          error: "Your preview environment is not branchable",
+        };
+      }
+
+      const limits = await checkBranchLimit(
+        this.#prismaClient,
+        parentEnvironment.organization.id,
+        parentEnvironment.project.id
+      );
+
+      if (limits.isAtLimit) {
+        return {
+          success: false as const,
+          error: `You've used all ${limits.used} of ${limits.limit} branches for your plan. Upgrade to get more branches or archive some.`,
+        };
+      }
+
+      const branchSlug = `${slug(`${parentEnvironment.slug}-${sanitizedBranchName}`)}`;
+      const apiKey = createApiKeyForEnv(parentEnvironment.type);
+      const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type);
+      const shortcode = branchSlug;
+
+      const now = new Date();
+
+      const branch = await this.#prismaClient.runtimeEnvironment.upsert({
+        where: {
+          projectId_shortcode: {
+            projectId: parentEnvironment.project.id,
+            shortcode: shortcode,
+          },
+        },
+        create: {
+          slug: branchSlug,
+          apiKey,
+          pkApiKey,
+          shortcode,
+          maximumConcurrencyLimit: parentEnvironment.maximumConcurrencyLimit,
+          organization: {
+            connect: {
+              id: parentEnvironment.organization.id,
+            },
+          },
+          project: {
+            connect: { id: parentEnvironment.project.id },
+          },
+          branchName: sanitizedBranchName,
+          type: parentEnvironment.type,
+          parentEnvironment: {
+            connect: { id: parentEnvironment.id },
+          },
+          git: git ?? undefined,
+        },
+        update: {
+          git: git ?? undefined,
+        },
+      });
+
+      const alreadyExisted = branch.createdAt < now;
+
+      return {
+        success: true as const,
+        alreadyExisted: alreadyExisted,
+        branch,
+        organization: parentEnvironment.organization,
+        project: parentEnvironment.project,
+      };
+    } catch (e) {
+      logger.error("CreateBranchService error", { error: e });
+      return {
+        success: false as const,
+        error: e instanceof Error ? e.message : "Failed to create branch",
+      };
+    }
+  }
+}
+
+export async function checkBranchLimit(
+  prisma: PrismaClientOrTransaction,
+  organizationId: string,
+  projectId: string
+) {
+  const used = await prisma.runtimeEnvironment.count({
+    where: {
+      projectId,
+      branchName: {
+        not: null,
+      },
+      archivedAt: null,
+    },
+  });
+  const limit = await getLimit(organizationId, "branches", 50);
+
+  return {
+    used,
+    limit,
+    isAtLimit: used >= limit,
+  };
+}
diff --git a/apps/webapp/app/utils/cn.ts b/apps/webapp/app/utils/cn.ts
index dc64e862cf..d33fe61b52 100644
--- a/apps/webapp/app/utils/cn.ts
+++ b/apps/webapp/app/utils/cn.ts
@@ -10,6 +10,7 @@ const customTwMerge = extendTailwindMerge({
           "xs",
           "sm",
           "2sm",
+          "base",
           "md",
           "lg",
           "xl",
diff --git a/apps/webapp/app/utils/environmentSort.ts b/apps/webapp/app/utils/environmentSort.ts
index 4ed749bf64..00c7a33580 100644
--- a/apps/webapp/app/utils/environmentSort.ts
+++ b/apps/webapp/app/utils/environmentSort.ts
@@ -2,8 +2,8 @@ import { type RuntimeEnvironmentType } from "@trigger.dev/database";
 
 const environmentSortOrder: RuntimeEnvironmentType[] = [
   "DEVELOPMENT",
-  "PREVIEW",
   "STAGING",
+  "PREVIEW",
   "PRODUCTION",
 ];
 
diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts
index c36204f7a1..4a48b3a4b8 100644
--- a/apps/webapp/app/utils/pathBuilder.ts
+++ b/apps/webapp/app/utils/pathBuilder.ts
@@ -415,6 +415,14 @@ export function v3DeploymentVersionPath(
   return `${v3DeploymentsPath(organization, project, environment)}?version=${version}`;
 }
 
+export function branchesPath(
+  organization: OrgForPath,
+  project: ProjectForPath,
+  environment: EnvironmentForPath
+) {
+  return `${v3EnvironmentPath(organization, project, environment)}/branches`;
+}
+
 export function v3BillingPath(organization: OrgForPath, message?: string) {
   return `${organizationPath(organization)}/settings/billing${
     message ? `?message=${encodeURIComponent(message)}` : ""
diff --git a/apps/webapp/app/v3/deduplicateVariableArray.server.ts b/apps/webapp/app/v3/deduplicateVariableArray.server.ts
new file mode 100644
index 0000000000..886ec7a440
--- /dev/null
+++ b/apps/webapp/app/v3/deduplicateVariableArray.server.ts
@@ -0,0 +1,14 @@
+import { type EnvironmentVariable } from "./environmentVariables/repository";
+
+/** Later variables override earlier ones */
+export function deduplicateVariableArray(variables: EnvironmentVariable[]) {
+  const result: EnvironmentVariable[] = [];
+  // Process array in reverse order so later variables override earlier ones
+  for (const variable of [...variables].reverse()) {
+    if (!result.some((v) => v.key === variable.key)) {
+      result.push(variable);
+    }
+  }
+  // Reverse back to maintain original order but with later variables taking precedence
+  return result.reverse();
+}
diff --git a/apps/webapp/app/v3/environmentVariableRules.server.ts b/apps/webapp/app/v3/environmentVariableRules.server.ts
new file mode 100644
index 0000000000..ddaee2b249
--- /dev/null
+++ b/apps/webapp/app/v3/environmentVariableRules.server.ts
@@ -0,0 +1,40 @@
+import { type EnvironmentVariable } from "./environmentVariables/repository";
+
+type VariableRule =
+  | { type: "exact"; key: string }
+  | { type: "prefix"; prefix: string }
+  | { type: "whitelist"; key: string };
+
+const blacklistedVariables: VariableRule[] = [
+  { type: "exact", key: "TRIGGER_SECRET_KEY" },
+  { type: "exact", key: "TRIGGER_API_URL" },
+  { type: "prefix", prefix: "OTEL_" },
+  { type: "whitelist", key: "OTEL_LOG_LEVEL" },
+];
+
+export function removeBlacklistedVariables(
+  variables: EnvironmentVariable[]
+): EnvironmentVariable[] {
+  return variables.filter((v) => {
+    const whitelisted = blacklistedVariables.find(
+      (bv) => bv.type === "whitelist" && bv.key === v.key
+    );
+    if (whitelisted) {
+      return true;
+    }
+
+    const exact = blacklistedVariables.find((bv) => bv.type === "exact" && bv.key === v.key);
+    if (exact) {
+      return false;
+    }
+
+    const prefix = blacklistedVariables.find(
+      (bv) => bv.type === "prefix" && v.key.startsWith(bv.prefix)
+    );
+    if (prefix) {
+      return false;
+    }
+
+    return true;
+  });
+}
diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts
index fdcbdc7e08..4462a7e15d 100644
--- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts
+++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts
@@ -1,25 +1,22 @@
-import {
-  Prisma,
-  PrismaClient,
-  RuntimeEnvironment,
-  RuntimeEnvironmentType,
-} from "@trigger.dev/database";
+import { Prisma, type PrismaClient, type RuntimeEnvironmentType } from "@trigger.dev/database";
 import { z } from "zod";
-import { environmentFullTitle, environmentTitle } from "~/components/environments/EnvironmentLabel";
+import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
 import { $transaction, prisma } from "~/db.server";
 import { env } from "~/env.server";
 import { getSecretStore } from "~/services/secrets/secretStore.server";
 import { generateFriendlyId } from "../friendlyIdentifiers";
 import {
-  CreateResult,
-  DeleteEnvironmentVariable,
-  DeleteEnvironmentVariableValue,
-  EnvironmentVariable,
-  EnvironmentVariableWithSecret,
-  ProjectEnvironmentVariable,
-  Repository,
-  Result,
+  type CreateResult,
+  type DeleteEnvironmentVariable,
+  type DeleteEnvironmentVariableValue,
+  type EnvironmentVariable,
+  type EnvironmentVariableWithSecret,
+  type ProjectEnvironmentVariable,
+  type Repository,
+  type Result,
 } from "./repository";
+import { removeBlacklistedVariables } from "../environmentVariableRules.server";
+import { deduplicateVariableArray } from "../deduplicateVariableArray.server";
 
 function secretKeyProjectPrefix(projectId: string) {
   return `environmentvariable:${projectId}:`;
@@ -94,14 +91,18 @@ export class EnvironmentVariablesRepository implements Repository {
     }
 
     // Remove `TRIGGER_SECRET_KEY` or `TRIGGER_API_URL`
-    let values = options.variables.filter(
-      (v) => v.key !== "TRIGGER_SECRET_KEY" && v.key !== "TRIGGER_API_URL"
-    );
+    let values = removeBlacklistedVariables(options.variables);
+    const removedBlacklisted = values.length !== options.variables.length;
 
     //get rid of empty variables
     values = values.filter((v) => v.key.trim() !== "" && v.value.trim() !== "");
     if (values.length === 0) {
-      return { success: false as const, error: `You must set at least one value` };
+      return {
+        success: false as const,
+        error: `You must set at least one valid variable.${
+          removedBlacklisted ? " All the variables submitted are not allowed." : ""
+        }`,
+      };
     }
 
     //check if any of them exist in an environment we're setting
@@ -512,14 +513,17 @@ export class EnvironmentVariablesRepository implements Repository {
 
   async getEnvironmentWithRedactedSecrets(
     projectId: string,
-    environmentId: string
+    environmentId: string,
+    parentEnvironmentId?: string
   ): Promise<EnvironmentVariableWithSecret[]> {
-    const variables = await this.getEnvironment(projectId, environmentId);
+    const variables = await this.getEnvironment(projectId, environmentId, parentEnvironmentId);
 
     // Get the keys of all secret variables
     const secretValues = await this.prismaClient.environmentVariableValue.findMany({
       where: {
-        environmentId,
+        environmentId: parentEnvironmentId
+          ? { in: [environmentId, parentEnvironmentId] }
+          : environmentId,
         isSecret: true,
       },
       select: {
@@ -532,7 +536,7 @@ export class EnvironmentVariablesRepository implements Repository {
     });
     const secretVarKeys = secretValues.map((r) => r.variable.key);
 
-    // Filter out secret variables if includeSecrets is false
+    // Filter out secret variables
     return variables.map((v) => {
       if (secretVarKeys.includes(v.key)) {
         return {
@@ -546,7 +550,11 @@ export class EnvironmentVariablesRepository implements Repository {
     });
   }
 
-  async getEnvironment(projectId: string, environmentId: string): Promise<EnvironmentVariable[]> {
+  async getEnvironment(
+    projectId: string,
+    environmentId: string,
+    parentEnvironmentId?: string
+  ): Promise<EnvironmentVariable[]> {
     const project = await this.prismaClient.project.findFirst({
       where: {
         id: projectId,
@@ -568,36 +576,57 @@ export class EnvironmentVariablesRepository implements Repository {
       return [];
     }
 
-    return this.getEnvironmentVariables(projectId, environmentId);
+    return this.getEnvironmentVariables(projectId, environmentId, parentEnvironmentId);
   }
 
   async #getSecretEnvironmentVariables(
     projectId: string,
-    environmentId: string
+    environmentId: string,
+    parentEnvironmentId?: string
   ): Promise<EnvironmentVariable[]> {
     const secretStore = getSecretStore("DATABASE", {
       prismaClient: this.prismaClient,
     });
 
-    const secrets = await secretStore.getSecrets(
+    const parentSecrets = parentEnvironmentId
+      ? await secretStore.getSecrets(
+          SecretValue,
+          secretKeyEnvironmentPrefix(projectId, parentEnvironmentId)
+        )
+      : [];
+
+    const childSecrets = await secretStore.getSecrets(
       SecretValue,
       secretKeyEnvironmentPrefix(projectId, environmentId)
     );
 
-    return secrets.map((secret) => {
-      const { key } = parseSecretKey(secret.key);
+    // Merge the secrets, we want child ones to override parent ones
+    const mergedSecrets = new Map<string, string>();
+    for (const secret of parentSecrets) {
+      const { key: parsedKey } = parseSecretKey(secret.key);
+      mergedSecrets.set(parsedKey, secret.value.secret);
+    }
+    for (const secret of childSecrets) {
+      const { key: parsedKey } = parseSecretKey(secret.key);
+      mergedSecrets.set(parsedKey, secret.value.secret);
+    }
+
+    const merged = Array.from(mergedSecrets.entries()).map(([key, value]) => {
       return {
         key,
-        value: secret.value.secret,
+        value,
       };
     });
+
+    return removeBlacklistedVariables(merged);
   }
 
   async getEnvironmentVariables(
     projectId: string,
-    environmentId: string
+    environmentId: string,
+    parentEnvironmentId?: string
   ): Promise<EnvironmentVariable[]> {
-    return this.#getSecretEnvironmentVariables(projectId, environmentId);
+    return this.#getSecretEnvironmentVariables(projectId, environmentId, parentEnvironmentId);
   }
 
   async delete(projectId: string, options: DeleteEnvironmentVariable): Promise<Result> {
@@ -780,6 +809,7 @@ export const RuntimeEnvironmentForEnvRepoPayload = {
     projectId: true,
     apiKey: true,
     organizationId: true,
+    branchName: true,
   },
 } as const;
 
@@ -790,11 +820,13 @@ export type RuntimeEnvironmentForEnvRepo = Prisma.RuntimeEnvironmentGetPayload<
 export const environmentVariablesRepository = new EnvironmentVariablesRepository();
 
 export async function resolveVariablesForEnvironment(
-  runtimeEnvironment: RuntimeEnvironmentForEnvRepo
+  runtimeEnvironment: RuntimeEnvironmentForEnvRepo,
+  parentEnvironment?: RuntimeEnvironmentForEnvRepo
 ) {
   const projectSecrets = await environmentVariablesRepository.getEnvironmentVariables(
     runtimeEnvironment.projectId,
-    runtimeEnvironment.id
+    runtimeEnvironment.id,
+    parentEnvironment?.id
   );
 
   const overridableTriggerVariables = await resolveOverridableTriggerVariables(runtimeEnvironment);
@@ -802,9 +834,13 @@ export async function resolveVariablesForEnvironment(
   const builtInVariables =
     runtimeEnvironment.type === "DEVELOPMENT"
       ? await resolveBuiltInDevVariables(runtimeEnvironment)
-      : await resolveBuiltInProdVariables(runtimeEnvironment);
+      : await resolveBuiltInProdVariables(runtimeEnvironment, parentEnvironment);
 
-  return [...overridableTriggerVariables, ...projectSecrets, ...builtInVariables];
+  return deduplicateVariableArray([
+    ...overridableTriggerVariables,
+    ...projectSecrets,
+    ...builtInVariables,
+  ]);
 }
 
 async function resolveOverridableTriggerVariables(
@@ -882,11 +918,14 @@ async function resolveBuiltInDevVariables(runtimeEnvironment: RuntimeEnvironment
   return [...result, ...commonVariables];
 }
 
-async function resolveBuiltInProdVariables(runtimeEnvironment: RuntimeEnvironmentForEnvRepo) {
+async function resolveBuiltInProdVariables(
+  runtimeEnvironment: RuntimeEnvironmentForEnvRepo,
+  parentEnvironment?: RuntimeEnvironmentForEnvRepo
+) {
   let result: Array<EnvironmentVariable> = [
     {
       key: "TRIGGER_SECRET_KEY",
-      value: runtimeEnvironment.apiKey,
+      value: parentEnvironment?.apiKey ?? runtimeEnvironment.apiKey,
     },
     {
       key: "TRIGGER_API_URL",
@@ -906,6 +945,15 @@ async function resolveBuiltInProdVariables(runtimeEnvironment: RuntimeEnvironmen
     },
   ];
 
+  if (runtimeEnvironment.branchName) {
+    result = result.concat([
+      {
+        key: "TRIGGER_PREVIEW_BRANCH",
+        value: runtimeEnvironment.branchName,
+      },
+    ]);
+  }
+
   if (env.PROD_OTEL_BATCH_PROCESSING_ENABLED === "1") {
     result = result.concat([
       {
diff --git a/apps/webapp/app/v3/gitBranch.ts b/apps/webapp/app/v3/gitBranch.ts
new file mode 100644
index 0000000000..109de645b9
--- /dev/null
+++ b/apps/webapp/app/v3/gitBranch.ts
@@ -0,0 +1,44 @@
+export function isValidGitBranchName(branch: string): boolean {
+  // Must not be empty
+  if (!branch) return false;
+
+  // Disallowed characters: space, ~, ^, :, ?, *, [, \
+  if (/[ \~\^:\?\*\[\\]/.test(branch)) return false;
+
+  // Disallow ASCII control characters (0-31) and DEL (127)
+  for (let i = 0; i < branch.length; i++) {
+    const code = branch.charCodeAt(i);
+    if ((code >= 0 && code <= 31) || code === 127) return false;
+  }
+
+  // Cannot start or end with a slash
+  if (branch.startsWith("/") || branch.endsWith("/")) return false;
+
+  // Cannot have consecutive slashes
+  if (branch.includes("//")) return false;
+
+  // Cannot contain '..'
+  if (branch.includes("..")) return false;
+
+  // Cannot contain '@{'
+  if (branch.includes("@{")) return false;
+
+  // Cannot end with '.lock'
+  if (branch.endsWith(".lock")) return false;
+
+  return true;
+}
+
+export function sanitizeBranchName(ref: string): string | null {
+  if (!ref) return null;
+  if (ref.startsWith("refs/heads/")) return ref.substring("refs/heads/".length);
+  if (ref.startsWith("refs/remotes/")) return ref.substring("refs/remotes/".length);
+  if (ref.startsWith("refs/tags/")) return ref.substring("refs/tags/".length);
+  if (ref.startsWith("refs/pull/")) return ref.substring("refs/pull/".length);
+  if (ref.startsWith("refs/merge/")) return ref.substring("refs/merge/".length);
+  if (ref.startsWith("refs/release/")) return ref.substring("refs/release/".length);
+  //unknown ref format, so reject
+  if (ref.startsWith("refs/")) return null;
+
+  return ref;
+}
diff --git a/apps/webapp/app/v3/services/checkSchedule.server.ts b/apps/webapp/app/v3/services/checkSchedule.server.ts
index eff6dcd3ad..d5249f6b58 100644
--- a/apps/webapp/app/v3/services/checkSchedule.server.ts
+++ b/apps/webapp/app/v3/services/checkSchedule.server.ts
@@ -14,7 +14,7 @@ type Schedule = {
 };
 
 export class CheckScheduleService extends BaseService {
-  public async call(projectId: string, schedule: Schedule) {
+  public async call(projectId: string, schedule: Schedule, environmentIds: string[]) {
     //validate the cron expression
     try {
       CronPattern.parse(schedule.cron);
@@ -61,28 +61,34 @@ export class CheckScheduleService extends BaseService {
       );
     }
 
-    //if creating a schedule, check they're under the limits
-    if (!schedule.friendlyId) {
-      //check they're within their limit
-      const project = await this._prisma.project.findFirst({
-        where: {
-          id: projectId,
-        },
-        select: {
-          organizationId: true,
-          environments: {
-            select: {
-              id: true,
-              type: true,
-            },
+    //check they're within their limit
+    const project = await this._prisma.project.findFirst({
+      where: {
+        id: projectId,
+      },
+      select: {
+        organizationId: true,
+        environments: {
+          select: {
+            id: true,
+            type: true,
+            archivedAt: true,
           },
         },
-      });
+      },
+    });
 
-      if (!project) {
-        throw new ServiceValidationError("Project not found");
-      }
+    if (!project) {
+      throw new ServiceValidationError("Project not found");
+    }
 
+    const environments = project.environments.filter((env) => environmentIds.includes(env.id));
+    if (environments.some((env) => env.archivedAt)) {
+      throw new ServiceValidationError("Can't add or edit a schedule for an archived branch");
+    }
+
+    //if creating a schedule, check they're under the limits
+    if (!schedule.friendlyId) {
       const limit = await getLimit(project.organizationId, "schedules", 100_000_000);
       const schedulesCount = await CheckScheduleService.getUsedSchedulesCount({
         prisma: this._prisma,
diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts
index 9ad4ba254b..3fa78a6c22 100644
--- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts
+++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts
@@ -523,12 +523,16 @@ export async function syncDeclarativeSchedules(
     );
 
     //this throws errors if the schedule is invalid
-    await checkSchedule.call(environment.projectId, {
-      cron: task.schedule.cron,
-      timezone: task.schedule.timezone,
-      taskIdentifier: task.id,
-      friendlyId: existingSchedule?.friendlyId,
-    });
+    await checkSchedule.call(
+      environment.projectId,
+      {
+        cron: task.schedule.cron,
+        timezone: task.schedule.timezone,
+        taskIdentifier: task.id,
+        friendlyId: existingSchedule?.friendlyId,
+      },
+      [environment.id]
+    );
 
     if (existingSchedule) {
       const schedule = await prisma.taskSchedule.update({
diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts
index e99473ca9c..dfcfb3c4f8 100644
--- a/apps/webapp/app/v3/services/initializeDeployment.server.ts
+++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts
@@ -1,14 +1,14 @@
-import { InitializeDeploymentRequestBody } from "@trigger.dev/core/v3";
+import { type InitializeDeploymentRequestBody } from "@trigger.dev/core/v3";
+import { WorkerDeploymentType } from "@trigger.dev/database";
 import { customAlphabet } from "nanoid";
-import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
+import { env } from "~/env.server";
+import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
+import { logger } from "~/services/logger.server";
 import { generateFriendlyId } from "../friendlyIdentifiers";
 import { createRemoteImageBuild } from "../remoteImageBuilder.server";
 import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion";
 import { BaseService, ServiceValidationError } from "./baseService.server";
 import { TimeoutDeploymentService } from "./timeoutDeployment.server";
-import { env } from "~/env.server";
-import { WorkerDeploymentType } from "@trigger.dev/database";
-import { logger } from "~/services/logger.server";
 
 const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8);
 
@@ -107,6 +107,7 @@ export class InitializeDeploymentService extends BaseService {
           triggeredById: triggeredBy?.id,
           type: payload.type,
           imageReference: isManaged ? undefined : unmanagedImageTag,
+          git: payload.gitMeta ?? undefined,
         },
       });
 
diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts
index a521c4f435..5b7ca7098b 100644
--- a/apps/webapp/app/v3/services/replayTaskRun.server.ts
+++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts
@@ -27,6 +27,10 @@ export class ReplayTaskRunService extends BaseService {
       return;
     }
 
+    if (authenticatedEnvironment.archivedAt) {
+      throw new Error("Can't replay a run on an archived environment");
+    }
+
     logger.info("Replaying task run", {
       taskRunId: existingTaskRun.id,
       taskRunFriendlyId: existingTaskRun.friendlyId,
diff --git a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts
index f20b04c3db..52e0cfab63 100644
--- a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts
+++ b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts
@@ -53,6 +53,16 @@ export class TriggerScheduledTaskService extends BaseService {
       return;
     }
 
+    if (instance.environment.archivedAt) {
+      logger.debug("Environment is archived, disabling schedule", {
+        instanceId,
+        scheduleId: instance.taskSchedule.friendlyId,
+        environmentId: instance.environment.id,
+      });
+
+      return;
+    }
+
     try {
       let shouldTrigger = true;
 
diff --git a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts
index 46cc23fdf9..9f27bb4c70 100644
--- a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts
+++ b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts
@@ -29,7 +29,7 @@ export class UpsertTaskScheduleService extends BaseService {
   public async call(projectId: string, schedule: UpsertTaskScheduleServiceOptions) {
     //this throws errors if the schedule is invalid
     const checkSchedule = new CheckScheduleService(this._prisma);
-    await checkSchedule.call(projectId, schedule);
+    await checkSchedule.call(projectId, schedule, schedule.environments);
 
     const deduplicationKey =
       typeof schedule.deduplicationKey === "string" && schedule.deduplicationKey !== ""
diff --git a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts
index 5c3a8d1cce..ecdb49724e 100644
--- a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts
+++ b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts
@@ -73,7 +73,7 @@ export class WorkerGroupTokenService extends WithRunEngine {
   }
 
   async rotateToken({ workerGroupId }: { workerGroupId: string }) {
-    const workerGroup = await this._prisma.workerInstanceGroup.findUnique({
+    const workerGroup = await this._prisma.workerInstanceGroup.findFirst({
       where: {
         id: workerGroupId,
       },
@@ -266,12 +266,10 @@ export class WorkerGroupTokenService extends WithRunEngine {
     return await $transaction(this._prisma, async (tx) => {
       const resourceIdentifier = deploymentId ? `${deploymentId}:${instanceName}` : instanceName;
 
-      const workerInstance = await tx.workerInstance.findUnique({
+      const workerInstance = await tx.workerInstance.findFirst({
         where: {
-          workerGroupId_resourceIdentifier: {
-            workerGroupId: workerGroup.id,
-            resourceIdentifier,
-          },
+          workerGroupId: workerGroup.id,
+          resourceIdentifier,
         },
         include: {
           deployment: true,
@@ -315,12 +313,10 @@ export class WorkerGroupTokenService extends WithRunEngine {
             // Unique constraint violation
             if (error.code === "P2002") {
               try {
-                const existingWorkerInstance = await tx.workerInstance.findUnique({
+                const existingWorkerInstance = await tx.workerInstance.findFirst({
                   where: {
-                    workerGroupId_resourceIdentifier: {
-                      workerGroupId: workerGroup.id,
-                      resourceIdentifier,
-                    },
+                    workerGroupId: workerGroup.id,
+                    resourceIdentifier,
                   },
                   include: {
                     deployment: true,
@@ -363,7 +359,7 @@ export class WorkerGroupTokenService extends WithRunEngine {
 
       // Unmanaged workers instances are locked to a specific deployment version
 
-      const deployment = await tx.workerDeployment.findUnique({
+      const deployment = await tx.workerDeployment.findFirst({
         where: {
           ...(deploymentId.startsWith("deployment_")
             ? {
@@ -457,7 +453,11 @@ export class WorkerGroupTokenService extends WithRunEngine {
         },
         include: {
           deployment: true,
-          environment: true,
+          environment: {
+            include: {
+              parentEnvironment: true,
+            },
+          },
         },
       });
 
@@ -481,6 +481,10 @@ export class WorkerGroupTokenService extends WithRunEngine {
 export const WorkerInstanceEnv = z.enum(["dev", "staging", "prod"]).default("prod");
 export type WorkerInstanceEnv = z.infer<typeof WorkerInstanceEnv>;
 
+type EnvironmentWithParent = RuntimeEnvironment & {
+  parentEnvironment?: RuntimeEnvironment | null;
+};
+
 export type AuthenticatedWorkerInstanceOptions = WithRunEngineOptions<{
   type: WorkerInstanceGroupType;
   name: string;
@@ -491,7 +495,7 @@ export type AuthenticatedWorkerInstanceOptions = WithRunEngineOptions<{
   deploymentId?: string;
   backgroundWorkerId?: string;
   runnerId?: string;
-  environment: RuntimeEnvironment | null;
+  environment: EnvironmentWithParent | null;
 }>;
 
 export class AuthenticatedWorkerInstance extends WithRunEngine {
@@ -501,7 +505,7 @@ export class AuthenticatedWorkerInstance extends WithRunEngine {
   readonly workerInstanceId: string;
   readonly runnerId?: string;
   readonly masterQueue: string;
-  readonly environment: RuntimeEnvironment | null;
+  readonly environment: EnvironmentWithParent | null;
   readonly deploymentId?: string;
   readonly backgroundWorkerId?: string;
 
@@ -682,17 +686,21 @@ export class AuthenticatedWorkerInstance extends WithRunEngine {
 
     const environment =
       this.environment ??
-      (await this._prisma.runtimeEnvironment.findUnique({
+      (await this._prisma.runtimeEnvironment.findFirst({
         where: {
           id: engineResult.execution.environment.id,
         },
+        include: {
+          parentEnvironment: true,
+        },
       }));
 
     const envVars = environment
       ? await this.getEnvVars(
           environment,
           engineResult.run.id,
-          engineResult.execution.machine ?? defaultMachinePreset
+          engineResult.execution.machine ?? defaultMachinePreset,
+          environment.parentEnvironment ?? undefined
         )
       : {};
 
@@ -797,9 +805,10 @@ export class AuthenticatedWorkerInstance extends WithRunEngine {
   private async getEnvVars(
     environment: RuntimeEnvironment,
     runId: string,
-    machinePreset: MachinePreset
+    machinePreset: MachinePreset,
+    parentEnvironment?: RuntimeEnvironment
   ): Promise<Record<string, string>> {
-    const variables = await resolveVariablesForEnvironment(environment);
+    const variables = await resolveVariablesForEnvironment(environment, parentEnvironment);
 
     const jwt = await generateJWTTokenForEnvironment(environment, {
       run_id: runId,
diff --git a/apps/webapp/package.json b/apps/webapp/package.json
index 99867164e3..88435c4127 100644
--- a/apps/webapp/package.json
+++ b/apps/webapp/package.json
@@ -45,18 +45,19 @@
     "@codemirror/view": "^6.5.0",
     "@conform-to/react": "0.9.2",
     "@conform-to/zod": "0.9.2",
-    "@depot/sdk-node": "^1.0.0",
     "@depot/cli": "0.0.1-cli.2.80.0",
+    "@depot/sdk-node": "^1.0.0",
     "@electric-sql/react": "^0.3.5",
     "@headlessui/react": "^1.7.8",
     "@heroicons/react": "^2.0.12",
+    "@internal/redis": "workspace:*",
     "@internal/run-engine": "workspace:*",
+    "@internal/tracing": "workspace:*",
     "@internal/zod-worker": "workspace:*",
-    "@internal/redis": "workspace:*",
-    "@trigger.dev/redis-worker": "workspace:*",
     "@internationalized/date": "^3.5.1",
     "@lezer/highlight": "^1.1.6",
     "@opentelemetry/api": "1.9.0",
+    "@opentelemetry/api-logs": "0.52.1",
     "@opentelemetry/core": "1.25.1",
     "@opentelemetry/exporter-logs-otlp-http": "0.52.1",
     "@opentelemetry/exporter-trace-otlp-http": "0.52.1",
@@ -69,9 +70,9 @@
     "@opentelemetry/sdk-trace-base": "1.25.1",
     "@opentelemetry/sdk-trace-node": "1.25.1",
     "@opentelemetry/semantic-conventions": "1.25.1",
-    "@opentelemetry/api-logs": "0.52.1",
     "@popperjs/core": "^2.11.8",
     "@prisma/instrumentation": "^5.11.0",
+    "@radix-ui/react-accordion": "^1.2.11",
     "@radix-ui/react-alert-dialog": "^1.0.4",
     "@radix-ui/react-dialog": "^1.0.3",
     "@radix-ui/react-label": "^2.0.1",
@@ -103,9 +104,9 @@
     "@trigger.dev/core": "workspace:*",
     "@trigger.dev/database": "workspace:*",
     "@trigger.dev/otlp-importer": "workspace:*",
-    "@trigger.dev/platform": "1.0.14",
+    "@trigger.dev/platform": "1.0.15",
+    "@trigger.dev/redis-worker": "workspace:*",
     "@trigger.dev/sdk": "workspace:*",
-    "@internal/tracing": "workspace:*",
     "@types/pg": "8.6.6",
     "@uiw/react-codemirror": "^4.19.5",
     "@unkey/cache": "^1.5.0",
@@ -146,8 +147,8 @@
     "non.geist": "^1.0.2",
     "ohash": "^1.1.3",
     "openai": "^4.33.1",
-    "parse-duration": "^2.1.0",
     "p-limit": "^6.2.0",
+    "parse-duration": "^2.1.0",
     "posthog-js": "^1.93.3",
     "posthog-node": "4.17.1",
     "prism-react-renderer": "^2.3.1",
@@ -194,9 +195,9 @@
     "zod-validation-error": "^1.5.0"
   },
   "devDependencies": {
-    "@internal/testcontainers": "workspace:*",
-    "@internal/replication": "workspace:*",
     "@internal/clickhouse": "workspace:*",
+    "@internal/replication": "workspace:*",
+    "@internal/testcontainers": "workspace:*",
     "@remix-run/dev": "2.1.0",
     "@remix-run/eslint-config": "2.1.0",
     "@remix-run/testing": "^2.1.0",
diff --git a/apps/webapp/prisma/seed.ts b/apps/webapp/prisma/seed.ts
index f9e418f25e..009f9278b5 100644
--- a/apps/webapp/prisma/seed.ts
+++ b/apps/webapp/prisma/seed.ts
@@ -1,5 +1,3 @@
-/* eslint-disable turbo/no-undeclared-env-vars */
-
 import { seedCloud } from "./seedCloud";
 import { prisma } from "../app/db.server";
 import { createEnvironment } from "~/models/organization.server";
@@ -48,7 +46,14 @@ async function runStagingEnvironmentMigration() {
             `Creating staging environment for project ${project.slug} on org ${project.organization.slug}`
           );
 
-          await createEnvironment(project.organization, project, "STAGING", undefined, tx);
+          await createEnvironment({
+            organization: project.organization,
+            project,
+            type: "STAGING",
+            isBranchableEnvironment: false,
+            member: undefined,
+            prismaClient: tx,
+          });
         } catch (error) {
           console.error(error);
         }
diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js
index 5b41bcafd8..1e47ecf75b 100644
--- a/apps/webapp/tailwind.config.js
+++ b/apps/webapp/tailwind.config.js
@@ -149,8 +149,8 @@ const pending = colors.blue[500];
 const warning = colors.amber[500];
 const error = colors.rose[600];
 const devEnv = colors.pink[500];
-const stagingEnv = colors.amber[400];
-const previewEnv = colors.amber[400];
+const stagingEnv = colors.orange[400];
+const previewEnv = colors.yellow[400];
 const prodEnv = mint[500];
 
 /** Icon colors */
@@ -310,9 +310,14 @@ module.exports = {
       },
       width: {
         0.75: "0.1875rem",
+        4.5: "1.125rem",
       },
       height: {
         0.75: "0.1875rem",
+        4.5: "1.125rem",
+      },
+      size: {
+        4.5: "1.125rem",
       },
     },
   },
diff --git a/apps/webapp/test/environmentVariableDeduplication.test.ts b/apps/webapp/test/environmentVariableDeduplication.test.ts
new file mode 100644
index 0000000000..d47da457a0
--- /dev/null
+++ b/apps/webapp/test/environmentVariableDeduplication.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect } from "vitest";
+import type { EnvironmentVariable } from "../app/v3/environmentVariables/repository";
+import { deduplicateVariableArray } from "~/v3/deduplicateVariableArray.server";
+
+describe("Deduplicate variables", () => {
+  it("should keep later variables when there are duplicates", () => {
+    const variables: EnvironmentVariable[] = [
+      { key: "API_KEY", value: "old_value" },
+      { key: "API_KEY", value: "new_value" },
+    ];
+
+    const result = deduplicateVariableArray(variables);
+
+    expect(result).toHaveLength(1);
+    expect(result[0]).toEqual({ key: "API_KEY", value: "new_value" });
+  });
+
+  it("should preserve order of unique variables", () => {
+    const variables: EnvironmentVariable[] = [
+      { key: "FIRST", value: "first" },
+      { key: "SECOND", value: "second" },
+      { key: "THIRD", value: "third" },
+    ];
+
+    const result = deduplicateVariableArray(variables);
+
+    expect(result).toHaveLength(3);
+    expect(result[0].key).toBe("FIRST");
+    expect(result[1].key).toBe("SECOND");
+    expect(result[2].key).toBe("THIRD");
+  });
+
+  it("should handle multiple duplicates with later values taking precedence", () => {
+    const variables: EnvironmentVariable[] = [
+      { key: "DB_URL", value: "old_db" },
+      { key: "API_KEY", value: "old_key" },
+      { key: "DB_URL", value: "new_db" },
+      { key: "API_KEY", value: "new_key" },
+    ];
+
+    const result = deduplicateVariableArray(variables);
+
+    expect(result).toHaveLength(2);
+    expect(result.find((v) => v.key === "DB_URL")?.value).toBe("new_db");
+    expect(result.find((v) => v.key === "API_KEY")?.value).toBe("new_key");
+  });
+
+  it("should handle empty array", () => {
+    const result = deduplicateVariableArray([]);
+    expect(result).toEqual([]);
+  });
+
+  it("should handle array with no duplicates", () => {
+    const variables: EnvironmentVariable[] = [
+      { key: "VAR1", value: "value1" },
+      { key: "VAR2", value: "value2" },
+    ];
+
+    const result = deduplicateVariableArray(variables);
+
+    expect(result).toHaveLength(2);
+    expect(result).toEqual(variables);
+  });
+});
diff --git a/apps/webapp/test/environmentVariableRules.test.ts b/apps/webapp/test/environmentVariableRules.test.ts
new file mode 100644
index 0000000000..af27dd3c7c
--- /dev/null
+++ b/apps/webapp/test/environmentVariableRules.test.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect } from "vitest";
+import type { EnvironmentVariable } from "../app/v3/environmentVariables/repository";
+import { removeBlacklistedVariables } from "~/v3/environmentVariableRules.server";
+
+describe("removeBlacklistedVariables", () => {
+  it("should remove exact match blacklisted variables", () => {
+    const variables: EnvironmentVariable[] = [
+      { key: "TRIGGER_SECRET_KEY", value: "secret123" },
+      { key: "TRIGGER_API_URL", value: "https://api.example.com" },
+      { key: "NORMAL_VAR", value: "normal" },
+    ];
+
+    const result = removeBlacklistedVariables(variables);
+
+    expect(result).toEqual([{ key: "NORMAL_VAR", value: "normal" }]);
+  });
+
+  it("should remove variables with blacklisted prefixes", () => {
+    const variables: EnvironmentVariable[] = [
+      { key: "OTEL_SERVICE_NAME", value: "my-service" },
+      { key: "OTEL_TRACE_SAMPLER", value: "always_on" },
+      { key: "NORMAL_VAR", value: "normal" },
+    ];
+
+    const result = removeBlacklistedVariables(variables);
+
+    expect(result).toEqual([{ key: "NORMAL_VAR", value: "normal" }]);
+  });
+
+  it("should keep whitelisted variables even if they match a blacklisted prefix", () => {
+    const variables: EnvironmentVariable[] = [
+      { key: "OTEL_LOG_LEVEL", value: "debug" },
+      { key: "OTEL_SERVICE_NAME", value: "my-service" },
+      { key: "NORMAL_VAR", value: "normal" },
+    ];
+
+    const result = removeBlacklistedVariables(variables);
+
+    expect(result).toEqual([
+      { key: "OTEL_LOG_LEVEL", value: "debug" },
+      { key: "NORMAL_VAR", value: "normal" },
+    ]);
+  });
+
+  it("should handle empty input array", () => {
+    const variables: EnvironmentVariable[] = [];
+
+    const result = removeBlacklistedVariables(variables);
+
+    expect(result).toEqual([]);
+  });
+
+  it("should handle mixed case variables", () => {
+    const variables: EnvironmentVariable[] = [
+      { key: "trigger_secret_key", value: "secret123" }, // Different case
+      { key: "OTEL_LOG_LEVEL", value: "debug" },
+      { key: "otel_service_name", value: "my-service" }, // Different case
+      { key: "NORMAL_VAR", value: "normal" },
+    ];
+
+    const result = removeBlacklistedVariables(variables);
+
+    // Should keep only the whitelisted OTEL_LOG_LEVEL and NORMAL_VAR
+    // Note: The function is case-sensitive, so different case variables should pass through
+    expect(result).toEqual([
+      { key: "trigger_secret_key", value: "secret123" },
+      { key: "OTEL_LOG_LEVEL", value: "debug" },
+      { key: "otel_service_name", value: "my-service" },
+      { key: "NORMAL_VAR", value: "normal" },
+    ]);
+  });
+
+  it("should handle variables with empty values", () => {
+    const variables: EnvironmentVariable[] = [
+      { key: "TRIGGER_SECRET_KEY", value: "" },
+      { key: "OTEL_SERVICE_NAME", value: "" },
+      { key: "OTEL_LOG_LEVEL", value: "" },
+      { key: "NORMAL_VAR", value: "" },
+    ];
+
+    const result = removeBlacklistedVariables(variables);
+
+    expect(result).toEqual([
+      { key: "OTEL_LOG_LEVEL", value: "" },
+      { key: "NORMAL_VAR", value: "" },
+    ]);
+  });
+
+  it("should handle all types of rules in a single array", () => {
+    const variables: EnvironmentVariable[] = [
+      // Exact matches (should be removed)
+      { key: "TRIGGER_SECRET_KEY", value: "secret123" },
+      { key: "TRIGGER_API_URL", value: "https://api.example.com" },
+      // Prefix matches (should be removed)
+      { key: "OTEL_SERVICE_NAME", value: "my-service" },
+      { key: "OTEL_TRACE_SAMPLER", value: "always_on" },
+      // Whitelist exception (should be kept)
+      { key: "OTEL_LOG_LEVEL", value: "debug" },
+      // Normal variables (should be kept)
+      { key: "NORMAL_VAR", value: "normal" },
+      { key: "DATABASE_URL", value: "postgres://..." },
+    ];
+
+    const result = removeBlacklistedVariables(variables);
+
+    expect(result).toEqual([
+      { key: "OTEL_LOG_LEVEL", value: "debug" },
+      { key: "NORMAL_VAR", value: "normal" },
+      { key: "DATABASE_URL", value: "postgres://..." },
+    ]);
+  });
+});
diff --git a/apps/webapp/test/validateGitBranchName.test.ts b/apps/webapp/test/validateGitBranchName.test.ts
new file mode 100644
index 0000000000..28f4056c46
--- /dev/null
+++ b/apps/webapp/test/validateGitBranchName.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest";
+import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch";
+
+describe("isValidGitBranchName", () => {
+  it("returns true for a valid branch name", async () => {
+    expect(isValidGitBranchName("feature/valid-branch")).toBe(true);
+  });
+
+  it("returns false for an invalid branch name", async () => {
+    expect(isValidGitBranchName("invalid branch name!")).toBe(false);
+  });
+
+  it("disallows control characters (ASCII 0–31)", async () => {
+    for (let i = 0; i <= 31; i++) {
+      const branch = `feature${String.fromCharCode(i)}branch`;
+      // eslint-disable-next-line no-await-in-loop
+      expect(isValidGitBranchName(branch)).toBe(false);
+    }
+  });
+
+  it("disallows space", async () => {
+    expect(isValidGitBranchName("feature branch")).toBe(false);
+  });
+
+  it("disallows tilde (~)", async () => {
+    expect(isValidGitBranchName("feature~branch")).toBe(false);
+  });
+
+  it("disallows caret (^)", async () => {
+    expect(isValidGitBranchName("feature^branch")).toBe(false);
+  });
+
+  it("disallows colon (:)", async () => {
+    expect(isValidGitBranchName("feature:branch")).toBe(false);
+  });
+
+  it("disallows question mark (?)", async () => {
+    expect(isValidGitBranchName("feature?branch")).toBe(false);
+  });
+
+  it("disallows asterisk (*)", async () => {
+    expect(isValidGitBranchName("feature*branch")).toBe(false);
+  });
+
+  it("disallows open bracket ([)", async () => {
+    expect(isValidGitBranchName("feature[branch")).toBe(false);
+  });
+
+  it("disallows backslash (\\)", async () => {
+    expect(isValidGitBranchName("feature\\branch")).toBe(false);
+  });
+
+  it("disallows branch names that begin with a slash", async () => {
+    expect(isValidGitBranchName("/feature-branch")).toBe(false);
+  });
+
+  it("disallows branch names that end with a slash", async () => {
+    expect(isValidGitBranchName("feature-branch/")).toBe(false);
+  });
+
+  it("disallows consecutive slashes (//)", async () => {
+    expect(isValidGitBranchName("feature//branch")).toBe(false);
+  });
+
+  it("disallows the sequence ..", async () => {
+    expect(isValidGitBranchName("feature..branch")).toBe(false);
+  });
+
+  it("disallows @{ in the name", async () => {
+    expect(isValidGitBranchName("feature@{branch")).toBe(false);
+  });
+
+  it("disallows names ending with .lock", async () => {
+    expect(isValidGitBranchName("feature-branch.lock")).toBe(false);
+  });
+});
+
+describe("branchNameFromRef", () => {
+  it("returns the branch name for refs/heads/branch", async () => {
+    const result = sanitizeBranchName("refs/heads/feature/branch");
+    expect(result).toBe("feature/branch");
+  });
+
+  it("returns the branch name for refs/remotes/origin/branch", async () => {
+    const result = sanitizeBranchName("refs/remotes/origin/feature/branch");
+    expect(result).toBe("origin/feature/branch");
+  });
+
+  it("returns the tag name for refs/tags/v1.0.0", async () => {
+    const result = sanitizeBranchName("refs/tags/v1.0.0");
+    expect(result).toBe("v1.0.0");
+  });
+
+  it("returns the input if just a branch name is given", async () => {
+    const result = sanitizeBranchName("feature/branch");
+    expect(result).toBe("feature/branch");
+  });
+
+  it("returns null for an invalid ref", async () => {
+    const result = sanitizeBranchName("refs/invalid/branch");
+    expect(result).toBeNull();
+  });
+
+  it("returns null for an empty string", async () => {
+    const result = sanitizeBranchName("");
+    expect(result).toBeNull();
+  });
+});
diff --git a/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql
new file mode 100644
index 0000000000..eb32648d76
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql
@@ -0,0 +1,24 @@
+-- AlterTable
+ALTER TABLE "RuntimeEnvironment"
+ADD COLUMN IF NOT EXISTS "archivedAt" TIMESTAMP(3),
+ADD COLUMN IF NOT EXISTS "branchName" TEXT,
+ADD COLUMN IF NOT EXISTS "git" JSONB,
+ADD COLUMN IF NOT EXISTS "parentEnvironmentId" TEXT;
+
+-- AddForeignKey
+DO $$ 
+BEGIN 
+    IF NOT EXISTS (
+        SELECT 1 
+        FROM information_schema.table_constraints 
+        WHERE table_name = 'RuntimeEnvironment' 
+        AND constraint_name = 'RuntimeEnvironment_parentEnvironmentId_fkey'
+    ) THEN 
+        ALTER TABLE "RuntimeEnvironment" 
+        ADD CONSTRAINT "RuntimeEnvironment_parentEnvironmentId_fkey" 
+        FOREIGN KEY ("parentEnvironmentId") 
+        REFERENCES "RuntimeEnvironment" ("id") 
+        ON DELETE CASCADE 
+        ON UPDATE CASCADE;
+    END IF;
+END $$;
\ No newline at end of file
diff --git a/internal-packages/database/prisma/migrations/20250509180346_runtime_environment_parent_environment_id_index/migration.sql b/internal-packages/database/prisma/migrations/20250509180346_runtime_environment_parent_environment_id_index/migration.sql
new file mode 100644
index 0000000000..a3beef3c1e
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20250509180346_runtime_environment_parent_environment_id_index/migration.sql
@@ -0,0 +1,2 @@
+-- CreateIndex
+CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_parentEnvironmentId_idx" ON "RuntimeEnvironment" ("parentEnvironmentId");
\ No newline at end of file
diff --git a/internal-packages/database/prisma/migrations/20250511145836_runtime_environment_add_is_branchable_environment/migration.sql b/internal-packages/database/prisma/migrations/20250511145836_runtime_environment_add_is_branchable_environment/migration.sql
new file mode 100644
index 0000000000..d241e349b3
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20250511145836_runtime_environment_add_is_branchable_environment/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "RuntimeEnvironment"
+ADD COLUMN "isBranchableEnvironment" BOOLEAN NOT NULL DEFAULT false;
\ No newline at end of file
diff --git a/internal-packages/database/prisma/migrations/20250513135201_runtime_environment_add_project_id_index/migration.sql b/internal-packages/database/prisma/migrations/20250513135201_runtime_environment_add_project_id_index/migration.sql
new file mode 100644
index 0000000000..1787fc45db
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20250513135201_runtime_environment_add_project_id_index/migration.sql
@@ -0,0 +1 @@
+CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_projectId_idx" ON "RuntimeEnvironment" ("projectId");
\ No newline at end of file
diff --git a/internal-packages/database/prisma/migrations/20250515134154_worker_deployment_add_git_info/migration.sql b/internal-packages/database/prisma/migrations/20250515134154_worker_deployment_add_git_info/migration.sql
new file mode 100644
index 0000000000..3f6e1ef8ec
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20250515134154_worker_deployment_add_git_info/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "WorkerDeployment"
+ADD COLUMN "git" JSONB;
\ No newline at end of file
diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma
index 5d143e18da..0435c7e779 100644
--- a/internal-packages/database/prisma/schema.prisma
+++ b/internal-packages/database/prisma/schema.prisma
@@ -373,13 +373,29 @@ model OrgMemberInvite {
 }
 
 model RuntimeEnvironment {
-  id       String @id @default(cuid())
-  slug     String
-  apiKey   String @unique
+  id     String @id @default(cuid())
+  slug   String
+  apiKey String @unique
+
+  /// @deprecated was for v2
   pkApiKey String @unique
 
   type RuntimeEnvironmentType @default(DEVELOPMENT)
 
+  // Preview branches
+  /// If true, this environment has branches and is treated differently in the dashboard/API
+  isBranchableEnvironment Boolean              @default(false)
+  branchName              String?
+  parentEnvironment       RuntimeEnvironment?  @relation("parentEnvironment", fields: [parentEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+  parentEnvironmentId     String?
+  childEnvironments       RuntimeEnvironment[] @relation("parentEnvironment")
+
+  // This is GitMeta type
+  git Json?
+
+  /// When set API calls will fail
+  archivedAt DateTime?
+
   ///A memorable code for the environment
   shortcode String
 
@@ -401,24 +417,26 @@ model RuntimeEnvironment {
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt
 
-  tunnelId String?
-
-  endpoints                  Endpoint[]
-  jobVersions                JobVersion[]
-  events                     EventRecord[]
-  jobRuns                    JobRun[]
-  requestDeliveries          HttpSourceRequestDelivery[]
-  jobAliases                 JobAlias[]
-  JobQueue                   JobQueue[]
-  sources                    TriggerSource[]
-  eventDispatchers           EventDispatcher[]
-  scheduleSources            ScheduleSource[]
-  ExternalAccount            ExternalAccount[]
-  httpEndpointEnvironments   TriggerHttpEndpointEnvironment[]
-  concurrencyLimitGroups     ConcurrencyLimitGroup[]
-  keyValueItems              KeyValueItem[]
-  webhookEnvironments        WebhookEnvironment[]
-  webhookRequestDeliveries   WebhookRequestDelivery[]
+  /// @deprecated (v2)
+  tunnelId                 String?
+  endpoints                Endpoint[]
+  jobVersions              JobVersion[]
+  events                   EventRecord[]
+  jobRuns                  JobRun[]
+  requestDeliveries        HttpSourceRequestDelivery[]
+  jobAliases               JobAlias[]
+  JobQueue                 JobQueue[]
+  sources                  TriggerSource[]
+  eventDispatchers         EventDispatcher[]
+  scheduleSources          ScheduleSource[]
+  ExternalAccount          ExternalAccount[]
+  httpEndpointEnvironments TriggerHttpEndpointEnvironment[]
+  concurrencyLimitGroups   ConcurrencyLimitGroup[]
+  keyValueItems            KeyValueItem[]
+  webhookEnvironments      WebhookEnvironment[]
+  webhookRequestDeliveries WebhookRequestDelivery[]
+
+  /// v3
   backgroundWorkers          BackgroundWorker[]
   backgroundWorkerTasks      BackgroundWorkerTask[]
   taskRuns                   TaskRun[]
@@ -445,6 +463,8 @@ model RuntimeEnvironment {
 
   @@unique([projectId, slug, orgMemberId])
   @@unique([projectId, shortcode])
+  @@index([parentEnvironmentId])
+  @@index([projectId])
 }
 
 enum RuntimeEnvironmentType {
@@ -2862,6 +2882,9 @@ model WorkerDeployment {
   failedAt  DateTime?
   errorData Json?
 
+  // This is GitMeta type
+  git Json?
+
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt
 
diff --git a/internal-packages/run-engine/src/engine/db/worker.ts b/internal-packages/run-engine/src/engine/db/worker.ts
index 3e4ce60b61..6b89eacbaf 100644
--- a/internal-packages/run-engine/src/engine/db/worker.ts
+++ b/internal-packages/run-engine/src/engine/db/worker.ts
@@ -32,7 +32,8 @@ type RunWithBackgroundWorkerTasksResult =
         | "TASK_NOT_IN_LATEST"
         | "TASK_NEVER_REGISTERED"
         | "BACKGROUND_WORKER_MISMATCH"
-        | "QUEUE_NOT_FOUND";
+        | "QUEUE_NOT_FOUND"
+        | "RUN_ENVIRONMENT_ARCHIVED";
       message: string;
       run: RunWithMininimalEnvironment;
     }
@@ -69,6 +70,7 @@ export async function getRunWithBackgroundWorkerTasks(
         select: {
           id: true,
           type: true,
+          archivedAt: true,
         },
       },
       lockedToVersion: {
@@ -88,6 +90,15 @@ export async function getRunWithBackgroundWorkerTasks(
     };
   }
 
+  if (run.runtimeEnvironment.archivedAt) {
+    return {
+      success: false as const,
+      code: "RUN_ENVIRONMENT_ARCHIVED",
+      message: `Run is on an archived environment: ${run.id}`,
+      run,
+    };
+  }
+
   const workerId = run.lockedToVersionId ?? backgroundWorkerId;
 
   //get the relevant BackgroundWorker with tasks and deployment (if not DEV)
diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts
index 91e47c7ec4..d02ac75f97 100644
--- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts
+++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts
@@ -198,6 +198,19 @@ export class DequeueSystem {
                       await this.$.runQueue.acknowledgeMessage(orgId, runId);
                       return null;
                     }
+                    case "RUN_ENVIRONMENT_ARCHIVED": {
+                      //this happens if the preview branch was archived
+                      this.$.logger.warn(
+                        "RunEngine.dequeueFromMasterQueue(): Run environment archived",
+                        {
+                          runId,
+                          latestSnapshot: snapshot.id,
+                          result,
+                        }
+                      );
+                      await this.$.runQueue.acknowledgeMessage(orgId, runId);
+                      return null;
+                    }
                     case "NO_WORKER":
                     case "TASK_NEVER_REGISTERED":
                     case "QUEUE_NOT_FOUND":
diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts
index a63110c970..687b5aa028 100644
--- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts
+++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts
@@ -2,6 +2,7 @@ import { startSpan } from "@internal/tracing";
 import {
   CompleteRunAttemptResult,
   ExecutionResult,
+  GitMeta,
   StartRunAttemptResult,
   TaskRunError,
   TaskRunExecution,
@@ -25,15 +26,15 @@ import { retryOutcomeFromCompletion } from "../retrying.js";
 import { isExecuting, isInitialState } from "../statuses.js";
 import { RunEngineOptions } from "../types.js";
 import { BatchSystem } from "./batchSystem.js";
+import { DelayedRunSystem } from "./delayedRunSystem.js";
 import {
   executionResultFromSnapshot,
   ExecutionSnapshotSystem,
   getLatestExecutionSnapshot,
 } from "./executionSnapshotSystem.js";
+import { ReleaseConcurrencySystem } from "./releaseConcurrencySystem.js";
 import { SystemResources } from "./systems.js";
 import { WaitpointSystem } from "./waitpointSystem.js";
-import { DelayedRunSystem } from "./delayedRunSystem.js";
-import { ReleaseConcurrencySystem } from "./releaseConcurrencySystem.js";
 
 export type RunAttemptSystemOptions = {
   resources: SystemResources;
@@ -284,6 +285,14 @@ export class RunAttemptSystem {
             dataType: taskRun.metadataType,
           });
 
+          let git: GitMeta | undefined = undefined;
+          if (environment.git) {
+            const parsed = GitMeta.safeParse(environment.git);
+            if (parsed.success) {
+              git = parsed.data;
+            }
+          }
+
           const execution: TaskRunExecution = {
             task: {
               id: run.lockedBy!.slug,
@@ -334,6 +343,8 @@ export class RunAttemptSystem {
               id: environment.id,
               slug: environment.slug,
               type: environment.type,
+              branchName: environment.branchName ?? undefined,
+              git,
             },
             organization: {
               id: environment.organization.id,
diff --git a/packages/build/src/extensions/core/syncEnvVars.ts b/packages/build/src/extensions/core/syncEnvVars.ts
index 231bdb86f0..107ea15359 100644
--- a/packages/build/src/extensions/core/syncEnvVars.ts
+++ b/packages/build/src/extensions/core/syncEnvVars.ts
@@ -11,6 +11,7 @@ export type SyncEnvVarsResult =
 export type SyncEnvVarsParams = {
   projectRef: string;
   environment: string;
+  branch?: string;
   env: Record<string, string>;
 };
 
@@ -85,6 +86,7 @@ export function syncEnvVars(fn: SyncEnvVarsFunction, options?: SyncEnvVarsOption
         fn,
         manifest.deploy.env ?? {},
         manifest.environment,
+        manifest.branch,
         context
       );
 
@@ -138,6 +140,7 @@ async function callSyncEnvVarsFn(
   syncEnvVarsFn: SyncEnvVarsFunction | undefined,
   env: Record<string, string>,
   environment: string,
+  branch: string | undefined,
   context: BuildContext
 ): Promise<Record<string, string> | undefined> {
   if (syncEnvVarsFn && typeof syncEnvVarsFn === "function") {
@@ -149,6 +152,7 @@ async function callSyncEnvVarsFn(
         projectRef: context.config.project,
         environment,
         env,
+        branch,
       });
     } catch (error) {
       context.logger.warn("Error calling syncEnvVars function", error);
diff --git a/packages/build/src/extensions/core/vercelSyncEnvVars.ts b/packages/build/src/extensions/core/vercelSyncEnvVars.ts
index 8e125f95da..74a832d8c0 100644
--- a/packages/build/src/extensions/core/vercelSyncEnvVars.ts
+++ b/packages/build/src/extensions/core/vercelSyncEnvVars.ts
@@ -1,31 +1,43 @@
 import { BuildExtension } from "@trigger.dev/core/v3/build";
 import { syncEnvVars } from "../core.js";
 
-export function syncVercelEnvVars(
-  options?: {
-    projectId?: string;
-    vercelAccessToken?: string;
-    vercelTeamId?: string;
-  },
-): BuildExtension {
+export function syncVercelEnvVars(options?: {
+  projectId?: string;
+  vercelAccessToken?: string;
+  vercelTeamId?: string;
+  branch?: string;
+}): BuildExtension {
   const sync = syncEnvVars(async (ctx) => {
-    const projectId = options?.projectId ?? process.env.VERCEL_PROJECT_ID ??
-      ctx.env.VERCEL_PROJECT_ID;
-    const vercelAccessToken = options?.vercelAccessToken ??
+    const projectId =
+      options?.projectId ?? process.env.VERCEL_PROJECT_ID ?? ctx.env.VERCEL_PROJECT_ID;
+    const vercelAccessToken =
+      options?.vercelAccessToken ??
       process.env.VERCEL_ACCESS_TOKEN ??
-      ctx.env.VERCEL_ACCESS_TOKEN;
-    const vercelTeamId = options?.vercelTeamId ?? process.env.VERCEL_TEAM_ID ??
-      ctx.env.VERCEL_TEAM_ID;
+      ctx.env.VERCEL_ACCESS_TOKEN ??
+      process.env.VERCEL_TOKEN;
+    const vercelTeamId =
+      options?.vercelTeamId ?? process.env.VERCEL_TEAM_ID ?? ctx.env.VERCEL_TEAM_ID;
+    const branch =
+      options?.branch ??
+      process.env.VERCEL_PREVIEW_BRANCH ??
+      ctx.env.VERCEL_PREVIEW_BRANCH ??
+      ctx.branch;
+
+    console.debug("syncVercelEnvVars()", {
+      projectId,
+      vercelTeamId,
+      branch,
+    });
 
     if (!projectId) {
       throw new Error(
-        "syncVercelEnvVars: you did not pass in a projectId or set the VERCEL_PROJECT_ID env var.",
+        "syncVercelEnvVars: you did not pass in a projectId or set the VERCEL_PROJECT_ID env var."
       );
     }
 
     if (!vercelAccessToken) {
       throw new Error(
-        "syncVercelEnvVars: you did not pass in a vercelAccessToken or set the VERCEL_ACCESS_TOKEN env var.",
+        "syncVercelEnvVars: you did not pass in a vercelAccessToken or set the VERCEL_ACCESS_TOKEN env var."
       );
     }
 
@@ -33,20 +45,20 @@ export function syncVercelEnvVars(
       prod: "production",
       staging: "preview",
       dev: "development",
+      preview: "preview",
     } as const;
 
-    const vercelEnvironment =
-      environmentMap[ctx.environment as keyof typeof environmentMap];
+    const vercelEnvironment = environmentMap[ctx.environment as keyof typeof environmentMap];
 
     if (!vercelEnvironment) {
       throw new Error(
-        `Invalid environment '${ctx.environment}'. Expected 'prod', 'staging', or 'dev'.`,
+        `Invalid environment '${ctx.environment}'. Expected 'prod', 'staging', or 'dev'.`
       );
     }
     const params = new URLSearchParams({ decrypt: "true" });
     if (vercelTeamId) params.set("teamId", vercelTeamId);
-    const vercelApiUrl =
-      `https://api.vercel.com/v8/projects/${projectId}/env?${params}`;
+    if (branch) params.set("gitBranch", branch);
+    const vercelApiUrl = `https://api.vercel.com/v8/projects/${projectId}/env?${params}`;
 
     try {
       const response = await fetch(vercelApiUrl, {
@@ -64,8 +76,7 @@ export function syncVercelEnvVars(
       const filteredEnvs = data.envs
         .filter(
           (env: { type: string; value: string; target: string[] }) =>
-            env.value &&
-            env.target.includes(vercelEnvironment),
+            env.value && env.target.includes(vercelEnvironment)
         )
         .map((env: { key: string; value: string }) => ({
           name: env.key,
@@ -74,10 +85,7 @@ export function syncVercelEnvVars(
 
       return filteredEnvs;
     } catch (error) {
-      console.error(
-        "Error fetching or processing Vercel environment variables:",
-        error,
-      );
+      console.error("Error fetching or processing Vercel environment variables:", error);
       throw error; // Re-throw the error to be handled by the caller
     }
   });
diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json
index c8652d448d..7ff111fac7 100644
--- a/packages/cli-v3/package.json
+++ b/packages/cli-v3/package.json
@@ -51,6 +51,7 @@
     "@epic-web/test-server": "^0.1.0",
     "@types/eventsource": "^1.1.15",
     "@types/gradient-string": "^1.1.2",
+    "@types/ini": "^4.1.1",
     "@types/object-hash": "3.0.6",
     "@types/polka": "^0.5.7",
     "@types/react": "^18.2.48",
@@ -107,10 +108,12 @@
     "eventsource": "^3.0.2",
     "evt": "^2.4.13",
     "fast-npm-meta": "^0.2.2",
+    "git-last-commit": "^1.0.1",
     "gradient-string": "^2.0.2",
     "has-flag": "^5.0.1",
     "import-in-the-middle": "1.11.0",
     "import-meta-resolve": "^4.1.0",
+    "ini": "^5.0.0",
     "jsonc-parser": "3.2.1",
     "magicast": "^0.3.4",
     "minimatch": "^10.0.1",
diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts
index 5c01eea72a..1e4398b539 100644
--- a/packages/cli-v3/src/apiClient.ts
+++ b/packages/cli-v3/src/apiClient.ts
@@ -1,39 +1,37 @@
-import { z } from "zod";
-import { EventSource } from "eventsource";
 import {
   CreateAuthorizationCodeResponseSchema,
-  GetPersonalAccessTokenResponseSchema,
-  WhoAmIResponseSchema,
   CreateBackgroundWorkerRequestBody,
   CreateBackgroundWorkerResponse,
-  StartDeploymentIndexingResponseBody,
-  GetProjectEnvResponse,
-  GetEnvironmentVariablesResponseBody,
-  InitializeDeploymentResponseBody,
-  InitializeDeploymentRequestBody,
-  StartDeploymentIndexingRequestBody,
-  GetDeploymentResponseBody,
-  GetProjectsResponseBody,
-  GetProjectResponseBody,
-  ImportEnvironmentVariablesRequestBody,
+  DevConfigResponseBody,
+  DevDequeueRequestBody,
+  DevDequeueResponseBody,
   EnvironmentVariableResponseBody,
-  TaskRunExecution,
   FailDeploymentRequestBody,
   FailDeploymentResponseBody,
   FinalizeDeploymentRequestBody,
-  WorkersListResponseBody,
-  WorkersCreateResponseBody,
-  WorkersCreateRequestBody,
-  TriggerTaskRequestBody,
-  TriggerTaskResponse,
+  GetDeploymentResponseBody,
+  GetEnvironmentVariablesResponseBody,
   GetLatestDeploymentResponseBody,
-  DevConfigResponseBody,
-  DevDequeueRequestBody,
-  DevDequeueResponseBody,
+  GetPersonalAccessTokenResponseSchema,
+  GetProjectEnvResponse,
+  GetProjectResponseBody,
+  GetProjectsResponseBody,
+  ImportEnvironmentVariablesRequestBody,
+  InitializeDeploymentRequestBody,
+  InitializeDeploymentResponseBody,
   PromoteDeploymentResponseBody,
+  StartDeploymentIndexingRequestBody,
+  StartDeploymentIndexingResponseBody,
+  TaskRunExecution,
+  TriggerTaskRequestBody,
+  TriggerTaskResponse,
+  UpsertBranchRequestBody,
+  UpsertBranchResponseBody,
+  WhoAmIResponseSchema,
+  WorkersCreateRequestBody,
+  WorkersCreateResponseBody,
+  WorkersListResponseBody,
 } from "@trigger.dev/core/v3";
-import { ApiResult, wrapZodFetch, zodfetchSSE } from "@trigger.dev/core/v3/zodfetch";
-import { logger } from "./utilities/logger.js";
 import {
   WorkloadDebugLogRequestBody,
   WorkloadHeartbeatRequestBody,
@@ -43,6 +41,10 @@ import {
   WorkloadRunAttemptStartResponseBody,
   WorkloadRunLatestSnapshotResponseBody,
 } from "@trigger.dev/core/v3/workers";
+import { ApiResult, wrapZodFetch, zodfetchSSE } from "@trigger.dev/core/v3/zodfetch";
+import { EventSource } from "eventsource";
+import { z } from "zod";
+import { logger } from "./utilities/logger.js";
 
 export class CliApiClient {
   private engineURL: string;
@@ -50,10 +52,12 @@ export class CliApiClient {
   constructor(
     public readonly apiURL: string,
     // TODO: consider making this required
-    public readonly accessToken?: string
+    public readonly accessToken?: string,
+    public readonly branch?: string
   ) {
     this.apiURL = apiURL.replace(/\/$/, "");
     this.engineURL = this.apiURL;
+    this.branch = branch;
   }
 
   async createAuthorizationCode() {
@@ -136,44 +140,60 @@ export class CliApiClient {
       `${this.apiURL}/api/v1/projects/${projectRef}/background-workers`,
       {
         method: "POST",
-        headers: {
-          Authorization: `Bearer ${this.accessToken}`,
-          "Content-Type": "application/json",
-        },
+        headers: this.getHeaders(),
         body: JSON.stringify(body),
       }
     );
   }
 
-  async createTaskRunAttempt(
-    runFriendlyId: string
-  ): Promise<ApiResult<z.infer<typeof TaskRunExecution>>> {
+  async getProjectEnv({ projectRef, env }: { projectRef: string; env: string }) {
     if (!this.accessToken) {
-      throw new Error("creatTaskRunAttempt: No access token");
+      throw new Error("getProjectDevEnv: No access token");
     }
 
-    return wrapZodFetch(TaskRunExecution, `${this.apiURL}/api/v1/runs/${runFriendlyId}/attempts`, {
-      method: "POST",
-      headers: {
-        Authorization: `Bearer ${this.accessToken}`,
-        "Content-Type": "application/json",
-      },
-    });
+    return wrapZodFetch(
+      GetProjectEnvResponse,
+      `${this.apiURL}/api/v1/projects/${projectRef}/${env}`,
+      {
+        headers: {
+          Authorization: `Bearer ${this.accessToken}`,
+          "Content-Type": "application/json",
+        },
+      }
+    );
   }
 
-  async getProjectEnv({ projectRef, env }: { projectRef: string; env: string }) {
+  async upsertBranch(projectRef: string, body: UpsertBranchRequestBody) {
     if (!this.accessToken) {
-      throw new Error("getProjectDevEnv: No access token");
+      throw new Error("upsertBranch: No access token");
     }
 
     return wrapZodFetch(
-      GetProjectEnvResponse,
-      `${this.apiURL}/api/v1/projects/${projectRef}/${env}`,
+      UpsertBranchResponseBody,
+      `${this.apiURL}/api/v1/projects/${projectRef}/branches`,
       {
+        method: "POST",
         headers: {
           Authorization: `Bearer ${this.accessToken}`,
           "Content-Type": "application/json",
         },
+        body: JSON.stringify(body),
+      }
+    );
+  }
+
+  async archiveBranch(projectRef: string, branch: string) {
+    if (!this.accessToken) {
+      throw new Error("archiveBranch: No access token");
+    }
+
+    return wrapZodFetch(
+      z.object({ branch: z.object({ id: z.string() }) }),
+      `${this.apiURL}/api/v1/projects/${projectRef}/branches/archive`,
+      {
+        method: "POST",
+        headers: this.getHeaders(),
+        body: JSON.stringify({ branch }),
       }
     );
   }
@@ -187,10 +207,7 @@ export class CliApiClient {
       GetEnvironmentVariablesResponseBody,
       `${this.apiURL}/api/v1/projects/${projectRef}/envvars`,
       {
-        headers: {
-          Authorization: `Bearer ${this.accessToken}`,
-          "Content-Type": "application/json",
-        },
+        headers: this.getHeaders(),
       }
     );
   }
@@ -209,10 +226,7 @@ export class CliApiClient {
       `${this.apiURL}/api/v1/projects/${projectRef}/envvars/${slug}/import`,
       {
         method: "POST",
-        headers: {
-          Authorization: `Bearer ${this.accessToken}`,
-          "Content-Type": "application/json",
-        },
+        headers: this.getHeaders(),
         body: JSON.stringify(params),
       }
     );
@@ -225,10 +239,7 @@ export class CliApiClient {
 
     return wrapZodFetch(InitializeDeploymentResponseBody, `${this.apiURL}/api/v1/deployments`, {
       method: "POST",
-      headers: {
-        Authorization: `Bearer ${this.accessToken}`,
-        "Content-Type": "application/json",
-      },
+      headers: this.getHeaders(),
       body: JSON.stringify(body),
     });
   }
@@ -246,10 +257,7 @@ export class CliApiClient {
       `${this.apiURL}/api/v1/deployments/${deploymentId}/background-workers`,
       {
         method: "POST",
-        headers: {
-          Authorization: `Bearer ${this.accessToken}`,
-          "Content-Type": "application/json",
-        },
+        headers: this.getHeaders(),
         body: JSON.stringify(body),
       }
     );
@@ -265,10 +273,7 @@ export class CliApiClient {
       `${this.apiURL}/api/v1/deployments/${id}/fail`,
       {
         method: "POST",
-        headers: {
-          Authorization: `Bearer ${this.accessToken}`,
-          "Content-Type": "application/json",
-        },
+        headers: this.getHeaders(),
         body: JSON.stringify(body),
       }
     );
@@ -295,10 +300,7 @@ export class CliApiClient {
       url: `${this.apiURL}/api/v3/deployments/${id}/finalize`,
       request: {
         method: "POST",
-        headers: {
-          Authorization: `Bearer ${this.accessToken}`,
-          "Content-Type": "application/json",
-        },
+        headers: this.getHeaders(),
         body: JSON.stringify(body),
       },
       messages: {
@@ -352,10 +354,7 @@ export class CliApiClient {
       `${this.apiURL}/api/v1/deployments/${version}/promote`,
       {
         method: "POST",
-        headers: {
-          Authorization: `Bearer ${this.accessToken}`,
-          "Content-Type": "application/json",
-        },
+        headers: this.getHeaders(),
       }
     );
   }
@@ -370,10 +369,7 @@ export class CliApiClient {
       `${this.apiURL}/api/v1/deployments/${deploymentId}/start-indexing`,
       {
         method: "POST",
-        headers: {
-          Authorization: `Bearer ${this.accessToken}`,
-          "Content-Type": "application/json",
-        },
+        headers: this.getHeaders(),
         body: JSON.stringify(body),
       }
     );
@@ -388,10 +384,7 @@ export class CliApiClient {
       GetDeploymentResponseBody,
       `${this.apiURL}/api/v1/deployments/${deploymentId}`,
       {
-        headers: {
-          Authorization: `Bearer ${this.accessToken}`,
-          Accept: "application/json",
-        },
+        headers: this.getHeaders(),
       }
     );
   }
@@ -403,10 +396,7 @@ export class CliApiClient {
 
     return wrapZodFetch(TriggerTaskResponse, `${this.apiURL}/api/v1/tasks/${taskId}/trigger`, {
       method: "POST",
-      headers: {
-        Authorization: `Bearer ${this.accessToken}`,
-        Accept: "application/json",
-      },
+      headers: this.getHeaders(),
       body: JSON.stringify(body ?? {}),
     });
   }
@@ -449,10 +439,7 @@ export class CliApiClient {
       GetLatestDeploymentResponseBody,
       `${this.apiURL}/api/v1/deployments/latest`,
       {
-        headers: {
-          Authorization: `Bearer ${this.accessToken}`,
-          Accept: "application/json",
-        },
+        headers: this.getHeaders(),
       }
     );
   }
@@ -463,10 +450,7 @@ export class CliApiClient {
     }
 
     return wrapZodFetch(WorkersListResponseBody, `${this.apiURL}/api/v1/workers`, {
-      headers: {
-        Authorization: `Bearer ${this.accessToken}`,
-        Accept: "application/json",
-      },
+      headers: this.getHeaders(),
     });
   }
 
@@ -477,10 +461,7 @@ export class CliApiClient {
 
     return wrapZodFetch(WorkersCreateResponseBody, `${this.apiURL}/api/v1/workers`, {
       method: "POST",
-      headers: {
-        Authorization: `Bearer ${this.accessToken}`,
-        Accept: "application/json",
-      },
+      headers: this.getHeaders(),
       body: JSON.stringify(options),
     });
   }
@@ -667,4 +648,17 @@ export class CliApiClient {
   private setEngineURL(engineURL: string) {
     this.engineURL = engineURL.replace(/\/$/, "");
   }
+
+  private getHeaders() {
+    const headers: Record<string, string> = {
+      Authorization: `Bearer ${this.accessToken}`,
+      "Content-Type": "application/json",
+    };
+
+    if (this.branch) {
+      headers["x-trigger-branch"] = this.branch;
+    }
+
+    return headers;
+  }
 }
diff --git a/packages/cli-v3/src/build/buildWorker.ts b/packages/cli-v3/src/build/buildWorker.ts
index 8b396f21c7..168fb31e86 100644
--- a/packages/cli-v3/src/build/buildWorker.ts
+++ b/packages/cli-v3/src/build/buildWorker.ts
@@ -28,6 +28,7 @@ export type BuildWorkerOptions = {
   destination: string;
   target: BuildTarget;
   environment: string;
+  branch?: string;
   resolvedConfig: ResolvedConfig;
   listener?: BuildWorkerEventListener;
   envVars?: Record<string, string>;
@@ -75,6 +76,7 @@ export async function buildWorker(options: BuildWorkerOptions) {
     destination: options.destination,
     resolvedConfig,
     environment: options.environment,
+    branch: options.branch,
     target: options.target,
     envVars: options.envVars,
   });
diff --git a/packages/cli-v3/src/build/bundle.ts b/packages/cli-v3/src/build/bundle.ts
index b4675cce6a..d8687922d1 100644
--- a/packages/cli-v3/src/build/bundle.ts
+++ b/packages/cli-v3/src/build/bundle.ts
@@ -344,6 +344,7 @@ export async function createBuildManifestFromBundle({
   resolvedConfig,
   workerDir,
   environment,
+  branch,
   target,
   envVars,
   sdkVersion,
@@ -353,6 +354,7 @@ export async function createBuildManifestFromBundle({
   resolvedConfig: ResolvedConfig;
   workerDir?: string;
   environment: string;
+  branch?: string;
   target: BuildTarget;
   envVars?: Record<string, string>;
   sdkVersion?: string;
@@ -361,6 +363,7 @@ export async function createBuildManifestFromBundle({
     contentHash: bundle.contentHash,
     runtime: resolvedConfig.runtime ?? DEFAULT_RUNTIME,
     environment: environment,
+    branch,
     packageVersion: sdkVersion ?? CORE_VERSION,
     cliPackageVersion: VERSION,
     target: target,
diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts
index fd024d1f6f..694b2b4280 100644
--- a/packages/cli-v3/src/cli/index.ts
+++ b/packages/cli-v3/src/cli/index.ts
@@ -14,6 +14,7 @@ import { configureWorkersCommand } from "../commands/workers/index.js";
 import { configureSwitchProfilesCommand } from "../commands/switch.js";
 import { configureTriggerTaskCommand } from "../commands/trigger.js";
 import { configurePromoteCommand } from "../commands/promote.js";
+import { configurePreviewCommand } from "../commands/preview.js";
 
 export const program = new Command();
 
@@ -32,6 +33,7 @@ configureLogoutCommand(program);
 configureListProfilesCommand(program);
 configureSwitchProfilesCommand(program);
 configureUpdateCommand(program);
+configurePreviewCommand(program);
 // configureWorkersCommand(program);
 // configureTriggerTaskCommand(program);
 
diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts
index 581235e325..63f9d87a49 100644
--- a/packages/cli-v3/src/commands/deploy.ts
+++ b/packages/cli-v3/src/commands/deploy.ts
@@ -1,11 +1,11 @@
 import { intro, log, outro } from "@clack/prompts";
-import { prepareDeploymentError, tryCatch } from "@trigger.dev/core/v3";
+import { getBranch, prepareDeploymentError, tryCatch } from "@trigger.dev/core/v3";
 import { InitializeDeploymentResponseBody } from "@trigger.dev/core/v3/schemas";
 import { Command, Option as CommandOption } from "commander";
 import { resolve } from "node:path";
+import { isCI } from "std-env";
 import { x } from "tinyexec";
 import { z } from "zod";
-import { isCI } from "std-env";
 import { CliApiClient } from "../apiClient.js";
 import { buildWorker } from "../build/buildWorker.js";
 import { resolveAlwaysExternal } from "../build/externals.js";
@@ -27,21 +27,24 @@ import {
 } from "../deploy/logs.js";
 import { chalkError, cliLink, isLinksSupported, prettyError } from "../utilities/cliOutput.js";
 import { loadDotEnvVars } from "../utilities/dotEnv.js";
+import { isDirectory } from "../utilities/fileSystem.js";
+import { setGithubActionsOutputAndEnvVars } from "../utilities/githubActions.js";
+import { createGitMeta } from "../utilities/gitMeta.js";
 import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
+import { resolveLocalEnvVars } from "../utilities/localEnvVars.js";
 import { logger } from "../utilities/logger.js";
-import { getProjectClient } from "../utilities/session.js";
+import { getProjectClient, upsertBranch } from "../utilities/session.js";
 import { getTmpDir } from "../utilities/tempDirectories.js";
 import { spinner } from "../utilities/windows.js";
 import { login } from "./login.js";
+import { archivePreviewBranch } from "./preview.js";
 import { updateTriggerPackages } from "./update.js";
-import { setGithubActionsOutputAndEnvVars } from "../utilities/githubActions.js";
-import { isDirectory } from "../utilities/fileSystem.js";
-import { resolveLocalEnvVars } from "../utilities/localEnvVars.js";
 
 const DeployCommandOptions = CommonCommandOptions.extend({
   dryRun: z.boolean().default(false),
   skipSyncEnvVars: z.boolean().default(false),
-  env: z.enum(["prod", "staging"]),
+  env: z.enum(["prod", "staging", "preview"]),
+  branch: z.string().optional(),
   loadImage: z.boolean().default(false),
   buildPlatform: z.enum(["linux/amd64", "linux/arm64"]).default("linux/amd64"),
   namespace: z.string().optional(),
@@ -73,6 +76,10 @@ export function configureDeployCommand(program: Command) {
         "Deploy to a specific environment (currently only prod and staging are supported)",
         "prod"
       )
+      .option(
+        "-b, --branch <branch>",
+        "The preview branch to deploy to when passing --env preview. If not provided, we'll detect your git branch."
+      )
       .option("--skip-update-check", "Skip checking for @trigger.dev package updates")
       .option("-c, --config <config file>", "The name of the config file, found at [path]")
       .option(
@@ -174,21 +181,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
   const cwd = process.cwd();
   const projectPath = resolve(cwd, dir);
 
-  if (dir !== "." && !isDirectory(projectPath)) {
-    if (dir === "staging" || dir === "prod") {
-      throw new Error(`To deploy to ${dir}, you need to pass "--env ${dir}", not just "${dir}".`);
-    }
-
-    if (dir === "production") {
-      throw new Error(`To deploy to production, you need to pass "--env prod", not "production".`);
-    }
-
-    if (dir === "stg") {
-      throw new Error(`To deploy to staging, you need to pass "--env staging", not "stg".`);
-    }
-
-    throw new Error(`Directory "${dir}" not found at ${projectPath}`);
-  }
+  verifyDirectory(dir, projectPath);
 
   const authorization = await login({
     embedded: true,
@@ -222,11 +215,54 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
 
   logger.debug("Resolved config", resolvedConfig);
 
+  const gitMeta = await createGitMeta(resolvedConfig.workspaceDir);
+  logger.debug("gitMeta", gitMeta);
+
+  const branch =
+    options.env === "preview" ? getBranch({ specified: options.branch, gitMeta }) : undefined;
+  if (options.env === "preview" && !branch) {
+    throw new Error(
+      "Didn't auto-detect preview branch, so you need to specify one. Pass --branch <branch>."
+    );
+  }
+
+  if (options.env === "preview" && branch) {
+    //auto-archive a branch if the PR is merged or closed
+    if (gitMeta?.pullRequestState === "merged" || gitMeta?.pullRequestState === "closed") {
+      log.message(`Pull request ${gitMeta?.pullRequestNumber} is ${gitMeta?.pullRequestState}.`);
+      const $buildSpinner = spinner();
+      $buildSpinner.start(`Archiving preview branch: "${branch}"`);
+      const result = await archivePreviewBranch(authorization, branch, resolvedConfig.project);
+      $buildSpinner.stop(
+        result ? `Successfully archived "${branch}"` : `Failed to archive "${branch}".`
+      );
+      return;
+    }
+
+    logger.debug("Upserting branch", { env: options.env, branch });
+    const branchEnv = await upsertBranch({
+      accessToken: authorization.auth.accessToken,
+      apiUrl: authorization.auth.apiUrl,
+      projectRef: resolvedConfig.project,
+      branch,
+      gitMeta,
+    });
+
+    logger.debug("Upserted branch env", branchEnv);
+
+    log.success(`Using preview branch "${branch}"`);
+
+    if (!branchEnv) {
+      throw new Error(`Failed to create branch "${branch}"`);
+    }
+  }
+
   const projectClient = await getProjectClient({
     accessToken: authorization.auth.accessToken,
     apiUrl: authorization.auth.apiUrl,
     projectRef: resolvedConfig.project,
     env: options.env,
+    branch,
     profile: options.profile,
   });
 
@@ -249,6 +285,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
     buildWorker({
       target: "deploy",
       environment: options.env,
+      branch,
       destination: destination.path,
       resolvedConfig,
       rewritePaths: true,
@@ -285,6 +322,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
     selfHosted: options.selfHosted,
     registryHost: options.registry,
     namespace: options.namespace,
+    gitMeta,
     type: features.run_engine_v2 ? "MANAGED" : "V1",
   });
 
@@ -396,6 +434,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
     projectRef: resolvedConfig.project,
     apiUrl: projectClient.client.apiURL,
     apiKey: projectClient.client.accessToken!,
+    branchName: branch,
     authAccessToken: authorization.auth.accessToken,
     compilationPath: destination.path,
     buildEnvVars: buildManifest.build.env,
@@ -683,3 +722,21 @@ async function failDeploy(
     }
   }
 }
+
+export function verifyDirectory(dir: string, projectPath: string) {
+  if (dir !== "." && !isDirectory(projectPath)) {
+    if (dir === "staging" || dir === "prod" || dir === "preview") {
+      throw new Error(`To deploy to ${dir}, you need to pass "--env ${dir}", not just "${dir}".`);
+    }
+
+    if (dir === "production") {
+      throw new Error(`To deploy to production, you need to pass "--env prod", not "production".`);
+    }
+
+    if (dir === "stg") {
+      throw new Error(`To deploy to staging, you need to pass "--env staging", not "stg".`);
+    }
+
+    throw new Error(`Directory "${dir}" not found at ${projectPath}`);
+  }
+}
diff --git a/packages/cli-v3/src/commands/preview.ts b/packages/cli-v3/src/commands/preview.ts
new file mode 100644
index 0000000000..2dab147a51
--- /dev/null
+++ b/packages/cli-v3/src/commands/preview.ts
@@ -0,0 +1,146 @@
+import { intro } from "@clack/prompts";
+import { getBranch } from "@trigger.dev/core/v3";
+import { Command } from "commander";
+import { resolve } from "node:path";
+import { z } from "zod";
+import {
+  CommonCommandOptions,
+  commonOptions,
+  handleTelemetry,
+  wrapCommandAction,
+} from "../cli/common.js";
+import { loadConfig } from "../config.js";
+import { createGitMeta } from "../utilities/gitMeta.js";
+import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
+import { logger } from "../utilities/logger.js";
+import { getProjectClient, LoginResultOk } from "../utilities/session.js";
+import { spinner } from "../utilities/windows.js";
+import { verifyDirectory } from "./deploy.js";
+import { login } from "./login.js";
+import { updateTriggerPackages } from "./update.js";
+import { CliApiClient } from "../apiClient.js";
+
+const PreviewCommandOptions = CommonCommandOptions.extend({
+  branch: z.string().optional(),
+  config: z.string().optional(),
+  projectRef: z.string().optional(),
+  skipUpdateCheck: z.boolean().default(false),
+});
+
+type PreviewCommandOptions = z.infer<typeof PreviewCommandOptions>;
+
+export function configurePreviewCommand(program: Command) {
+  const preview = program.command("preview").description("Modify preview branches");
+
+  commonOptions(
+    preview
+      .command("archive")
+      .description("Archive a preview branch")
+      .argument("[path]", "The path to the project", ".")
+      .option(
+        "-b, --branch <branch>",
+        "The preview branch to archive. If not provided, we'll detect your local git branch."
+      )
+      .option("--skip-update-check", "Skip checking for @trigger.dev package updates")
+      .option("-c, --config <config file>", "The name of the config file, found at [path]")
+      .option(
+        "-p, --project-ref <project ref>",
+        "The project ref. Required if there is no config file. This will override the project specified in the config file."
+      )
+      .option(
+        "--env-file <env file>",
+        "Path to the .env file to load into the CLI process. Defaults to .env in the project directory."
+      )
+  ).action(async (path, options) => {
+    await handleTelemetry(async () => {
+      await printStandloneInitialBanner(true);
+      await previewArchiveCommand(path, options);
+    });
+  });
+}
+
+export async function previewArchiveCommand(dir: string, options: unknown) {
+  return await wrapCommandAction(
+    "previewArchiveCommand",
+    PreviewCommandOptions,
+    options,
+    async (opts) => {
+      return await _previewArchiveCommand(dir, opts);
+    }
+  );
+}
+
+async function _previewArchiveCommand(dir: string, options: PreviewCommandOptions) {
+  intro(`Archiving preview branch`);
+
+  if (!options.skipUpdateCheck) {
+    await updateTriggerPackages(dir, { ...options }, true, true);
+  }
+
+  const cwd = process.cwd();
+  const projectPath = resolve(cwd, dir);
+
+  verifyDirectory(dir, projectPath);
+
+  const authorization = await login({
+    embedded: true,
+    defaultApiUrl: options.apiUrl,
+    profile: options.profile,
+  });
+
+  if (!authorization.ok) {
+    if (authorization.error === "fetch failed") {
+      throw new Error(
+        `Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?`
+      );
+    } else {
+      throw new Error(
+        `You must login first. Use the \`login\` CLI command.\n\n${authorization.error}`
+      );
+    }
+  }
+
+  const resolvedConfig = await loadConfig({
+    cwd: projectPath,
+    overrides: { project: options.projectRef },
+    configFile: options.config,
+  });
+
+  logger.debug("Resolved config", resolvedConfig);
+
+  const gitMeta = await createGitMeta(resolvedConfig.workspaceDir);
+  logger.debug("gitMeta", gitMeta);
+
+  const branch = getBranch({ specified: options.branch, gitMeta });
+
+  if (!branch) {
+    throw new Error(
+      "Didn't auto-detect branch, so you need to specify a preview branch. Use --branch <branch>."
+    );
+  }
+
+  const $buildSpinner = spinner();
+  $buildSpinner.start(`Archiving "${branch}"`);
+  const result = await archivePreviewBranch(authorization, branch, resolvedConfig.project);
+  $buildSpinner.stop(
+    result ? `Successfully archived "${branch}"` : `Failed to archive "${branch}".`
+  );
+  return result;
+}
+
+export async function archivePreviewBranch(
+  authorization: LoginResultOk,
+  branch: string,
+  project: string
+) {
+  const apiClient = new CliApiClient(authorization.auth.apiUrl, authorization.auth.accessToken);
+
+  const result = await apiClient.archiveBranch(project, branch);
+
+  if (result.success) {
+    return true;
+  } else {
+    logger.error(result.error);
+    return false;
+  }
+}
diff --git a/packages/cli-v3/src/commands/promote.ts b/packages/cli-v3/src/commands/promote.ts
index bee97bac11..59452c9ec2 100644
--- a/packages/cli-v3/src/commands/promote.ts
+++ b/packages/cli-v3/src/commands/promote.ts
@@ -12,13 +12,16 @@ import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
 import { logger } from "../utilities/logger.js";
 import { getProjectClient } from "../utilities/session.js";
 import { login } from "./login.js";
+import { createGitMeta } from "../utilities/gitMeta.js";
+import { getBranch } from "@trigger.dev/core/v3";
 
 const PromoteCommandOptions = CommonCommandOptions.extend({
   projectRef: z.string().optional(),
   apiUrl: z.string().optional(),
   skipUpdateCheck: z.boolean().default(false),
   config: z.string().optional(),
-  env: z.enum(["prod", "staging"]),
+  env: z.enum(["prod", "staging", "preview"]),
+  branch: z.string().optional(),
 });
 
 type PromoteCommandOptions = z.infer<typeof PromoteCommandOptions>;
@@ -35,6 +38,10 @@ export function configurePromoteCommand(program: Command) {
         "Deploy to a specific environment (currently only prod and staging are supported)",
         "prod"
       )
+      .option(
+        "-b, --branch <branch>",
+        "The preview branch to promote when passing --env preview. If not provided, we'll detect your git branch."
+      )
       .option("--skip-update-check", "Skip checking for @trigger.dev package updates")
       .option(
         "-p, --project-ref <project ref>",
@@ -88,11 +95,23 @@ async function _promoteCommand(version: string, options: PromoteCommandOptions)
 
   logger.debug("Resolved config", resolvedConfig);
 
+  const gitMeta = await createGitMeta(resolvedConfig.workspaceDir);
+  logger.debug("gitMeta", gitMeta);
+
+  const branch =
+    options.env === "preview" ? getBranch({ specified: options.branch, gitMeta }) : undefined;
+  if (options.env === "preview" && !branch) {
+    throw new Error(
+      "Didn't auto-detect preview branch, so you need to specify one. Pass --branch <branch>."
+    );
+  }
+
   const projectClient = await getProjectClient({
     accessToken: authorization.auth.accessToken,
     apiUrl: authorization.auth.apiUrl,
     projectRef: resolvedConfig.project,
     env: options.env,
+    branch,
     profile: options.profile,
   });
 
diff --git a/packages/cli-v3/src/commands/workers/build.ts b/packages/cli-v3/src/commands/workers/build.ts
index 1a85a11269..9daca0ff3c 100644
--- a/packages/cli-v3/src/commands/workers/build.ts
+++ b/packages/cli-v3/src/commands/workers/build.ts
@@ -1,5 +1,5 @@
 import { intro, outro, log } from "@clack/prompts";
-import { parseDockerImageReference, prepareDeploymentError } from "@trigger.dev/core/v3";
+import { getBranch, parseDockerImageReference, prepareDeploymentError } from "@trigger.dev/core/v3";
 import { InitializeDeploymentResponseBody } from "@trigger.dev/core/v3/schemas";
 import { Command, Option as CommandOption } from "commander";
 import { resolve } from "node:path";
@@ -32,6 +32,7 @@ import { spinner } from "../../utilities/windows.js";
 import { login } from "../login.js";
 import { updateTriggerPackages } from "../update.js";
 import { resolveAlwaysExternal } from "../../build/externals.js";
+import { createGitMeta } from "../../utilities/gitMeta.js";
 
 const WorkersBuildCommandOptions = CommonCommandOptions.extend({
   // docker build options
@@ -45,7 +46,8 @@ const WorkersBuildCommandOptions = CommonCommandOptions.extend({
   local: z.boolean().default(false), // TODO: default to true when webapp has no remote build support
   dryRun: z.boolean().default(false),
   skipSyncEnvVars: z.boolean().default(false),
-  env: z.enum(["prod", "staging"]),
+  env: z.enum(["prod", "staging", "preview"]),
+  branch: z.string().optional(),
   config: z.string().optional(),
   projectRef: z.string().optional(),
   apiUrl: z.string().optional(),
@@ -69,6 +71,10 @@ export function configureWorkersBuildCommand(program: Command) {
         "Deploy to a specific environment (currently only prod and staging are supported)",
         "prod"
       )
+      .option(
+        "-b, --branch <branch>",
+        "The branch to deploy to. If not provided, the branch will be detected from the current git branch."
+      )
       .option("--skip-update-check", "Skip checking for @trigger.dev package updates")
       .option("-c, --config <config file>", "The name of the config file, found at [path]")
       .option(
@@ -168,11 +174,23 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti
 
   logger.debug("Resolved config", resolvedConfig);
 
+  const gitMeta = await createGitMeta(resolvedConfig.workspaceDir);
+  logger.debug("gitMeta", gitMeta);
+
+  const branch =
+    options.env === "preview" ? getBranch({ specified: options.branch, gitMeta }) : undefined;
+  if (options.env === "preview" && !branch) {
+    throw new Error(
+      "You need to specify a preview branch when deploying to preview, pass --branch <branch>."
+    );
+  }
+
   const projectClient = await getProjectClient({
     accessToken: authorization.auth.accessToken,
     apiUrl: authorization.auth.apiUrl,
     projectRef: resolvedConfig.project,
     env: options.env,
+    branch,
     profile: options.profile,
   });
 
@@ -192,6 +210,7 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti
   const buildManifest = await buildWorker({
     target: "unmanaged",
     environment: options.env,
+    branch,
     destination: destination.path,
     resolvedConfig,
     rewritePaths: true,
@@ -327,6 +346,7 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti
     projectRef: resolvedConfig.project,
     apiUrl: projectClient.client.apiURL,
     apiKey: projectClient.client.accessToken!,
+    branchName: branch,
     authAccessToken: authorization.auth.accessToken,
     compilationPath: destination.path,
     buildEnvVars: buildManifest.build.env,
diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts
index 6721317e40..1d4da183fe 100644
--- a/packages/cli-v3/src/deploy/buildImage.ts
+++ b/packages/cli-v3/src/deploy/buildImage.ts
@@ -35,6 +35,7 @@ export interface BuildImageOptions {
   extraCACerts?: string;
   apiUrl: string;
   apiKey: string;
+  branchName?: string;
   buildEnvVars?: Record<string, string | undefined>;
   onLog?: (log: string) => void;
 
@@ -65,6 +66,7 @@ export async function buildImage(options: BuildImageOptions) {
     extraCACerts,
     apiUrl,
     apiKey,
+    branchName,
     buildEnvVars,
     network,
     onLog,
@@ -87,6 +89,7 @@ export async function buildImage(options: BuildImageOptions) {
       extraCACerts,
       apiUrl,
       apiKey,
+      branchName,
       buildEnvVars,
       network,
       onLog,
@@ -124,6 +127,7 @@ export async function buildImage(options: BuildImageOptions) {
     extraCACerts,
     apiUrl,
     apiKey,
+    branchName,
     buildEnvVars,
     onLog,
   });
@@ -145,6 +149,7 @@ export interface DepotBuildImageOptions {
   buildPlatform: string;
   apiUrl: string;
   apiKey: string;
+  branchName?: string;
   loadImage?: boolean;
   noCache?: boolean;
   extraCACerts?: string;
@@ -202,6 +207,8 @@ async function depotBuildImage(options: DepotBuildImageOptions): Promise<BuildIm
     "--build-arg",
     `TRIGGER_API_URL=${options.apiUrl}`,
     "--build-arg",
+    `TRIGGER_PREVIEW_BRANCH=${options.branchName ?? ""}`,
+    "--build-arg",
     `TRIGGER_SECRET_KEY=${options.apiKey}`,
     ...(buildArgs || []),
     ...(options.extraCACerts ? ["--build-arg", `NODE_EXTRA_CA_CERTS=${options.extraCACerts}`] : []),
@@ -292,6 +299,7 @@ interface SelfHostedBuildImageOptions {
   selfHostedRegistry: boolean;
   apiUrl: string;
   apiKey: string;
+  branchName?: string;
   noCache?: boolean;
   extraCACerts?: string;
   buildEnvVars?: Record<string, string | undefined>;
@@ -329,6 +337,8 @@ async function selfHostedBuildImage(
     "--build-arg",
     `TRIGGER_API_URL=${normalizeApiUrlForBuild(options.apiUrl)}`,
     "--build-arg",
+    `TRIGGER_PREVIEW_BRANCH=${options.branchName ?? ""}`,
+    "--build-arg",
     `TRIGGER_SECRET_KEY=${options.apiKey}`,
     ...(buildArgs || []),
     ...(options.extraCACerts ? ["--build-arg", `NODE_EXTRA_CA_CERTS=${options.extraCACerts}`] : []),
@@ -572,6 +582,7 @@ ARG TRIGGER_PROJECT_REF
 ARG NODE_EXTRA_CA_CERTS
 ARG TRIGGER_SECRET_KEY
 ARG TRIGGER_API_URL
+ARG TRIGGER_PREVIEW_BRANCH
 
 ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \
     TRIGGER_DEPLOYMENT_ID=\${TRIGGER_DEPLOYMENT_ID} \
@@ -580,6 +591,7 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \
     TRIGGER_CONTENT_HASH=\${TRIGGER_CONTENT_HASH} \
     TRIGGER_SECRET_KEY=\${TRIGGER_SECRET_KEY} \
     TRIGGER_API_URL=\${TRIGGER_API_URL} \
+    TRIGGER_PREVIEW_BRANCH=\${TRIGGER_PREVIEW_BRANCH} \
     NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \
     NODE_ENV=production
 
@@ -676,6 +688,7 @@ ARG TRIGGER_PROJECT_REF
 ARG NODE_EXTRA_CA_CERTS
 ARG TRIGGER_SECRET_KEY
 ARG TRIGGER_API_URL
+ARG TRIGGER_PREVIEW_BRANCH
 
 ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \
     TRIGGER_DEPLOYMENT_ID=\${TRIGGER_DEPLOYMENT_ID} \
@@ -684,6 +697,7 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \
     TRIGGER_CONTENT_HASH=\${TRIGGER_CONTENT_HASH} \
     TRIGGER_SECRET_KEY=\${TRIGGER_SECRET_KEY} \
     TRIGGER_API_URL=\${TRIGGER_API_URL} \
+    TRIGGER_PREVIEW_BRANCH=\${TRIGGER_PREVIEW_BRANCH} \
     TRIGGER_LOG_LEVEL=debug \
     NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \
     NODE_ENV=production \
diff --git a/packages/cli-v3/src/entryPoints/managed-index-controller.ts b/packages/cli-v3/src/entryPoints/managed-index-controller.ts
index d7c2f048a9..26b3332bb1 100644
--- a/packages/cli-v3/src/entryPoints/managed-index-controller.ts
+++ b/packages/cli-v3/src/entryPoints/managed-index-controller.ts
@@ -26,7 +26,11 @@ async function bootstrap() {
     process.exit(1);
   }
 
-  const cliApiClient = new CliApiClient(env.TRIGGER_API_URL, env.TRIGGER_SECRET_KEY);
+  const cliApiClient = new CliApiClient(
+    env.TRIGGER_API_URL,
+    env.TRIGGER_SECRET_KEY,
+    env.TRIGGER_PREVIEW_BRANCH
+  );
 
   if (!env.TRIGGER_PROJECT_REF) {
     console.error("TRIGGER_PROJECT_REF is not set");
diff --git a/packages/cli-v3/src/utilities/gitMeta.ts b/packages/cli-v3/src/utilities/gitMeta.ts
new file mode 100644
index 0000000000..91e59a8781
--- /dev/null
+++ b/packages/cli-v3/src/utilities/gitMeta.ts
@@ -0,0 +1,263 @@
+import fs from "fs/promises";
+import { join } from "path";
+import ini from "ini";
+import git from "git-last-commit";
+import { x } from "tinyexec";
+import { GitMeta } from "@trigger.dev/core/v3";
+
+export async function createGitMeta(directory: string): Promise<GitMeta | undefined> {
+  // First try to get metadata from GitHub Actions environment
+  const githubMeta = await getGitHubActionsMeta();
+  if (githubMeta) {
+    return githubMeta;
+  }
+
+  // Fall back to git commands for local development
+  const remoteUrl = await getOriginUrl(join(directory, ".git/config"));
+
+  const [commitResult, dirtyResult] = await Promise.allSettled([
+    getLastCommit(directory),
+    isDirty(directory),
+  ]);
+
+  if (commitResult.status === "rejected") {
+    return;
+  }
+
+  if (dirtyResult.status === "rejected") {
+    return;
+  }
+
+  const dirty = dirtyResult.value;
+  const commit = commitResult.value;
+
+  // Get the pull request number from process.env (GitHub Actions)
+  const pullRequestNumber: number | undefined = process.env.GITHUB_PULL_REQUEST_NUMBER
+    ? parseInt(process.env.GITHUB_PULL_REQUEST_NUMBER)
+    : undefined;
+
+  return {
+    remoteUrl: remoteUrl ?? undefined,
+    commitAuthorName: commit.author.name,
+    commitMessage: commit.subject,
+    commitRef: commit.branch,
+    commitSha: commit.hash,
+    dirty,
+    pullRequestNumber,
+  };
+}
+
+function getLastCommit(directory: string): Promise<git.Commit> {
+  return new Promise((resolve, reject) => {
+    git.getLastCommit(
+      (err, commit) => {
+        if (err) {
+          return reject(err);
+        }
+
+        resolve(commit);
+      },
+      { dst: directory }
+    );
+  });
+}
+
+async function isDirty(directory: string): Promise<boolean> {
+  try {
+    const result = await x("git", ["--no-optional-locks", "status", "-s"], {
+      nodeOptions: {
+        cwd: directory,
+      },
+    });
+
+    // Example output (when dirty):
+    //    M ../fs-detectors/src/index.ts
+    return result.stdout.trim().length > 0;
+  } catch (error) {
+    throw error;
+  }
+}
+
+async function parseGitConfig(configPath: string) {
+  try {
+    return ini.parse(await fs.readFile(configPath, "utf8"));
+  } catch (err: unknown) {
+    return;
+  }
+}
+
+function pluckRemoteUrls(gitConfig: { [key: string]: any }): { [key: string]: string } | undefined {
+  const remoteUrls: { [key: string]: string } = {};
+
+  for (const key of Object.keys(gitConfig)) {
+    if (key.includes("remote")) {
+      // ex. remote "origin" — matches origin
+      const remoteName = key.match(/(?<=").*(?=")/g)?.[0];
+      const remoteUrl = gitConfig[key]?.url;
+      if (remoteName && remoteUrl) {
+        remoteUrls[remoteName] = remoteUrl;
+      }
+    }
+  }
+
+  if (Object.keys(remoteUrls).length === 0) {
+    return;
+  }
+
+  return remoteUrls;
+}
+
+function pluckOriginUrl(gitConfig: { [key: string]: any }): string | undefined {
+  // Assuming "origin" is the remote url that the user would want to use
+  return gitConfig['remote "origin"']?.url;
+}
+
+async function getOriginUrl(configPath: string): Promise<string | null> {
+  const gitConfig = await parseGitConfig(configPath);
+  if (!gitConfig) {
+    return null;
+  }
+
+  const originUrl = pluckOriginUrl(gitConfig);
+  if (originUrl) {
+    return originUrl;
+  }
+  return null;
+}
+
+function errorToString(err: unknown): string {
+  return err instanceof Error ? err.message : String(err);
+}
+
+function isGitHubActions(): boolean {
+  return process.env.GITHUB_ACTIONS === "true";
+}
+
+async function getGitHubActionsMeta(): Promise<GitMeta | undefined> {
+  if (!isGitHubActions()) {
+    return undefined;
+  }
+
+  // Required fields that should always be present in GitHub Actions
+  if (!process.env.GITHUB_SHA || !process.env.GITHUB_REF) {
+    return undefined;
+  }
+
+  const remoteUrl =
+    process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY
+      ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}.git`
+      : undefined;
+
+  let commitRef = process.env.GITHUB_REF;
+  let commitMessage: string | undefined;
+  let pullRequestNumber: number | undefined;
+  let pullRequestTitle: string | undefined;
+  let pullRequestState: "open" | "closed" | "merged" | undefined;
+  let commitSha = process.env.GITHUB_SHA;
+
+  if (process.env.GITHUB_EVENT_PATH) {
+    try {
+      const eventData = JSON.parse(await fs.readFile(process.env.GITHUB_EVENT_PATH, "utf8"));
+
+      if (process.env.GITHUB_EVENT_NAME === "push") {
+        commitMessage = eventData.head_commit?.message;
+        // For push events, GITHUB_REF will be like "refs/heads/main"
+        commitRef = process.env.GITHUB_REF.replace(/^refs\/(heads|tags)\//, "");
+      } else if (process.env.GITHUB_EVENT_NAME === "pull_request") {
+        // For PRs, use the head commit info
+        pullRequestTitle = eventData.pull_request?.title;
+        commitRef = eventData.pull_request?.head?.ref;
+        pullRequestNumber = eventData.pull_request?.number;
+        commitSha = eventData.pull_request?.head?.sha;
+        pullRequestState = eventData.pull_request?.state as "open" | "closed";
+
+        // Check if PR was merged
+        if (pullRequestState === "closed" && eventData.pull_request?.merged === true) {
+          pullRequestState = "merged";
+        }
+
+        await x("git", ["status"], {
+          nodeOptions: {
+            cwd: process.cwd(),
+          },
+        }).then((result) => console.debug(result.stdout));
+
+        commitMessage = await getCommitMessage(process.cwd(), commitSha, pullRequestNumber);
+      }
+    } catch (error) {
+      console.debug("Failed to parse GitHub event payload:", errorToString(error));
+    }
+  } else {
+    console.debug("No GITHUB_EVENT_PATH found");
+    // If we can't read the event payload, at least try to clean up the ref
+    commitRef = process.env.GITHUB_REF.replace(/^refs\/(heads|tags)\//, "");
+  }
+
+  return {
+    remoteUrl,
+    commitSha,
+    commitRef,
+    // In CI, the workspace is always clean
+    dirty: false,
+    // These fields might not be available in all GitHub Actions contexts
+    commitAuthorName: process.env.GITHUB_ACTOR,
+    commitMessage,
+    pullRequestNumber:
+      pullRequestNumber ??
+      (process.env.GITHUB_PULL_REQUEST_NUMBER
+        ? parseInt(process.env.GITHUB_PULL_REQUEST_NUMBER)
+        : undefined),
+    pullRequestTitle,
+    pullRequestState,
+  };
+}
+
+async function getCommitMessage(
+  directory: string,
+  sha: string,
+  prNumber?: number
+): Promise<string | undefined> {
+  try {
+    // First try to fetch the specific commit
+    await x("git", ["fetch", "origin", sha], {
+      nodeOptions: {
+        cwd: directory,
+      },
+    });
+
+    // Try to get the commit message
+    const result = await x("git", ["log", "-1", "--format=%B", sha], {
+      nodeOptions: {
+        cwd: directory,
+      },
+    });
+
+    const message = result.stdout.trim();
+
+    if (!message && prNumber) {
+      // If that didn't work, try fetching the PR branch
+      const branchResult = await x(
+        "git",
+        ["fetch", "origin", `pull/${prNumber}/head:pr-${prNumber}`],
+        {
+          nodeOptions: {
+            cwd: directory,
+          },
+        }
+      );
+
+      // Try again with the fetched branch
+      const branchCommitResult = await x("git", ["log", "-1", "--format=%B", `pr-${prNumber}`], {
+        nodeOptions: {
+          cwd: directory,
+        },
+      });
+      return branchCommitResult.stdout.trim();
+    }
+
+    return message;
+  } catch (error) {
+    console.debug("Error getting commit message:", errorToString(error));
+    return undefined;
+  }
+}
diff --git a/packages/cli-v3/src/utilities/session.ts b/packages/cli-v3/src/utilities/session.ts
index 1853a3847d..be9d9ebfd4 100644
--- a/packages/cli-v3/src/utilities/session.ts
+++ b/packages/cli-v3/src/utilities/session.ts
@@ -3,6 +3,7 @@ import { CliApiClient } from "../apiClient.js";
 import { readAuthConfigProfile } from "./configFiles.js";
 import { getTracer } from "../telemetry/tracing.js";
 import { logger } from "./logger.js";
+import { GitMeta } from "@trigger.dev/core/v3";
 
 const tracer = getTracer();
 
@@ -94,6 +95,7 @@ export type GetEnvOptions = {
   apiUrl: string;
   projectRef: string;
   env: string;
+  branch?: string;
   profile: string;
 };
 
@@ -124,7 +126,7 @@ export async function getProjectClient(options: GetEnvOptions) {
     return;
   }
 
-  const client = new CliApiClient(projectEnv.data.apiUrl, projectEnv.data.apiKey);
+  const client = new CliApiClient(projectEnv.data.apiUrl, projectEnv.data.apiKey, options.branch);
 
   return {
     id: projectEnv.data.projectId,
@@ -132,3 +134,28 @@ export async function getProjectClient(options: GetEnvOptions) {
     client,
   };
 }
+
+export type UpsertBranchOptions = {
+  accessToken: string;
+  apiUrl: string;
+  projectRef: string;
+  branch: string;
+  gitMeta: GitMeta | undefined;
+};
+
+export async function upsertBranch(options: UpsertBranchOptions) {
+  const apiClient = new CliApiClient(options.apiUrl, options.accessToken);
+
+  const branchEnv = await apiClient.upsertBranch(options.projectRef, {
+    env: "preview",
+    branch: options.branch,
+    git: options.gitMeta,
+  });
+
+  if (!branchEnv.success) {
+    logger.error(`Failed to upsert branch: ${branchEnv.error}`);
+    return;
+  }
+
+  return branchEnv.data;
+}
diff --git a/packages/core/src/v3/apiClient/getBranch.ts b/packages/core/src/v3/apiClient/getBranch.ts
new file mode 100644
index 0000000000..cf1f1f2d44
--- /dev/null
+++ b/packages/core/src/v3/apiClient/getBranch.ts
@@ -0,0 +1,33 @@
+import { GitMeta } from "../schemas/index.js";
+import { getEnvVar } from "../utils/getEnv.js";
+
+export function getBranch({
+  specified,
+  gitMeta,
+}: {
+  specified?: string;
+  gitMeta?: GitMeta;
+}): string | undefined {
+  if (specified) {
+    return specified;
+  }
+
+  // not specified, so detect our variable from process.env
+  const envVar = getEnvVar("TRIGGER_PREVIEW_BRANCH");
+  if (envVar) {
+    return envVar;
+  }
+
+  // detect the Vercel preview branch
+  const vercelPreviewBranch = getEnvVar("VERCEL_GIT_COMMIT_REF");
+  if (vercelPreviewBranch) {
+    return vercelPreviewBranch;
+  }
+
+  // not specified, so detect from git metadata
+  if (gitMeta?.commitRef) {
+    return gitMeta.commitRef;
+  }
+
+  return undefined;
+}
diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts
index 0bac97c8e5..117792466c 100644
--- a/packages/core/src/v3/apiClient/index.ts
+++ b/packages/core/src/v3/apiClient/index.ts
@@ -128,17 +128,26 @@ export type {
   TaskRunShape,
 };
 
+export * from "./getBranch.js";
+
 /**
  * Trigger.dev v3 API client
  */
 export class ApiClient {
   public readonly baseUrl: string;
   public readonly accessToken: string;
+  public readonly previewBranch?: string;
   private readonly defaultRequestOptions: ZodFetchOptions;
 
-  constructor(baseUrl: string, accessToken: string, requestOptions: ApiRequestOptions = {}) {
+  constructor(
+    baseUrl: string,
+    accessToken: string,
+    previewBranch?: string,
+    requestOptions: ApiRequestOptions = {}
+  ) {
     this.accessToken = accessToken;
     this.baseUrl = baseUrl.replace(/\/$/, "");
+    this.previewBranch = previewBranch;
     this.defaultRequestOptions = mergeRequestOptions(DEFAULT_ZOD_FETCH_OPTIONS, requestOptions);
   }
 
@@ -984,6 +993,10 @@ export class ApiClient {
       ),
     };
 
+    if (this.previewBranch) {
+      headers["x-trigger-branch"] = this.previewBranch;
+    }
+
     // Only inject the context if we are inside a task
     if (taskContext.isInsideTask) {
       headers["x-trigger-worker"] = "true";
diff --git a/packages/core/src/v3/apiClientManager/index.ts b/packages/core/src/v3/apiClientManager/index.ts
index e2f47c9261..daaf354e0b 100644
--- a/packages/core/src/v3/apiClientManager/index.ts
+++ b/packages/core/src/v3/apiClientManager/index.ts
@@ -44,12 +44,18 @@ export class APIClientManagerAPI {
     );
   }
 
+  get branchName(): string | undefined {
+    const config = this.#getConfig();
+    const value = config?.previewBranch ?? getEnvVar("TRIGGER_PREVIEW_BRANCH") ?? undefined;
+    return value ? value : undefined;
+  }
+
   get client(): ApiClient | undefined {
     if (!this.baseURL || !this.accessToken) {
       return undefined;
     }
 
-    return new ApiClient(this.baseURL, this.accessToken);
+    return new ApiClient(this.baseURL, this.accessToken, this.branchName);
   }
 
   clientOrThrow(): ApiClient {
@@ -57,7 +63,7 @@ export class APIClientManagerAPI {
       throw new ApiClientMissingError(this.apiClientMissingError());
     }
 
-    return new ApiClient(this.baseURL, this.accessToken);
+    return new ApiClient(this.baseURL, this.accessToken, this.branchName);
   }
 
   runWithConfig<R extends (...args: any[]) => Promise<any>>(
diff --git a/packages/core/src/v3/apiClientManager/types.ts b/packages/core/src/v3/apiClientManager/types.ts
index b0e3da624c..2905af6d8e 100644
--- a/packages/core/src/v3/apiClientManager/types.ts
+++ b/packages/core/src/v3/apiClientManager/types.ts
@@ -10,5 +10,9 @@ export type ApiClientConfiguration = {
    * The access token to authenticate with the Trigger API.
    */
   accessToken?: string;
+  /**
+   * The preview branch name (for preview environments)
+   */
+  previewBranch?: string;
   requestOptions?: ApiRequestOptions;
 };
diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts
index bc73ce534b..2fea3f9b08 100644
--- a/packages/core/src/v3/schemas/api.ts
+++ b/packages/core/src/v3/schemas/api.ts
@@ -1,6 +1,12 @@
 import { z } from "zod";
 import { DeserializedJsonSchema } from "../../schemas/json.js";
-import { FlushedRunMetadata, MachinePresetName, SerializedError, TaskRunError } from "./common.js";
+import {
+  FlushedRunMetadata,
+  GitMeta,
+  MachinePresetName,
+  SerializedError,
+  TaskRunError,
+} from "./common.js";
 import { BackgroundWorkerMetadata } from "./resources.js";
 import { DequeuedMessage, MachineResources } from "./runEngine.js";
 
@@ -302,6 +308,20 @@ export const ExternalBuildData = z.object({
 
 export type ExternalBuildData = z.infer<typeof ExternalBuildData>;
 
+export const UpsertBranchRequestBody = z.object({
+  git: GitMeta.optional(),
+  env: z.enum(["preview"]),
+  branch: z.string(),
+});
+
+export type UpsertBranchRequestBody = z.infer<typeof UpsertBranchRequestBody>;
+
+export const UpsertBranchResponseBody = z.object({
+  id: z.string(),
+});
+
+export type UpsertBranchResponseBody = z.infer<typeof UpsertBranchResponseBody>;
+
 export const InitializeDeploymentResponseBody = z.object({
   id: z.string(),
   contentHash: z.string(),
@@ -320,6 +340,7 @@ export const InitializeDeploymentRequestBody = z.object({
   registryHost: z.string().optional(),
   selfHosted: z.boolean().optional(),
   namespace: z.string().optional(),
+  gitMeta: GitMeta.optional(),
   type: z.enum(["MANAGED", "UNMANAGED", "V1"]).optional(),
 });
 
diff --git a/packages/core/src/v3/schemas/build.ts b/packages/core/src/v3/schemas/build.ts
index c3df04eaa7..5c0c276f42 100644
--- a/packages/core/src/v3/schemas/build.ts
+++ b/packages/core/src/v3/schemas/build.ts
@@ -24,6 +24,7 @@ export const BuildManifest = z.object({
   contentHash: z.string(),
   runtime: BuildRuntime,
   environment: z.string(),
+  branch: z.string().optional(),
   config: ConfigManifest,
   files: z.array(TaskFile),
   sources: z.record(
diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts
index 259b902687..99e5bc5f0c 100644
--- a/packages/core/src/v3/schemas/common.ts
+++ b/packages/core/src/v3/schemas/common.ts
@@ -263,12 +263,28 @@ export const TaskRunExecutionAttempt = z.object({
   status: z.string(),
 });
 
+export const GitMeta = z.object({
+  commitAuthorName: z.string().optional(),
+  commitMessage: z.string().optional(),
+  commitRef: z.string().optional(),
+  commitSha: z.string().optional(),
+  dirty: z.boolean().optional(),
+  remoteUrl: z.string().optional(),
+  pullRequestNumber: z.number().optional(),
+  pullRequestTitle: z.string().optional(),
+  pullRequestState: z.enum(["open", "closed", "merged"]).optional(),
+});
+
+export type GitMeta = z.infer<typeof GitMeta>;
+
 export type TaskRunExecutionAttempt = z.infer<typeof TaskRunExecutionAttempt>;
 
 export const TaskRunExecutionEnvironment = z.object({
   id: z.string(),
   slug: z.string(),
   type: z.enum(["PRODUCTION", "STAGING", "DEVELOPMENT", "PREVIEW"]),
+  branchName: z.string().optional(),
+  git: GitMeta.optional(),
 });
 
 export type TaskRunExecutionEnvironment = z.infer<typeof TaskRunExecutionEnvironment>;
diff --git a/packages/react-hooks/src/hooks/useApiClient.ts b/packages/react-hooks/src/hooks/useApiClient.ts
index b2cb9c6082..21f0aa53de 100644
--- a/packages/react-hooks/src/hooks/useApiClient.ts
+++ b/packages/react-hooks/src/hooks/useApiClient.ts
@@ -11,6 +11,8 @@ export type UseApiClientOptions = {
   accessToken?: string;
   /** Optional base URL for the API endpoints */
   baseURL?: string;
+  /** Optional preview branch name for preview environments */
+  previewBranch?: string;
   /** Optional additional request configuration */
   requestOptions?: ApiRequestOptions;
 
@@ -47,7 +49,7 @@ export function useApiClient(options?: UseApiClientOptions): ApiClient | undefin
 
   const baseUrl = options?.baseURL ?? auth?.baseURL ?? "https://api.trigger.dev";
   const accessToken = options?.accessToken ?? auth?.accessToken;
-
+  const previewBranch = options?.previewBranch ?? auth?.previewBranch;
   if (!accessToken) {
     if (options?.enabled === false) {
       return undefined;
@@ -61,5 +63,5 @@ export function useApiClient(options?: UseApiClientOptions): ApiClient | undefin
     ...options?.requestOptions,
   };
 
-  return new ApiClient(baseUrl, accessToken, requestOptions);
+  return new ApiClient(baseUrl, accessToken, previewBranch, requestOptions);
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 57d142b076..6e55c2a198 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -309,6 +309,9 @@ importers:
       '@prisma/instrumentation':
         specifier: ^5.11.0
         version: 5.11.0
+      '@radix-ui/react-accordion':
+        specifier: ^1.2.11
+        version: 1.2.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)
       '@radix-ui/react-alert-dialog':
         specifier: ^1.0.4
         version: 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)
@@ -403,8 +406,8 @@ importers:
         specifier: workspace:*
         version: link:../../internal-packages/otlp-importer
       '@trigger.dev/platform':
-        specifier: 1.0.14
-        version: 1.0.14
+        specifier: 1.0.15
+        version: 1.0.15
       '@trigger.dev/redis-worker':
         specifier: workspace:*
         version: link:../../packages/redis-worker
@@ -1233,6 +1236,9 @@ importers:
       fast-npm-meta:
         specifier: ^0.2.2
         version: 0.2.2
+      git-last-commit:
+        specifier: ^1.0.1
+        version: 1.0.1
       gradient-string:
         specifier: ^2.0.2
         version: 2.0.2
@@ -1245,6 +1251,9 @@ importers:
       import-meta-resolve:
         specifier: ^4.1.0
         version: 4.1.0
+      ini:
+        specifier: ^5.0.0
+        version: 5.0.0
       jsonc-parser:
         specifier: 3.2.1
         version: 3.2.1
@@ -1333,6 +1342,9 @@ importers:
       '@types/gradient-string':
         specifier: ^1.1.2
         version: 1.1.2
+      '@types/ini':
+        specifier: ^4.1.1
+        version: 4.1.1
       '@types/object-hash':
         specifier: 3.0.6
         version: 3.0.6
@@ -1960,6 +1972,9 @@ importers:
 
   references/hello-world:
     dependencies:
+      '@trigger.dev/build':
+        specifier: workspace:*
+        version: link:../../packages/build
       '@trigger.dev/sdk':
         specifier: workspace:*
         version: link:../../packages/trigger-sdk
@@ -9904,6 +9919,38 @@ packages:
     resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
     dev: false
 
+  /@radix-ui/primitive@1.1.2:
+    resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
+    dev: false
+
+  /@radix-ui/react-accordion@1.2.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+    dependencies:
+      '@radix-ui/primitive': 1.1.2
+      '@radix-ui/react-collapsible': 1.1.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-context': 1.1.2(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-direction': 1.1.1(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-id': 1.1.1(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.69)(react@18.2.0)
+      '@types/react': 18.2.69
+      '@types/react-dom': 18.2.7
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /@radix-ui/react-alert-dialog@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-jbfBCRlKYlhbitueOAv7z74PXYeIQmWpKwm3jllsdkw7fGWNkxqP3v0nY9WmOzcPqpQuoorNtvViBgL46n5gVg==}
     peerDependencies:
@@ -10014,6 +10061,33 @@ packages:
       react-dom: 18.2.0(react@18.3.1)
     dev: false
 
+  /@radix-ui/react-collapsible@1.1.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+    dependencies:
+      '@radix-ui/primitive': 1.1.2
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-context': 1.1.2(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-id': 1.1.1(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0)
+      '@types/react': 18.2.69
+      '@types/react-dom': 18.2.7
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /@radix-ui/react-collection@1.0.2(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-s8WdQQ6wNXpaxdZ308KSr8fEWGrg4un8i4r/w7fhiS4ElRNjk5rRcl0/C6TANG2LvLOGIxtzo/jAg6Qf73TEBw==}
     peerDependencies:
@@ -10092,6 +10166,29 @@ packages:
       react-dom: 18.2.0(react@18.3.1)
     dev: false
 
+  /@radix-ui/react-collection@1.1.7(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+    dependencies:
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-context': 1.1.2(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0)
+      '@radix-ui/react-slot': 1.2.3(@types/react@18.2.69)(react@18.2.0)
+      '@types/react': 18.2.69
+      '@types/react-dom': 18.2.7
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /@radix-ui/react-compose-refs@1.0.0(react@18.2.0):
     resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==}
     peerDependencies:
@@ -10204,6 +10301,19 @@ packages:
       react: 19.0.0
     dev: false
 
+  /@radix-ui/react-compose-refs@1.1.2(@types/react@18.2.69)(react@18.2.0):
+    resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.2.69
+      react: 18.2.0
+    dev: false
+
   /@radix-ui/react-context@1.0.0(react@18.2.0):
     resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
     peerDependencies:
@@ -10276,6 +10386,19 @@ packages:
       react: 19.0.0
     dev: false
 
+  /@radix-ui/react-context@1.1.2(@types/react@18.2.69)(react@18.2.0):
+    resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.2.69
+      react: 18.2.0
+    dev: false
+
   /@radix-ui/react-dialog@1.0.3(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-owNhq36kNPqC2/a+zJRioPg6HHnTn5B/sh/NjTY8r4W9g1L5VJlrzZIVcBr7R9Mg8iLjVmh6MGgMlfoVf/WO/A==}
     peerDependencies:
@@ -10412,7 +10535,7 @@ packages:
       '@types/react':
         optional: true
     dependencies:
-      '@babel/runtime': 7.27.0
+      '@babel/runtime': 7.26.7
       '@types/react': 18.3.1
       react: 18.3.1
     dev: false
@@ -10430,6 +10553,19 @@ packages:
       react: 18.3.1
     dev: false
 
+  /@radix-ui/react-direction@1.1.1(@types/react@18.2.69)(react@18.2.0):
+    resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.2.69
+      react: 18.2.0
+    dev: false
+
   /@radix-ui/react-dismissable-layer@1.0.3(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-nXZOvFjOuHS1ovumntGV7NNoLaEp9JEvTht3MBjP44NSW5hUKj/8OnfN3+8WmB+CEhN44XaGhpHoSsUIEl5P7Q==}
     peerDependencies:
@@ -10699,6 +10835,20 @@ packages:
       react: 18.3.1
     dev: false
 
+  /@radix-ui/react-id@1.1.1(@types/react@18.2.69)(react@18.2.0):
+    resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0)
+      '@types/react': 18.2.69
+      react: 18.2.0
+    dev: false
+
   /@radix-ui/react-label@2.0.1(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-qcfbS3B8hTYmEO44RNcXB6pegkxRsJIbdxTMu0PEX0Luv5O2DvTIwwVYxQfUwLpM88EL84QRPLOLgwUSApMsLQ==}
     peerDependencies:
@@ -11023,6 +11173,27 @@ packages:
       react-dom: 18.2.0(react@18.3.1)
     dev: false
 
+  /@radix-ui/react-presence@1.1.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+    dependencies:
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0)
+      '@types/react': 18.2.69
+      '@types/react-dom': 18.2.7
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /@radix-ui/react-primitive@1.0.2(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==}
     peerDependencies:
@@ -11149,6 +11320,26 @@ packages:
       react-dom: 19.0.0(react@19.0.0)
     dev: false
 
+  /@radix-ui/react-primitive@2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0):
+    resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+    dependencies:
+      '@radix-ui/react-slot': 1.2.3(@types/react@18.2.69)(react@18.2.0)
+      '@types/react': 18.2.69
+      '@types/react-dom': 18.2.7
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+    dev: false
+
   /@radix-ui/react-progress@1.1.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0)(react@18.3.1):
     resolution: {integrity: sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==}
     peerDependencies:
@@ -11498,6 +11689,20 @@ packages:
       react: 19.0.0
     dev: false
 
+  /@radix-ui/react-slot@1.2.3(@types/react@18.2.69)(react@18.2.0):
+    resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0)
+      '@types/react': 18.2.69
+      react: 18.2.0
+    dev: false
+
   /@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0):
     resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==}
     peerDependencies:
@@ -11792,6 +11997,35 @@ packages:
       react: 18.3.1
     dev: false
 
+  /@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.2.69)(react@18.2.0):
+    resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.2.69)(react@18.2.0)
+      '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0)
+      '@types/react': 18.2.69
+      react: 18.2.0
+    dev: false
+
+  /@radix-ui/react-use-effect-event@0.0.2(@types/react@18.2.69)(react@18.2.0):
+    resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0)
+      '@types/react': 18.2.69
+      react: 18.2.0
+    dev: false
+
   /@radix-ui/react-use-escape-keydown@1.0.2(react@18.2.0):
     resolution: {integrity: sha512-DXGim3x74WgUv+iMNCF+cAo8xUHHeqvjx8zs7trKf+FkQKPQXLk2sX7Gx1ysH7Q76xCpZuxIJE7HLPxRE+Q+GA==}
     peerDependencies:
@@ -11904,6 +12138,19 @@ packages:
       react: 19.0.0
     dev: false
 
+  /@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.2.69)(react@18.2.0):
+    resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+    dependencies:
+      '@types/react': 18.2.69
+      react: 18.2.0
+    dev: false
+
   /@radix-ui/react-use-previous@1.0.0(react@18.2.0):
     resolution: {integrity: sha512-RG2K8z/K7InnOKpq6YLDmT49HGjNmrK+fr82UCVKT2sW0GYfVnYp4wZWBooT/EYfQ5faA9uIjvsuMMhH61rheg==}
     peerDependencies:
@@ -17143,8 +17390,8 @@ packages:
       react-dom: 18.2.0(react@18.2.0)
     dev: false
 
-  /@trigger.dev/platform@1.0.14:
-    resolution: {integrity: sha512-sYWzsH5oNnSTe4zhm1s0JFtvuRyAjBadScE9REN4f0AkntRG572mJHOolr0HyER4k1gS1mSK0nQScXSG5LCVIA==}
+  /@trigger.dev/platform@1.0.15:
+    resolution: {integrity: sha512-rorRJJl7ecyiO8iQZcHGlXR00bTzm7e1xZt0ddCYJFhaQjxq2bo2oen5DVxUbLZsE2cp60ipQWFrmAipFwK79Q==}
     dependencies:
       zod: 3.23.8
     dev: false
@@ -17402,6 +17649,10 @@ packages:
     resolution: {integrity: sha512-K3e+NZlpCKd6Bd/EIdqjFJRFHbrq5TzPPLwREk5Iv/YoIjQrs6ljdAUCo+Lb2xFlGNOjGSE0dqsVD19cZL137w==}
     dev: true
 
+  /@types/ini@4.1.1:
+    resolution: {integrity: sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==}
+    dev: true
+
   /@types/interpret@1.1.3:
     resolution: {integrity: sha512-uBaBhj/BhilG58r64mtDb/BEdH51HIQLgP5bmWzc5qCtFMja8dCk/IOJmk36j0lbi9QHwI6sbtUNGuqXdKCAtQ==}
     dependencies:
@@ -23767,6 +24018,10 @@ packages:
       tar: 6.2.1
     dev: false
 
+  /git-last-commit@1.0.1:
+    resolution: {integrity: sha512-FDSgeMqa7GnJDxt/q0AbrxbfeTyxp4ImxEw1e4nw6NUHA5FMhFUq33dTXI4Xdgcj1VQ1q5QLWF6WxFrJ8KCBOg==}
+    dev: false
+
   /github-from-package@0.0.0:
     resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
     dev: false
@@ -24471,6 +24726,11 @@ packages:
     engines: {node: '>=10'}
     dev: true
 
+  /ini@5.0.0:
+    resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==}
+    engines: {node: ^18.17.0 || >=20.5.0}
+    dev: false
+
   /inline-style-parser@0.1.1:
     resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==}
     dev: true
diff --git a/references/hello-world/package.json b/references/hello-world/package.json
index e0617645b8..b6a8d799f4 100644
--- a/references/hello-world/package.json
+++ b/references/hello-world/package.json
@@ -6,6 +6,7 @@
     "trigger.dev": "workspace:*"
   },
   "dependencies": {
+    "@trigger.dev/build": "workspace:*",
     "@trigger.dev/sdk": "workspace:*",
     "openai": "^4.97.0",
     "replicate": "^1.0.1",
@@ -15,4 +16,4 @@
     "dev": "trigger dev",
     "deploy": "trigger deploy"
   }
-}
\ No newline at end of file
+}
diff --git a/references/hello-world/src/trigger/envvars.ts b/references/hello-world/src/trigger/envvars.ts
index 3a6bd700d8..efc1dc79cb 100644
--- a/references/hello-world/src/trigger/envvars.ts
+++ b/references/hello-world/src/trigger/envvars.ts
@@ -32,8 +32,8 @@ export const secretEnvVar = task({
 
     //get secret env var
     const secretEnvVar = vars.find((v) => v.isSecret);
-    assert.equal(secretEnvVar?.isSecret, true);
-    assert.equal(secretEnvVar?.value, "<redacted>");
+    assert.equal(secretEnvVar?.isSecret, true, "no secretEnvVar found");
+    assert.equal(secretEnvVar?.value, "<redacted>", "secretEnvVar value should be redacted");
 
     //retrieve the secret env var
     const retrievedSecret = await envvars.retrieve(
diff --git a/references/hello-world/src/trigger/public-access-tokens.ts b/references/hello-world/src/trigger/public-access-tokens.ts
new file mode 100644
index 0000000000..ddf1ac485b
--- /dev/null
+++ b/references/hello-world/src/trigger/public-access-tokens.ts
@@ -0,0 +1,21 @@
+import { auth, batch, logger, runs, task, tasks, timeout, wait } from "@trigger.dev/sdk";
+
+export const publicAccessTokensTask = task({
+  id: "public-access-tokens",
+  run: async (payload: any, { ctx }) => {
+    const token = await auth.createPublicToken({
+      scopes: {
+        read: {
+          runs: [ctx.run.id],
+        },
+      },
+    });
+
+    logger.info("Token", { token });
+
+    await auth.withAuth({ accessToken: token }, async () => {
+      const run = await runs.retrieve(ctx.run.id);
+      logger.info("Run", { run });
+    });
+  },
+});
diff --git a/references/hello-world/trigger.config.ts b/references/hello-world/trigger.config.ts
index 6712952fdf..fa6cfa566b 100644
--- a/references/hello-world/trigger.config.ts
+++ b/references/hello-world/trigger.config.ts
@@ -1,4 +1,5 @@
 import { defineConfig } from "@trigger.dev/sdk/v3";
+import { syncEnvVars } from "@trigger.dev/build/extensions/core";
 
 export default defineConfig({
   compatibilityFlags: ["run_engine_v2"],
@@ -15,9 +16,19 @@ export default defineConfig({
       randomize: true,
     },
   },
-  machine: "large-1x",
+  machine: "small-2x",
   build: {
     extensions: [
+      syncEnvVars(async (ctx) => {
+        console.log(ctx.environment);
+        console.log(ctx.branch);
+        return [
+          { name: "SYNC_ENV", value: ctx.environment },
+          { name: "BRANCH", value: ctx.branch ?? "–" },
+          { name: "SECRET_KEY", value: "secret-value" },
+          { name: "ANOTHER_SECRET", value: "another-secret-value" },
+        ];
+      }),
       {
         name: "npm-token",
         onBuildComplete: async (context, manifest) => {