diff --git a/go.mod b/go.mod
index e213f97d..5f27b3d4 100644
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,7 @@ require (
 	github.com/iancoleman/strcase v0.3.0
 	github.com/martinlindhe/base36 v1.1.1
 	github.com/open-policy-agent/opa v0.70.0
+	github.com/pomerium/csrf v1.7.0
 	github.com/pomerium/pomerium v0.28.1-0.20241213191330-3d53f26d181c
 	github.com/rs/zerolog v1.33.0
 	github.com/sergi/go-diff v1.3.1
@@ -164,7 +165,6 @@ require (
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
-	github.com/pomerium/csrf v1.7.0 // indirect
 	github.com/pomerium/datasource v0.18.2-0.20221108160055-c6134b5ed524 // indirect
 	github.com/pomerium/protoutil v0.0.0-20240813175624-47b7ac43ff46 // indirect
 	github.com/pomerium/webauthn v0.0.0-20240603205124-0428df511172 // indirect
diff --git a/model/ingress_config.go b/model/ingress_config.go
index 6877b47b..2620fdfb 100644
--- a/model/ingress_config.go
+++ b/model/ingress_config.go
@@ -32,6 +32,8 @@ const (
 	UseServiceProxy = "service_proxy_upstream"
 	// TCPUpstream indicates this route is a TCP service https://www.pomerium.com/docs/tcp/
 	TCPUpstream = "tcp_upstream"
+	// UDPUpstream indicates this route is a UDP service https://www.pomerium.com/docs/capabilities/udp/
+	UDPUpstream = "udp_upstream"
 	// SubtleAllowEmptyHost is a required annotation when creating an ingress containing
 	// rules with an empty (catch-all) host, as it can cause unexpected behavior
 	SubtleAllowEmptyHost = "subtle_allow_empty_host"
@@ -121,6 +123,11 @@ func (ic *IngressConfig) IsTCPUpstream() bool {
 	return ic.IsAnnotationSet(TCPUpstream)
 }
 
+// IsUDPUpstream returns true is this route represents a UDP service https://www.pomerium.com/docs/capabilities/tcp/
+func (ic *IngressConfig) IsUDPUpstream() bool {
+	return ic.IsAnnotationSet(UDPUpstream)
+}
+
 // IsPathRegex returns true if paths in the Ingress spec should be treated as regular expressions
 func (ic *IngressConfig) IsPathRegex() bool {
 	return ic.IsAnnotationSet(PathRegex)
diff --git a/pomerium/ingress_annotations.go b/pomerium/ingress_annotations.go
index b89cea59..1cbf3d7a 100644
--- a/pomerium/ingress_annotations.go
+++ b/pomerium/ingress_annotations.go
@@ -70,6 +70,7 @@ var (
 		model.PathRegex,
 		model.SecureUpstream,
 		model.TCPUpstream,
+		model.UDPUpstream,
 		model.UseServiceProxy,
 		model.SubtleAllowEmptyHost,
 	})
diff --git a/pomerium/ingress_to_route.go b/pomerium/ingress_to_route.go
index 1a51dd1b..bce69222 100644
--- a/pomerium/ingress_to_route.go
+++ b/pomerium/ingress_to_route.go
@@ -153,6 +153,16 @@ func setRoutePath(r *pb.Route, p networkingv1.HTTPIngressPath, ic *model.Ingress
 		return nil
 	}
 
+	if ic.IsUDPUpstream() {
+		if *p.PathType != networkingv1.PathTypeImplementationSpecific {
+			return fmt.Errorf("udp services must have %s path type", networkingv1.PathTypeImplementationSpecific)
+		}
+		if p.Path != "" {
+			return fmt.Errorf("udp services must not specify path, got %s", r.Path)
+		}
+		return nil
+	}
+
 	switch *p.PathType {
 	case networkingv1.PathTypeImplementationSpecific:
 		if ic.IsPathRegex() {
@@ -187,6 +197,15 @@ func setRouteFrom(r *pb.Route, host string, p networkingv1.HTTPIngressPath, ic *
 		u.Scheme = "tcp+https"
 	}
 
+	if ic.IsUDPUpstream() {
+		_, _, port, err := getServiceFromPath(p, ic)
+		if err != nil {
+			return err
+		}
+		u.Host = net.JoinHostPort(u.Host, fmt.Sprint(port))
+		u.Scheme = "udp+https"
+	}
+
 	r.From = u.String()
 	return nil
 }
@@ -261,6 +280,8 @@ func getPathServiceHosts(r *pb.Route, p networkingv1.HTTPIngressPath, ic *model.
 func getUpstreamScheme(ic *model.IngressConfig) string {
 	if ic.IsTCPUpstream() {
 		return "tcp"
+	} else if ic.IsUDPUpstream() {
+		return "udp"
 	} else if ic.IsSecureUpstream() {
 		return "https"
 	}