Repository: scylladb/scylla-operator
Pull request #993 (2022-06-27 13:48:02)

Reconcile Ingress objects for ScyllaCluster
ScyllaCluster CRD is extended with ExposeOptions and Domains allowing users to
configure domain, ingress class name and custom annotations.
If provided, Operator creates an Ingress object for each Scylla Node
pointing directly to it, and one additional Ingress for headless
Service allowing accessing any of available Scylla Nodes.

ExposeOptions are considered as experimental and are subject for
deprecation and incompatible changes. Using it, may require manual steps
during upgrades. User discretion is advised.

zimnx: @tnozicka PTAL

Patch:
diff --git a/deploy/operator/00_clusterrole_def.yaml b/deploy/operator/00_clusterrole_def.yaml
index 1e1442e01..e766be874 100644
--- a/deploy/operator/00_clusterrole_def.yaml
+++ b/deploy/operator/00_clusterrole_def.yaml
@@ -253,3 +253,15 @@ rules:
   - patch
   - update
   - watch
+- apiGroups:
+  - networking.k8s.io
+  resources:
+  - ingresses
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
diff --git a/examples/common/operator.yaml b/examples/common/operator.yaml
index 78f9be457..ad1524770 100644
--- a/examples/common/operator.yaml
+++ b/examples/common/operator.yaml
@@ -264,6 +264,18 @@ rules:
   - patch
   - update
   - watch
+- apiGroups:
+  - networking.k8s.io
+  resources:
+  - ingresses
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
 
 ---
 apiVersion: v1
@@ -1930,6 +1942,34 @@ spec:
                 developerMode:
                   description: developerMode determines if the cluster runs in developer-mode.
                   type: boolean
+                dnsDomains:
+                  description: dnsDomains is a list of DNS domains this cluster is reachable by. These domains are used when setting up the infrastructure, like certificates. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                  items:
+                    type: string
+                  type: array
+                exposeOptions:
+                  description: exposeOptions specifies options for exposing ScyllaCluster services. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                  properties:
+                    cql:
+                      description: cql specifies expose options for CQL SSL backend. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                      properties:
+                        ingress:
+                          description: ingress is an Ingress configuration options. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                          properties:
+                            annotations:
+                              additionalProperties:
+                                type: string
+                              description: annotations specifies custom annotations merged into every Ingress object. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                              type: object
+                            disabled:
+                              description: disabled controls if Ingress object creation is disabled. Unless disabled, there is an Ingress objects created for every Scylla node. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                              type: boolean
+                            ingressClassName:
+                              description: ingressClassName specifies Ingress class name. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                              type: string
+                          type: object
+                      type: object
+                  type: object
                 forceRedeploymentReason:
                   description: forceRedeploymentReason can be used to force a rolling update of all racks by providing a unique string.
                   type: string
diff --git a/helm/scylla-manager/values.schema.json b/helm/scylla-manager/values.schema.json
index 795a87ae2..ad667808b 100644
--- a/helm/scylla-manager/values.schema.json
+++ b/helm/scylla-manager/values.schema.json
@@ -230,6 +230,46 @@
           "description": "developerMode determines if the cluster runs in developer-mode.",
           "type": "boolean"
         },
+        "dnsDomains": {
+          "description": "dnsDomains is a list of DNS domains this cluster is reachable by. These domains are used when setting up the infrastructure, like certificates. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+          "items": {
+            "type": "string"
+          },
+          "type": "array"
+        },
+        "exposeOptions": {
+          "description": "exposeOptions specifies options for exposing ScyllaCluster services. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+          "properties": {
+            "cql": {
+              "description": "cql specifies expose options for CQL SSL backend. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+              "properties": {
+                "ingress": {
+                  "description": "ingress is an Ingress configuration options. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+                  "properties": {
+                    "annotations": {
+                      "additionalProperties": {
+                        "type": "string"
+                      },
+                      "description": "annotations specifies custom annotations merged into every Ingress object. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+                      "type": "object"
+                    },
+                    "disabled": {
+                      "description": "disabled controls if Ingress object creation is disabled. Unless disabled, there is an Ingress objects created for every Scylla node. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+                      "type": "boolean"
+                    },
+                    "ingressClassName": {
+                      "description": "ingressClassName specifies Ingress class name. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+                      "type": "string"
+                    }
+                  },
+                  "type": "object"
+                }
+              },
+              "type": "object"
+            }
+          },
+          "type": "object"
+        },
         "forceRedeploymentReason": {
           "description": "forceRedeploymentReason can be used to force a rolling update of all racks by providing a unique string.",
           "type": "string"
diff --git a/helm/scylla-operator/templates/clusterrole_def.yaml b/helm/scylla-operator/templates/clusterrole_def.yaml
index 1e1442e01..e766be874 100644
--- a/helm/scylla-operator/templates/clusterrole_def.yaml
+++ b/helm/scylla-operator/templates/clusterrole_def.yaml
@@ -253,3 +253,15 @@ rules:
   - patch
   - update
   - watch
+- apiGroups:
+  - networking.k8s.io
+  resources:
+  - ingresses
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
diff --git a/helm/scylla/templates/scyllacluster.yaml b/helm/scylla/templates/scyllacluster.yaml
index 17825aace..90c1c160c 100644
--- a/helm/scylla/templates/scyllacluster.yaml
+++ b/helm/scylla/templates/scyllacluster.yaml
@@ -42,6 +42,14 @@ spec:
   repairs:
     {{- toYaml . | nindent 4 }}
   {{- end }}
+  {{- with .Values.dnsDomains }}
+  dnsDomains:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
+  {{- with .Values.exposeOptions }}
+  exposeOptions:
+    {{- toYaml . | nindent 4 }}
+  {{- end }}
   datacenter:
     name: {{ .Values.datacenter }}
     racks:
diff --git a/helm/scylla/values.schema.json b/helm/scylla/values.schema.json
index 306618bc6..79d27bc04 100644
--- a/helm/scylla/values.schema.json
+++ b/helm/scylla/values.schema.json
@@ -119,6 +119,46 @@
       "description": "developerMode determines if the cluster runs in developer-mode.",
       "type": "boolean"
     },
+    "dnsDomains": {
+      "description": "dnsDomains is a list of DNS domains this cluster is reachable by. These domains are used when setting up the infrastructure, like certificates. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+      "items": {
+        "type": "string"
+      },
+      "type": "array"
+    },
+    "exposeOptions": {
+      "description": "exposeOptions specifies options for exposing ScyllaCluster services. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+      "properties": {
+        "cql": {
+          "description": "cql specifies expose options for CQL SSL backend. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+          "properties": {
+            "ingress": {
+              "description": "ingress is an Ingress configuration options. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+              "properties": {
+                "annotations": {
+                  "additionalProperties": {
+                    "type": "string"
+                  },
+                  "description": "annotations specifies custom annotations merged into every Ingress object. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+                  "type": "object"
+                },
+                "disabled": {
+                  "description": "disabled controls if Ingress object creation is disabled. Unless disabled, there is an Ingress objects created for every Scylla node. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+                  "type": "boolean"
+                },
+                "ingressClassName": {
+                  "description": "ingressClassName specifies Ingress class name. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.",
+                  "type": "string"
+                }
+              },
+              "type": "object"
+            }
+          },
+          "type": "object"
+        }
+      },
+      "type": "object"
+    },
     "forceRedeploymentReason": {
       "description": "forceRedeploymentReason can be used to force a rolling update of all racks by providing a unique string.",
       "type": "string"
diff --git a/pkg/api/scylla/v1/cluster_types.go b/pkg/api/scylla/v1/cluster_types.go
index 94766e13b..a14eaa801 100644
--- a/pkg/api/scylla/v1/cluster_types.go
+++ b/pkg/api/scylla/v1/cluster_types.go
@@ -95,6 +95,55 @@ type ScyllaClusterSpec struct {
 	// used for pulling Scylla and Agent images.
 	// +optional
 	ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"`
+
+	// dnsDomains is a list of DNS domains this cluster is reachable by.
+	// These domains are used when setting up the infrastructure, like certificates.
+	// EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+	// +optional
+	DNSDomains []string `json:"dnsDomains,omitempty"`
+
+	// exposeOptions specifies options for exposing ScyllaCluster services.
+	// EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+	// +optional
+	ExposeOptions *ExposeOptions `json:"exposeOptions,omitempty"`
+}
+
+// ExposeOptions hold options related to exposing ScyllaCluster backends.
+// EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+type ExposeOptions struct {
+	// cql specifies expose options for CQL SSL backend.
+	// EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+	// +optional
+	CQL *CQLExposeOptions `json:"cql,omitempty"`
+}
+
+// CQLExposeOptions hold options related to exposing CQL backend.
+// EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+type CQLExposeOptions struct {
+	// ingress is an Ingress configuration options.
+	// EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+	// +optional
+	Ingress *IngressOptions `json:"ingress,omitempty"`
+}
+
+// IngressOptions defines configuration options for Ingress objects associated with cluster nodes.
+// EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+type IngressOptions struct {
+	// disabled controls if Ingress object creation is disabled.
+	// Unless disabled, there is an Ingress objects created for every Scylla node.
+	// EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+	// +optional
+	Disabled *bool `json:"disabled,omitempty"`
+
+	// ingressClassName specifies Ingress class name.
+	// EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+	// +optional
+	IngressClassName string `json:"ingressClassName,omitempty"`
+
+	// annotations specifies custom annotations merged into every Ingress object.
+	// EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+	// +optional
+	Annotations map[string]string `json:"annotations,omitempty"`
 }
 
 // GenericUpgradeFailureStrategy allows to specify how upgrade logic should handle failures.
diff --git a/pkg/api/scylla/v1/scylla.scylladb.com_scyllaclusters.yaml b/pkg/api/scylla/v1/scylla.scylladb.com_scyllaclusters.yaml
index 67a81b0cc..367650ffa 100644
--- a/pkg/api/scylla/v1/scylla.scylladb.com_scyllaclusters.yaml
+++ b/pkg/api/scylla/v1/scylla.scylladb.com_scyllaclusters.yaml
@@ -1656,6 +1656,34 @@ spec:
                 developerMode:
                   description: developerMode determines if the cluster runs in developer-mode.
                   type: boolean
+                dnsDomains:
+                  description: dnsDomains is a list of DNS domains this cluster is reachable by. These domains are used when setting up the infrastructure, like certificates. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                  items:
+                    type: string
+                  type: array
+                exposeOptions:
+                  description: exposeOptions specifies options for exposing ScyllaCluster services. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                  properties:
+                    cql:
+                      description: cql specifies expose options for CQL SSL backend. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                      properties:
+                        ingress:
+                          description: ingress is an Ingress configuration options. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                          properties:
+                            annotations:
+                              additionalProperties:
+                                type: string
+                              description: annotations specifies custom annotations merged into every Ingress object. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                              type: object
+                            disabled:
+                              description: disabled controls if Ingress object creation is disabled. Unless disabled, there is an Ingress objects created for every Scylla node. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                              type: boolean
+                            ingressClassName:
+                              description: ingressClassName specifies Ingress class name. EXPERIMENTAL. Do not rely on any particular behaviour controlled by this field.
+                              type: string
+                          type: object
+                      type: object
+                  type: object
                 forceRedeploymentReason:
                   description: forceRedeploymentReason can be used to force a rolling update of all racks by providing a unique string.
                   type: string
diff --git a/pkg/api/scylla/v1/zz_generated.deepcopy.go b/pkg/api/scylla/v1/zz_generated.deepcopy.go
index 521769f48..3462e9a50 100644
--- a/pkg/api/scylla/v1/zz_generated.deepcopy.go
+++ b/pkg/api/scylla/v1/zz_generated.deepcopy.go
@@ -90,6 +90,27 @@ func (in *BackupTaskStatus) DeepCopy() *BackupTaskStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CQLExposeOptions) DeepCopyInto(out *CQLExposeOptions) {
+	*out = *in
+	if in.Ingress != nil {
+		in, out := &in.Ingress, &out.Ingress
+		*out = new(IngressOptions)
+		(*in).DeepCopyInto(*out)
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CQLExposeOptions.
+func (in *CQLExposeOptions) DeepCopy() *CQLExposeOptions {
+	if in == nil {
+		return nil
+	}
+	out := new(CQLExposeOptions)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *DatacenterSpec) DeepCopyInto(out *DatacenterSpec) {
 	*out = *in
@@ -113,6 +134,27 @@ func (in *DatacenterSpec) DeepCopy() *DatacenterSpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ExposeOptions) DeepCopyInto(out *ExposeOptions) {
+	*out = *in
+	if in.CQL != nil {
+		in, out := &in.CQL, &out.CQL
+		*out = new(CQLExposeOptions)
+		(*in).DeepCopyInto(*out)
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExposeOptions.
+func (in *ExposeOptions) DeepCopy() *ExposeOptions {
+	if in == nil {
+		return nil
+	}
+	out := new(ExposeOptions)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *GenericUpgradeSpec) DeepCopyInto(out *GenericUpgradeSpec) {
 	*out = *in
@@ -130,6 +172,34 @@ func (in *GenericUpgradeSpec) DeepCopy() *GenericUpgradeSpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *IngressOptions) DeepCopyInto(out *IngressOptions) {
+	*out = *in
+	if in.Disabled != nil {
+		in, out := &in.Disabled, &out.Disabled
+		*out = new(bool)
+		**out = **in
+	}
+	if in.Annotations != nil {
+		in, out := &in.Annotations, &out.Annotations
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
+	return
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressOptions.
+func (in *IngressOptions) DeepCopy() *IngressOptions {
+	if in == nil {
+		return nil
+	}
+	out := new(IngressOptions)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Network) DeepCopyInto(out *Network) {
 	*out = *in
@@ -453,6 +523,16 @@ func (in *ScyllaClusterSpec) DeepCopyInto(out *ScyllaClusterSpec) {
 		*out = make([]corev1.LocalObjectReference, len(*in))
 		copy(*out, *in)
 	}
+	if in.DNSDomains != nil {
+		in, out := &in.DNSDomains, &out.DNSDomains
+		*out = make([]string, len(*in))
+		copy(*out, *in)
+	}
+	if in.ExposeOptions != nil {
+		in, out := &in.ExposeOptions, &out.ExposeOptions
+		*out = new(ExposeOptions)
+		(*in).DeepCopyInto(*out)
+	}
 	return
 }
 
diff --git a/pkg/api/validation/cluster_validation.go b/pkg/api/validation/cluster_validation.go
index 13c041810..a97a3f155 100644
--- a/pkg/api/validation/cluster_validation.go
+++ b/pkg/api/validation/cluster_validation.go
@@ -8,7 +8,9 @@ import (
 	"github.com/scylladb/go-set/strset"
 	scyllav1 "github.com/scylladb/scylla-operator/pkg/api/scylla/v1"
 	"github.com/scylladb/scylla-operator/pkg/semver"
+	apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation"
 	"k8s.io/apimachinery/pkg/util/sets"
+	apimachineryutilvalidation "k8s.io/apimachinery/pkg/util/validation"
 	"k8s.io/apimachinery/pkg/util/validation/field"
 )
 
@@ -86,6 +88,46 @@ func ValidateScyllaClusterSpec(spec *scyllav1.ScyllaClusterSpec, fldPath *field.
 		}
 	}
 
+	for i, domain := range spec.DNSDomains {
+		allErrs = append(allErrs, apimachineryutilvalidation.IsFullyQualifiedName(fldPath.Child("dnsDomains").Index(i), domain)...)
+	}
+
+	if len(spec.DNSDomains) == 0 && spec.ExposeOptions != nil {
+		if spec.ExposeOptions.CQL != nil && spec.ExposeOptions.CQL.Ingress != nil {
+			allErrs = append(allErrs, field.Required(fldPath.Child("dnsDomains"), "at least one domain needs to be provided when exposing CQL via ingresses"))
+		}
+	}
+
+	if spec.ExposeOptions != nil {
+		allErrs = append(allErrs, ValidateExposeOptions(spec.ExposeOptions, fldPath.Child("exposeOptions"))...)
+	}
+
+	return allErrs
+}
+
+func ValidateExposeOptions(options *scyllav1.ExposeOptions, fldPath *field.Path) field.ErrorList {
+	allErrs := field.ErrorList{}
+
+	if options.CQL != nil && options.CQL.Ingress != nil {
+		allErrs = append(allErrs, ValidateIngressOptions(options.CQL.Ingress, fldPath.Child("cql", "ingress"))...)
+	}
+
+	return allErrs
+}
+
+func ValidateIngressOptions(options *scyllav1.IngressOptions, fldPath *field.Path) field.ErrorList {
+	allErrs := field.ErrorList{}
+
+	if len(options.IngressClassName) != 0 {
+		for _, msg := range apimachineryvalidation.NameIsDNSSubdomain(options.IngressClassName, false) {
+			allErrs = append(allErrs, field.Invalid(fldPath.Child("ingressClassName"), options.IngressClassName, msg))
+		}
+	}
+
+	if len(options.Annotations) != 0 {
+		allErrs = append(allErrs, apimachineryvalidation.ValidateAnnotations(options.Annotations, fldPath.Child("annotations"))...)
+	}
+
 	return allErrs
 }
 
diff --git a/pkg/cmd/operator/operator.go b/pkg/cmd/operator/operator.go
index a2766c560..ac543a4e7 100644
--- a/pkg/cmd/operator/operator.go
+++ b/pkg/cmd/operator/operator.go
@@ -184,6 +184,7 @@ func (o *OperatorOptions) run(ctx context.Context, streams genericclioptions.IOS
 		kubeInformers.Rbac().V1().RoleBindings(),
 		kubeInformers.Apps().V1().StatefulSets(),
 		kubeInformers.Policy().V1beta1().PodDisruptionBudgets(),
+		kubeInformers.Networking().V1().Ingresses(),
 		scyllaInformers.Scylla().V1().ScyllaClusters(),
 		o.OperatorImage,
 	)
diff --git a/pkg/controller/scyllacluster/controller.go b/pkg/controller/scyllacluster/controller.go
index 28ce6738b..1d8d279e1 100644
--- a/pkg/controller/scyllacluster/controller.go
+++ b/pkg/controller/scyllacluster/controller.go
@@ -14,6 +14,7 @@ import (
 	"github.com/scylladb/scylla-operator/pkg/scheme"
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
+	networkingv1 "k8s.io/api/networking/v1"
 	policyv1beta1 "k8s.io/api/policy/v1beta1"
 	rbacv1 "k8s.io/api/rbac/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -24,12 +25,14 @@ import (
 	"k8s.io/apimachinery/pkg/util/wait"
 	appsv1informers "k8s.io/client-go/informers/apps/v1"
 	corev1informers "k8s.io/client-go/informers/core/v1"
+	networkingv1informers "k8s.io/client-go/informers/networking/v1"
 	policyv1beta1informers "k8s.io/client-go/informers/policy/v1beta1"
 	rbacv1informers "k8s.io/client-go/informers/rbac/v1"
 	"k8s.io/client-go/kubernetes"
 	corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
 	appsv1listers "k8s.io/client-go/listers/apps/v1"
 	corev1listers "k8s.io/client-go/listers/core/v1"
+	networkingv1listers "k8s.io/client-go/listers/networking/v1"
 	policyv1beta1listers "k8s.io/client-go/listers/policy/v1beta1"
 	rbacv1listers "k8s.io/client-go/listers/rbac/v1"
 	"k8s.io/client-go/tools/cache"
@@ -67,6 +70,7 @@ type Controller struct {
 	roleBindingLister    rbacv1listers.RoleBindingLister
 	statefulSetLister    appsv1listers.StatefulSetLister
 	pdbLister            policyv1beta1listers.PodDisruptionBudgetLister
+	ingressLister        networkingv1listers.IngressLister
 	scyllaLister         scyllav1listers.ScyllaClusterLister
 
 	cachesToSync []cache.InformerSynced
@@ -86,6 +90,7 @@ func NewController(
 	roleBindingInformer rbacv1informers.RoleBindingInformer,
 	statefulSetInformer appsv1informers.StatefulSetInformer,
 	pdbInformer policyv1beta1informers.PodDisruptionBudgetInformer,
+	ingressInformer networkingv1informers.IngressInformer,
 	scyllaClusterInformer scyllav1informers.ScyllaClusterInformer,
 	operatorImage string,
 ) (*Controller, error) {
@@ -116,6 +121,7 @@ func NewController(
 		roleBindingLister:    roleBindingInformer.Lister(),
 		statefulSetLister:    statefulSetInformer.Lister(),
 		pdbLister:            pdbInformer.Lister(),
+		ingressLister:        ingressInformer.Lister(),
 		scyllaLister:         scyllaClusterInformer.Lister(),
 
 		cachesToSync: []cache.InformerSynced{
@@ -126,6 +132,7 @@ func NewController(
 			roleBindingInformer.Informer().HasSynced,
 			statefulSetInformer.Informer().HasSynced,
 			pdbInformer.Informer().HasSynced,
+			ingressInformer.Informer().HasSynced,
 			scyllaClusterInformer.Informer().HasSynced,
 		},
 
@@ -177,6 +184,12 @@ func NewController(
 		DeleteFunc: scc.deletePodDisruptionBudget,
 	})
 
+	ingressInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
+		AddFunc:    scc.addIngress,
+		UpdateFunc: scc.updateIngress,
+		DeleteFunc: scc.deleteIngress,
+	})
+
 	scyllaClusterInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
 		AddFunc:    scc.addScyllaCluster,
 		UpdateFunc: scc.updateScyllaCluster,
@@ -669,6 +682,50 @@ func (scc *Controller) deletePodDisruptionBudget(obj interface{}) {
 	scc.enqueueOwner(pdb)
 }
 
+func (scc *Controller) addIngress(obj interface{}) {
+	ingress := obj.(*networkingv1.Ingress)
+	klog.V(4).InfoS("Observed addition of Ingress", "Ingress", klog.KObj(ingress))
+	scc.enqueueOwner(ingress)
+}
+
+func (scc *Controller) updateIngress(old, cur interface{}) {
+	oldIngress := old.(*networkingv1.Ingress)
+	currentIngress := cur.(*networkingv1.Ingress)
+
+	if currentIngress.UID != oldIngress.UID {
+		key, err := keyFunc(oldIngress)
+		if err != nil {
+			utilruntime.HandleError(fmt.Errorf("couldn't get key for object %#v: %v", oldIngress, err))
+			return
+		}
+		scc.deleteIngress(cache.DeletedFinalStateUnknown{
+			Key: key,
+			Obj: oldIngress,
+		})
+	}
+
+	klog.V(4).InfoS("Observed update of Ingress", "Ingress", klog.KObj(oldIngress))
+	scc.enqueueOwner(currentIngress)
+}
+
+func (scc *Controller) deleteIngress(obj interface{}) {
+	ingress, ok := obj.(*networkingv1.Ingress)
+	if !ok {
+		tombstone, ok := obj.(cache.DeletedFinalStateUnknown)
+		if !ok {
+			utilruntime.HandleError(fmt.Errorf("couldn't get object from tombstone %#v", obj))
+			return
+		}
+		ingress, ok = tombstone.Obj.(*networkingv1.Ingress)
+		if !ok {
+			utilruntime.HandleError(fmt.Errorf("tombstone contained object that is not a Ingress %#v", obj))
+			return
+		}
+	}
+	klog.V(4).InfoS("Observed deletion of Ingress", "Ingress", klog.KObj(ingress))
+	scc.enqueueOwner(ingress)
+}
+
 func (scc *Controller) addScyllaCluster(obj interface{}) {
 	sc := obj.(*scyllav1.ScyllaCluster)
 	klog.V(4).InfoS("Observed addition of ScyllaCluster", "ScyllaCluster", klog.KObj(sc))
diff --git a/pkg/controller/scyllacluster/resource.go b/pkg/controller/scyllacluster/resource.go
index cd12aea4e..a240e0e02 100644
--- a/pkg/controller/scyllacluster/resource.go
+++ b/pkg/controller/scyllacluster/resource.go
@@ -3,6 +3,7 @@ package scyllacluster
 import (
 	"fmt"
 	"path"
+	"sort"
 	"strings"
 
 	scyllav1 "github.com/scylladb/scylla-operator/pkg/api/scylla/v1"
@@ -10,6 +11,7 @@ import (
 	"github.com/scylladb/scylla-operator/pkg/naming"
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
+	networkingv1 "k8s.io/api/networking/v1"
 	"k8s.io/api/policy/v1beta1"
 	rbacv1 "k8s.io/api/rbac/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -17,6 +19,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/util/intstr"
 	corev1listers "k8s.io/client-go/listers/core/v1"
+	"k8s.io/klog/v2"
 	"k8s.io/utils/pointer"
 )
 
@@ -30,7 +33,16 @@ const (
 	rootGID = 0
 )
 
-func HeadlessServiceForCluster(c *scyllav1.ScyllaCluster) *corev1.Service {
+const (
+	portNameCQL              = "cql"
+	portNameCQLSSL           = "cql-ssl"
+	portNameCQLShardAware    = "cql-shard-aware"
+	portNameCQLSSLShardAware = "cql-ssl-shard-aware"
+	portNameAlternator       = "alternator"
+	portNameThrift           = "thrift"
+)
+
+func IdentityService(c *scyllav1.ScyllaCluster) *corev1.Service {
 	labels := naming.ClusterLabels(c)
 	labels[naming.ScyllaServiceTypeLabel] = string(naming.ScyllaServiceTypeIdentity)
 
@@ -44,19 +56,9 @@ func HeadlessServiceForCluster(c *scyllav1.ScyllaCluster) *corev1.Service {
 			},
 		},
 		Spec: corev1.ServiceSpec{
-			ClusterIP: corev1.ClusterIPNone,
-			Type:      corev1.ServiceTypeClusterIP,
-			Selector:  naming.ClusterLabels(c),
-			Ports: []corev1.ServicePort{
-				{
-					Name: "prometheus",
-					Port: 9180,
-				},
-				{
-					Name: "agent-prometheus",
-					Port: 5090,
-				},
-			},
+			Type:     corev1.ServiceTypeClusterIP,
+			Selector: naming.ClusterLabels(c),
+			Ports:    servicePorts(c),
 		},
 	}
 }
@@ -106,13 +108,13 @@ func MemberService(sc *scyllav1.ScyllaCluster, rackName, name string, oldService
 		Spec: corev1.ServiceSpec{
 			Type:                     corev1.ServiceTypeClusterIP,
 			Selector:                 naming.StatefulSetPodLabel(name),
-			Ports:                    memberServicePorts(sc),
+			Ports:                    servicePorts(sc),
 			PublishNotReadyAddresses: true,
 		},
 	}
 }
 
-func memberServicePorts(cluster *scyllav1.ScyllaCluster) []corev1.ServicePort {
+func servicePorts(cluster *scyllav1.ScyllaCluster) []corev1.ServicePort {
 	ports := []corev1.ServicePort{
 		{
 			Name: "inter-node-communication",
@@ -127,41 +129,51 @@ func memberServicePorts(cluster *scyllav1.ScyllaCluster) []corev1.ServicePort {
 			Port: 7199,
 		},
 		{
-			Name: "cql",
-			Port: 9042,
+			Name: "agent-api",
+			Port: 10001,
 		},
 		{
-			Name: "cql-ssl",
-			Port: 9142,
+			Name: "prometheus",
+			Port: 9180,
 		},
 		{
-			Name: "cql-shard-aware",
-			Port: 19042,
+			Name: "agent-prometheus",
+			Port: 5090,
 		},
 		{
-			Name: "cql-ssl-shard-aware",
-			Port: 19142,
+			Name: "node-exporter",
+			Port: 9100,
 		},
 		{
-			Name: "agent-api",
-			Port: 10001,
+			Name: portNameCQL,
+			Port: 9042,
 		},
 		{
-			Name: "node-exporter",
-			Port: 9100,
+			Name: portNameCQLSSL,
+			Port: 9142,
+		},
+		{
+			Name: portNameCQLShardAware,
+			Port: 19042,
+		},
+		{
+			Name: portNameCQLSSLShardAware,
+			Port: 19142,
 		},
 	}
+
 	if cluster.Spec.Alternator.Enabled() {
 		ports = append(ports, corev1.ServicePort{
-			Name: "alternator",
+			Name: portNameAlternator,
 			Port: cluster.Spec.Alternator.Port,
 		})
 	} else {
 		ports = append(ports, corev1.ServicePort{
-			Name: "thrift",
+			Name: portNameThrift,
 			Port: 9160,
 		})
 	}
+
 	return ports
 }
 
@@ -625,6 +637,121 @@ func MakePodDisruptionBudget(c *scyllav1.ScyllaCluster) *v1beta1.PodDisruptionBu
 	}
 }
 
+func MakeIngresses(c *scyllav1.ScyllaCluster, services map[string]*corev1.Service) []*networkingv1.Ingress {
+	// Don't create Ingresses if cluster isn't exposed.
+	if c.Spec.ExposeOptions == nil {
+		return nil
+	}
+
+	type params struct {
+		backendName    string
+		portName       string
+		ingressOptions *scyllav1.IngressOptions
+	}
+	var ingressParams []params
+
+	if c.Spec.ExposeOptions.CQL != nil && isIngressEnabled(c.Spec.ExposeOptions.CQL.Ingress) {
+		ingressParams = append(ingressParams, params{
+			backendName:    "cql",
+			portName:       portNameCQLSSL,
+			ingressOptions: c.Spec.ExposeOptions.CQL.Ingress,
+		})
+	}
+
+	var ingresses []*networkingv1.Ingress
+
+	for _, ip := range ingressParams {
+		for _, service := range services {
+			var hosts []string
+			labels := naming.ClusterLabels(c)
+
+			switch naming.ScyllaServiceType(service.Labels[naming.ScyllaServiceTypeLabel]) {
+			case naming.ScyllaServiceTypeIdentity:
+				for _, domain := range c.Spec.DNSDomains {
+					hosts = append(hosts, fmt.Sprintf("%s.%s.%s", naming.ScyllaIngressSubdomainAny, ip.backendName, domain))
+				}
+				labels[naming.ScyllaIngressTypeLabel] = string(naming.ScyllaIngressTypeAnyNode)
+
+			case naming.ScyllaServiceTypeMember:
+				hostID, ok := service.Annotations[naming.HostIDAnnotation]
+				if !ok {
+					klog.V(4).Infof("Service %q is missing HostID annotation, postponing Ingress creation until it's available", naming.ObjRef(service))
+					continue
+				}
+
+				if len(hostID) == 0 {
+					klog.Warningf("Can't create Ingress for Service %s because it has unexpected empty HostID annotation", klog.KObj(service))
+					continue
+				}
+
+				for _, domain := range c.Spec.DNSDomains {
+					hosts = append(hosts, fmt.Sprintf("%s.%s.%s", hostID, ip.backendName, domain))
+				}
+				labels[naming.ScyllaIngressTypeLabel] = string(naming.ScyllaIngressTypeNode)
+
+			default:
+				klog.Warningf("Unsupported Scylla service type %q, not creating Ingress for it", service.Labels[naming.ScyllaServiceTypeLabel])
+				continue
+			}
+
+			ingress := &networkingv1.Ingress{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:        fmt.Sprintf("%s-%s", service.Name, ip.backendName),
+					Namespace:   c.Namespace,
+					Labels:      labels,
+					Annotations: ip.ingressOptions.Annotations,
+					OwnerReferences: []metav1.OwnerReference{
+						*metav1.NewControllerRef(c, controllerGVK),
+					},
+				},
+				Spec: networkingv1.IngressSpec{
+					IngressClassName: pointer.String(ip.ingressOptions.IngressClassName),
+				},
+			}
+
+			pathPrefix := networkingv1.PathTypePrefix
+			for _, host := range hosts {
+				ingress.Spec.Rules = append(ingress.Spec.Rules, networkingv1.IngressRule{
+					Host: host,
+					IngressRuleValue: networkingv1.IngressRuleValue{
+						HTTP: &networkingv1.HTTPIngressRuleValue{
+							Paths: []networkingv1.HTTPIngressPath{
+								{
+									Path:     "/",
+									PathType: &pathPrefix,
+									Backend: networkingv1.IngressBackend{
+										Service: &networkingv1.IngressServiceBackend{
+											Name: service.Name,
+											Port: networkingv1.ServiceBackendPort{
+												Name: ip.portName,
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				})
+			}
+
+			ingresses = append(ingresses, ingress)
+		}
+	}
+
+	sort.Slice(ingresses, func(i, j int) bool {
+		return ingresses[i].GetName() < ingresses[j].GetName()
+	})
+
+	return ingresses
+}
+
+func isIngressEnabled(ingressOptions *scyllav1.IngressOptions) bool {
+	if ingressOptions == nil {
+		return false
+	}
+	return ingressOptions.Disabled == nil || !*ingressOptions.Disabled
+}
+
 func MakeAgentAuthTokenSecret(c *scyllav1.ScyllaCluster, authToken string) (*corev1.Secret, error) {
 	data, err := helpers.GetAgentAuthTokenConfig(authToken)
 	if err != nil {
diff --git a/pkg/controller/scyllacluster/sync.go b/pkg/controller/scyllacluster/sync.go
index ef81f0a68..99f534057 100644
--- a/pkg/controller/scyllacluster/sync.go
+++ b/pkg/controller/scyllacluster/sync.go
@@ -10,6 +10,7 @@ import (
 	"github.com/scylladb/scylla-operator/pkg/naming"
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
+	networkingv1 "k8s.io/api/networking/v1"
 	policyv1beta1 "k8s.io/api/policy/v1beta1"
 	rbacv1 "k8s.io/api/rbac/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
@@ -284,6 +285,50 @@ func (scc *Controller) getPDBs(ctx context.Context, sc *scyllav1.ScyllaCluster)
 	return cm.ClaimPodDisruptionBudgets(pdbs)
 }
 
+func (scc *Controller) getIngresses(ctx context.Context, sc *scyllav1.ScyllaCluster) (map[string]*networkingv1.Ingress, error) {
+	// List all Ingresses to find even those that no longer match our selector.
+	// They will be orphaned in ClaimIngress().
+	ingresses, err := scc.ingressLister.Ingresses(sc.Namespace).List(labels.Everything())
+	if err != nil {
+		return nil, err
+	}
+
+	selector := labels.SelectorFromSet(labels.Set{
+		naming.ClusterNameLabel: sc.Name,
+	})
+
+	// If any adoptions are attempted, we should first recheck for deletion with
+	// an uncached quorum read sometime after listing Ingresses.
+	canAdoptFunc := func() error {
+		fresh, err := scc.scyllaClient.ScyllaClusters(sc.Namespace).Get(ctx, sc.Name, metav1.GetOptions{})
+		if err != nil {
+			return err
+		}
+
+		if fresh.UID != sc.UID {
+			return fmt.Errorf("original ScyllaCluster %v/%v is gone: got uid %v, wanted %v", sc.Namespace, sc.Name, fresh.UID, sc.UID)
+		}
+
+		if fresh.GetDeletionTimestamp() != nil {
+			return fmt.Errorf("%v/%v has just been deleted at %v", sc.Namespace, sc.Name, sc.DeletionTimestamp)
+		}
+
+		return nil
+	}
+	cm := controllertools.NewIngressControllerRefManager(
+		ctx,
+		sc,
+		controllerGVK,
+		selector,
+		canAdoptFunc,
+		controllertools.RealIngressControl{
+			KubeClient: scc.kubeClient,
+			Recorder:   scc.eventRecorder,
+		},
+	)
+	return cm.ClaimIngresss(ingresses)
+}
+
 func (scc *Controller) sync(ctx context.Context, key string) error {
 	namespace, name, err := cache.SplitMetaNamespaceKey(key)
 	if err != nil {
@@ -336,6 +381,11 @@ func (scc *Controller) sync(ctx context.Context, key string) error {
 		return err
 	}
 
+	ingressMap, err := scc.getIngresses(ctx, sc)
+	if err != nil {
+		return fmt.Errorf("can't get ingresses: %w", err)
+	}
+
 	status := scc.calculateStatus(sc, statefulSetMap, serviceMap)
 
 	if sc.DeletionTimestamp != nil {
@@ -379,6 +429,11 @@ func (scc *Controller) sync(ctx context.Context, key string) error {
 		errs = append(errs, err)
 		// TODO: Set degraded condition
 	}
+	status, err = scc.syncIngresses(ctx, sc, status, ingressMap, serviceMap)
+	if err != nil {
+		errs = append(errs, err)
+		// TODO: Set degraded condition
+	}
 
 	err = scc.updateStatus(ctx, sc, status)
 	errs = append(errs, err)
diff --git a/pkg/controller/scyllacluster/sync_ingress.go b/pkg/controller/scyllacluster/sync_ingress.go
new file mode 100644
index 000000000..1da886057
--- /dev/null
+++ b/pkg/controller/scyllacluster/sync_ingress.go
@@ -0,0 +1,66 @@
+package scyllacluster
+
+import (
+	"context"
+	"fmt"
+
+	scyllav1 "github.com/scylladb/scylla-operator/pkg/api/scylla/v1"
+	"github.com/scylladb/scylla-operator/pkg/resourceapply"
+	corev1 "k8s.io/api/core/v1"
+	networkingv1 "k8s.io/api/networking/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+)
+
+func (scc *Controller) syncIngresses(
+	ctx context.Context,
+	sc *scyllav1.ScyllaCluster,
+	status *scyllav1.ScyllaClusterStatus,
+	ingresses map[string]*networkingv1.Ingress,
+	services map[string]*corev1.Service,
+) (*scyllav1.ScyllaClusterStatus, error) {
+	var err error
+
+	requiredIngresses := MakeIngresses(sc, services)
+
+	// Delete any excessive Ingresses.
+	// Delete has to be the fist action to avoid getting stuck on quota.
+	var deletionErrors []error
+	for _, ingress := range ingresses {
+		if ingress.DeletionTimestamp != nil {
+			continue
+		}
+
+		isRequired := false
+		for _, req := range requiredIngresses {
+			if ingress.Name == req.Name {
+				isRequired = true
+			}
+		}
+		if isRequired {
+			continue
+		}
+
+		propagationPolicy := metav1.DeletePropagationBackground
+		err = scc.kubeClient.NetworkingV1().Ingresses(ingress.Namespace).Delete(ctx, ingress.Name, metav1.DeleteOptions{
+			Preconditions: &metav1.Preconditions{
+				UID: &ingress.UID,
+			},
+			PropagationPolicy: &propagationPolicy,
+		})
+		deletionErrors = append(deletionErrors, err)
+	}
+	err = utilerrors.NewAggregate(deletionErrors)
+	if err != nil {
+		return status, fmt.Errorf("can't delete ingress(s): %w", err)
+	}
+
+	for _, requiredIngress := range requiredIngresses {
+		_, _, err = resourceapply.ApplyIngress(ctx, scc.kubeClient.NetworkingV1(), scc.ingressLister, scc.eventRecorder, requiredIngress)
+		if err != nil {
+			return status, fmt.Errorf("can't apply ingress: %w", err)
+		}
+	}
+
+	return status, nil
+}
diff --git a/pkg/controller/scyllacluster/sync_services.go b/pkg/controller/scyllacluster/sync_services.go
index 81953f60a..01f2e0d89 100644
--- a/pkg/controller/scyllacluster/sync_services.go
+++ b/pkg/controller/scyllacluster/sync_services.go
@@ -23,7 +23,7 @@ var serviceOrdinalRegex = regexp.MustCompile("^.*-([0-9]+)$")
 
 func (scc *Controller) makeServices(sc *scyllav1.ScyllaCluster, oldServices map[string]*corev1.Service) []*corev1.Service {
 	services := []*corev1.Service{
-		HeadlessServiceForCluster(sc),
+		IdentityService(sc),
 	}
 
 	for _, rack := range sc.Spec.Datacenter.Racks {
diff --git a/pkg/controllertools/ingressmanager.go b/pkg/controllertools/ingressmanager.go
new file mode 100644
index 000000000..36853120c
--- /dev/null
+++ b/pkg/controllertools/ingressmanager.go
@@ -0,0 +1,139 @@
+package controllertools
+
+import (
+	"context"
+	"fmt"
+
+	kubecontroller "github.com/scylladb/scylla-operator/pkg/thirdparty/k8s.io/kubernetes/pkg/controller"
+	networkingv1 "k8s.io/api/networking/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
+	utilerrors "k8s.io/apimachinery/pkg/util/errors"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/tools/record"
+	"k8s.io/klog/v2"
+)
+
+type IngressControlInterface interface {
+	PatchIngress(ctx context.Context, namespace, name string, pt types.PatchType, data []byte, options metav1.PatchOptions) error
+}
+
+type RealIngressControl struct {
+	KubeClient kubernetes.Interface
+	Recorder   record.EventRecorder
+}
+
+var _ IngressControlInterface = &RealIngressControl{}
+
+func (r RealIngressControl) PatchIngress(ctx context.Context, namespace, name string, pt types.PatchType, data []byte, options metav1.PatchOptions) error {
+	_, err := r.KubeClient.NetworkingV1().Ingresses(namespace).Patch(ctx, name, pt, data, options)
+	return err
+}
+
+type IngressControllerRefManager struct {
+	kubecontroller.BaseControllerRefManager
+	ctx            context.Context
+	controllerGVK  schema.GroupVersionKind
+	ingressControl IngressControlInterface
+}
+
+func NewIngressControllerRefManager(
+	ctx context.Context,
+	controller metav1.Object,
+	controllerGVK schema.GroupVersionKind,
+	selector labels.Selector,
+	canAdopt func() error,
+	IngressControl IngressControlInterface,
+) *IngressControllerRefManager {
+	return &IngressControllerRefManager{
+		BaseControllerRefManager: kubecontroller.BaseControllerRefManager{
+			Controller:   controller,
+			Selector:     selector,
+			CanAdoptFunc: canAdopt,
+		},
+		ctx:            ctx,
+		controllerGVK:  controllerGVK,
+		ingressControl: IngressControl,
+	}
+}
+
+func (m *IngressControllerRefManager) ClaimIngresss(ingresses []*networkingv1.Ingress) (map[string]*networkingv1.Ingress, error) {
+	match := func(obj metav1.Object) bool {
+		return m.Selector.Matches(labels.Set(obj.GetLabels()))
+	}
+	adopt := func(obj metav1.Object) error {
+		return m.AdoptIngress(obj.(*networkingv1.Ingress))
+	}
+	release := func(obj metav1.Object) error {
+		return m.ReleaseIngress(obj.(*networkingv1.Ingress))
+	}
+
+	claimedMap := make(map[string]*networkingv1.Ingress, len(ingresses))
+	var errors []error
+	for _, ingress := range ingresses {
+		ok, err := m.ClaimObject(ingress, match, adopt, release)
+		if err != nil {
+			errors = append(errors, err)
+			continue
+		}
+		if ok {
+			claimedMap[ingress.Name] = ingress
+		}
+	}
+	return claimedMap, utilerrors.NewAggregate(errors)
+}
+
+func (m *IngressControllerRefManager) AdoptIngress(ingress *networkingv1.Ingress) error {
+	err := m.CanAdopt()
+	if err != nil {
+		return fmt.Errorf("can't adopt Ingress %v/%v (%v): %w", ingress.Namespace, ingress.Name, ingress.UID, err)
+	}
+
+	// Note that ValidateOwnerReferences() will reject this patch if another
+	// OwnerReference exists with controller=true.
+	patchBytes, err := kubecontroller.OwnerRefControllerPatch(m.Controller, m.controllerGVK, ingress.UID)
+	if err != nil {
+		return err
+	}
+
+	return m.ingressControl.PatchIngress(m.ctx, ingress.Namespace, ingress.Name, types.StrategicMergePatchType, patchBytes, metav1.PatchOptions{})
+}
+
+func (m *IngressControllerRefManager) ReleaseIngress(ingress *networkingv1.Ingress) error {
+	klog.V(2).InfoS("Patching Ingress to remove its controllerRef",
+		"Ingress",
+		klog.KObj(ingress),
+		"ControllerRef",
+		fmt.Sprintf("%s/%s:%s", m.controllerGVK.GroupVersion(), m.controllerGVK.Kind, m.Controller.GetName()),
+	)
+
+	patchBytes, err := kubecontroller.DeleteOwnerRefStrategicMergePatch(ingress.UID, m.Controller.GetUID())
+	if err != nil {
+		return err
+	}
+
+	err = m.ingressControl.PatchIngress(m.ctx, ingress.Namespace, ingress.Name, types.StrategicMergePatchType, patchBytes, metav1.PatchOptions{})
+	if err != nil {
+		if errors.IsNotFound(err) {
+			// If the Ingress no longer exists, ignore it.
+			klog.V(4).InfoS("Couldn't patch Ingress as it was missing", klog.KObj(ingress))
+			return nil
+		}
+
+		if errors.IsInvalid(err) {
+			// Invalid error will be returned in two cases:
+			// 1. the Ingress has no owner reference
+			// 2. the UID of the Ingress doesn't match because it was recreated
+			// In both cases, the error can be ignored.
+			klog.V(4).InfoS("Couldn't patch Ingress as it was invalid", klog.KObj(ingress))
+			return nil
+		}
+
+		return err
+	}
+
+	return nil
+}
diff --git a/pkg/naming/constants.go b/pkg/naming/constants.go
index 08f30341c..17babdd02 100644
--- a/pkg/naming/constants.go
+++ b/pkg/naming/constants.go
@@ -40,6 +40,15 @@ const (
 	ScyllaServiceTypeMember   ScyllaServiceType = "member"
 )
 
+type ScyllaIngressType string
+
+const (
+	ScyllaIngressTypeNode    ScyllaIngressType = "Node"
+	ScyllaIngressTypeAnyNode ScyllaIngressType = "AnyNode"
+
+	ScyllaIngressSubdomainAny = "any"
+)
+
 // Generic Labels used on objects created by the operator.
 const (
 	ClusterNameLabel             = "scylla/cluster"
@@ -47,6 +56,7 @@ const (
 	RackNameLabel                = "scylla/rack"
 	ScyllaVersionLabel           = "scylla/scylla-version"
 	ScyllaServiceTypeLabel       = "scylla-operator.scylladb.com/scylla-service-type"
+	ScyllaIngressTypeLabel       = "scylla-operator.scylladb.com/scylla-ingress-type"
 	ManagedHash                  = "scylla-operator.scylladb.com/managed-hash"
 	NodeConfigJobForNodeUIDLabel = "scylla-operator.scylladb.com/node-config-job-for-node-uid"
 	NodeConfigJobTypeLabel       = "scylla-operator.scylladb.com/node-config-job-type"
diff --git a/pkg/resourceapply/networking.go b/pkg/resourceapply/networking.go
new file mode 100644
index 000000000..de15a6632
--- /dev/null
+++ b/pkg/resourceapply/networking.go
@@ -0,0 +1,81 @@
+// Copyright (C) 2021 ScyllaDB
+
+package resourceapply
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/scylladb/scylla-operator/pkg/naming"
+	"github.com/scylladb/scylla-operator/pkg/resourcemerge"
+	networkingv1 "k8s.io/api/networking/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	networkingv1client "k8s.io/client-go/kubernetes/typed/networking/v1"
+	networkingv1listers "k8s.io/client-go/listers/networking/v1"
+	"k8s.io/client-go/tools/record"
+	"k8s.io/klog/v2"
+)
+
+// ApplyIngress will apply the Ingress to match the required object.
+func ApplyIngress(
+	ctx context.Context,
+	client networkingv1client.IngressesGetter,
+	lister networkingv1listers.IngressLister,
+	recorder record.EventRecorder,
+	required *networkingv1.Ingress,
+) (*networkingv1.Ingress, bool, error) {
+	requiredControllerRef := metav1.GetControllerOfNoCopy(required)
+	if requiredControllerRef == nil {
+		return nil, false, fmt.Errorf("ingress %q is missing controllerRef", naming.ObjRef(required))
+	}
+
+	requiredCopy := required.DeepCopy()
+	err := SetHashAnnotation(requiredCopy)
+	if err != nil {
+		return nil, false, err
+	}
+
+	existing, err := lister.Ingresses(requiredCopy.Namespace).Get(requiredCopy.Name)
+	if err != nil {
+		if !apierrors.IsNotFound(err) {
+			return nil, false, err
+		}
+
+		resourcemerge.SanitizeObject(requiredCopy)
+		actual, err := client.Ingresses(requiredCopy.Namespace).Create(ctx, requiredCopy, metav1.CreateOptions{})
+		if apierrors.IsAlreadyExists(err) {
+			klog.V(2).InfoS("Already exists (stale cache)", "Ingress", klog.KObj(requiredCopy))
+		} else {
+			ReportCreateEvent(recorder, requiredCopy, err)
+		}
+		return actual, true, err
+	}
+
+	existingControllerRef := metav1.GetControllerOfNoCopy(existing)
+	if existingControllerRef == nil || existingControllerRef.UID != requiredControllerRef.UID {
+		// This is not the place to handle adoption.
+		err := fmt.Errorf("ingress %q isn't controlled by us", naming.ObjRef(requiredCopy))
+		ReportUpdateEvent(recorder, requiredCopy, err)
+		return nil, false, err
+	}
+
+	// If they are the same do nothing.
+	if existing.Annotations[naming.ManagedHash] == requiredCopy.Annotations[naming.ManagedHash] {
+		return existing, false, nil
+	}
+
+	resourcemerge.MergeMetadataInPlace(&requiredCopy.ObjectMeta, existing.ObjectMeta)
+
+	requiredCopy.ResourceVersion = existing.ResourceVersion
+	actual, err := client.Ingresses(requiredCopy.Namespace).Update(ctx, requiredCopy, metav1.UpdateOptions{})
+	if apierrors.IsConflict(err) {
+		klog.V(2).InfoS("Hit update conflict, will retry.", "Ingress", klog.KObj(requiredCopy))
+	} else {
+		ReportUpdateEvent(recorder, requiredCopy, err)
+	}
+	if err != nil {
+		return nil, false, fmt.Errorf("can't update ingress: %w", err)
+	}
+	return actual, true, nil
+}


Test patch:
diff --git a/pkg/api/validation/cluster_validation_test.go b/pkg/api/validation/cluster_validation_test.go
index 42b105563..60946bd58 100644
--- a/pkg/api/validation/cluster_validation_test.go
+++ b/pkg/api/validation/cluster_validation_test.go
@@ -23,41 +23,25 @@ func TestValidateScyllaCluster(t *testing.T) {
 		},
 	}
 
-	sameName := validCluster.DeepCopy()
-	sameName.Spec.Datacenter.Racks = append(sameName.Spec.Datacenter.Racks, sameName.Spec.Datacenter.Racks[0])
-
-	invalidIntensity := validCluster.DeepCopy()
-	invalidIntensity.Spec.Repairs = append(invalidIntensity.Spec.Repairs, v1.RepairTaskSpec{
-		Intensity: "100Mib",
-	})
-
-	nonUniqueManagerTaskNames := validCluster.DeepCopy()
-	nonUniqueManagerTaskNames.Spec.Backups = append(nonUniqueManagerTaskNames.Spec.Backups, v1.BackupTaskSpec{
-		SchedulerTaskSpec: v1.SchedulerTaskSpec{
-			Name: "task-name",
-		},
-	})
-	nonUniqueManagerTaskNames.Spec.Repairs = append(nonUniqueManagerTaskNames.Spec.Repairs, v1.RepairTaskSpec{
-		SchedulerTaskSpec: v1.SchedulerTaskSpec{
-			Name: "task-name",
-		},
-	})
-
 	tests := []struct {
 		name                string
-		obj                 *v1.ScyllaCluster
+		cluster             *v1.ScyllaCluster
 		expectedErrorList   field.ErrorList
 		expectedErrorString string
 	}{
 		{
 			name:                "valid",
-			obj:                 validCluster,
+			cluster:             validCluster.DeepCopy(),
 			expectedErrorList:   field.ErrorList{},
 			expectedErrorString: "",
 		},
 		{
 			name: "two racks with same name",
-			obj:  sameName,
+			cluster: func() *v1.ScyllaCluster {
+				cluster := validCluster.DeepCopy()
+				cluster.Spec.Datacenter.Racks = append(cluster.Spec.Datacenter.Racks, *cluster.Spec.Datacenter.Racks[0].DeepCopy())
+				return cluster
+			}(),
 			expectedErrorList: field.ErrorList{
 				&field.Error{Type: field.ErrorTypeDuplicate, Field: "spec.datacenter.racks[1].name", BadValue: "test-rack"},
 			},
@@ -65,7 +49,13 @@ func TestValidateScyllaCluster(t *testing.T) {
 		},
 		{
 			name: "invalid intensity in repair task spec",
-			obj:  invalidIntensity,
+			cluster: func() *v1.ScyllaCluster {
+				cluster := validCluster.DeepCopy()
+				cluster.Spec.Repairs = append(cluster.Spec.Repairs, v1.RepairTaskSpec{
+					Intensity: "100Mib",
+				})
+				return cluster
+			}(),
 			expectedErrorList: field.ErrorList{
 				&field.Error{Type: field.ErrorTypeInvalid, Field: "spec.repairs[0].intensity", BadValue: "100Mib", Detail: "invalid intensity, it must be a float value"},
 			},
@@ -73,18 +63,61 @@ func TestValidateScyllaCluster(t *testing.T) {
 		},
 		{
 			name: "invalid intensity in repair task spec && non-unique names in manager tasks spec",
-			obj:  nonUniqueManagerTaskNames,
+			cluster: func() *v1.ScyllaCluster {
+				cluster := validCluster.DeepCopy()
+				cluster.Spec.Backups = append(cluster.Spec.Backups, v1.BackupTaskSpec{
+					SchedulerTaskSpec: v1.SchedulerTaskSpec{
+						Name: "task-name",
+					},
+				})
+				cluster.Spec.Repairs = append(cluster.Spec.Repairs, v1.RepairTaskSpec{
+					SchedulerTaskSpec: v1.SchedulerTaskSpec{
+						Name: "task-name",
+					},
+				})
+				return cluster
+			}(),
 			expectedErrorList: field.ErrorList{
 				&field.Error{Type: field.ErrorTypeInvalid, Field: "spec.repairs[0].intensity", BadValue: "", Detail: "invalid intensity, it must be a float value"},
 				&field.Error{Type: field.ErrorTypeDuplicate, Field: "spec.backups[0].name", BadValue: "task-name"},
 			},
 			expectedErrorString: `[spec.repairs[0].intensity: Invalid value: "": invalid intensity, it must be a float value, spec.backups[0].name: Duplicate value: "task-name"]`,
 		},
+		{
+			name: "when CQL ingress is provided, domains must not be empty",
+			cluster: func() *v1.ScyllaCluster {
+				cluster := validCluster.DeepCopy()
+				cluster.Spec.ExposeOptions = &v1.ExposeOptions{
+					CQL: &v1.CQLExposeOptions{
+						Ingress: &v1.IngressOptions{},
+					},
+				}
+
+				return cluster
+			}(),
+			expectedErrorList: field.ErrorList{
+				&field.Error{Type: field.ErrorTypeRequired, Field: "spec.dnsDomains", BadValue: "", Detail: "at least one domain needs to be provided when exposing CQL via ingresses"},
+			},
+			expectedErrorString: `spec.dnsDomains: Required value: at least one domain needs to be provided when exposing CQL via ingresses`,
+		},
+		{
+			name: "invalid domain",
+			cluster: func() *v1.ScyllaCluster {
+				cluster := validCluster.DeepCopy()
+				cluster.Spec.DNSDomains = []string{"-hello"}
+
+				return cluster
+			}(),
+			expectedErrorList: field.ErrorList{
+				&field.Error{Type: field.ErrorTypeInvalid, Field: "spec.dnsDomains[0]", BadValue: "-hello", Detail: `a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')`},
+			},
+			expectedErrorString: `spec.dnsDomains[0]: Invalid value: "-hello": a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com', regex used for validation is '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*')`,
+		},
 	}
 
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
-			errList := validation.ValidateScyllaCluster(test.obj)
+			errList := validation.ValidateScyllaCluster(test.cluster)
 			if !reflect.DeepEqual(errList, test.expectedErrorList) {
 				t.Errorf("expected and actual error lists differ: %s", cmp.Diff(test.expectedErrorList, errList))
 			}
diff --git a/pkg/controller/scyllacluster/resource_test.go b/pkg/controller/scyllacluster/resource_test.go
index 5fd0edf1a..b71df6db1 100644
--- a/pkg/controller/scyllacluster/resource_test.go
+++ b/pkg/controller/scyllacluster/resource_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/scylladb/scylla-operator/pkg/naming"
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
+	networkingv1 "k8s.io/api/networking/v1"
 	apiequality "k8s.io/apimachinery/pkg/api/equality"
 	"k8s.io/apimachinery/pkg/api/resource"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -71,6 +72,22 @@ func TestMemberService(t *testing.T) {
 			Name: "jmx-monitoring",
 			Port: 7199,
 		},
+		{
+			Name: "agent-api",
+			Port: 10001,
+		},
+		{
+			Name: "prometheus",
+			Port: 9180,
+		},
+		{
+			Name: "agent-prometheus",
+			Port: 5090,
+		},
+		{
+			Name: "node-exporter",
+			Port: 9100,
+		},
 		{
 			Name: "cql",
 			Port: 9042,
@@ -87,14 +104,6 @@ func TestMemberService(t *testing.T) {
 			Name: "cql-ssl-shard-aware",
 			Port: 19142,
 		},
-		{
-			Name: "agent-api",
-			Port: 10001,
-		},
-		{
-			Name: "node-exporter",
-			Port: 9100,
-		},
 		{
 			Name: "thrift",
 			Port: 9160,
@@ -103,7 +112,7 @@ func TestMemberService(t *testing.T) {
 
 	tt := []struct {
 		name            string
-		scyllaCLuster   *scyllav1.ScyllaCluster
+		scyllaCluster   *scyllav1.ScyllaCluster
 		rackName        string
 		svcName         string
 		oldService      *corev1.Service
@@ -111,7 +120,7 @@ func TestMemberService(t *testing.T) {
 	}{
 		{
 			name:          "new service",
-			scyllaCLuster: basicSC,
+			scyllaCluster: basicSC,
 			rackName:      basicRackName,
 			svcName:       basicSVCName,
 			oldService:    nil,
@@ -131,7 +140,7 @@ func TestMemberService(t *testing.T) {
 		},
 		{
 			name: "new service with saved IP",
-			scyllaCLuster: func() *scyllav1.ScyllaCluster {
+			scyllaCluster: func() *scyllav1.ScyllaCluster {
 				sc := basicSC.DeepCopy()
 				sc.Status.Racks[basicRackName] = scyllav1.RackStatus{
 					ReplaceAddressFirstBoot: map[string]string{
@@ -163,7 +172,7 @@ func TestMemberService(t *testing.T) {
 		},
 		{
 			name: "new service with saved IP and existing replace address",
-			scyllaCLuster: func() *scyllav1.ScyllaCluster {
+			scyllaCluster: func() *scyllav1.ScyllaCluster {
 				sc := basicSC.DeepCopy()
 				sc.Status.Racks[basicRackName] = scyllav1.RackStatus{
 					ReplaceAddressFirstBoot: map[string]string{
@@ -201,7 +210,7 @@ func TestMemberService(t *testing.T) {
 		},
 		{
 			name:          "new service with unsaved IP and existing replace address",
-			scyllaCLuster: basicSC,
+			scyllaCluster: basicSC,
 			rackName:      basicRackName,
 			svcName:       basicSVCName,
 			oldService: &corev1.Service{
@@ -231,7 +240,7 @@ func TestMemberService(t *testing.T) {
 		},
 		{
 			name:          "existing initial service",
-			scyllaCLuster: basicSC,
+			scyllaCluster: basicSC,
 			rackName:      basicRackName,
 			svcName:       basicSVCName,
 			oldService: &corev1.Service{
@@ -261,7 +270,7 @@ func TestMemberService(t *testing.T) {
 		},
 		{
 			name: "existing initial service with IP",
-			scyllaCLuster: func() *scyllav1.ScyllaCluster {
+			scyllaCluster: func() *scyllav1.ScyllaCluster {
 				sc := basicSC.DeepCopy()
 				sc.Status.Racks[basicRackName] = scyllav1.RackStatus{
 					ReplaceAddressFirstBoot: map[string]string{
@@ -299,7 +308,7 @@ func TestMemberService(t *testing.T) {
 		},
 		{
 			name:          "existing service",
-			scyllaCLuster: basicSC,
+			scyllaCluster: basicSC,
 			rackName:      basicRackName,
 			svcName:       basicSVCName,
 			oldService: &corev1.Service{
@@ -325,7 +334,7 @@ func TestMemberService(t *testing.T) {
 
 	for _, tc := range tt {
 		t.Run(tc.name, func(t *testing.T) {
-			got := MemberService(tc.scyllaCLuster, tc.rackName, tc.svcName, tc.oldService)
+			got := MemberService(tc.scyllaCluster, tc.rackName, tc.svcName, tc.oldService)
 
 			if !apiequality.Semantic.DeepEqual(got, tc.expectedService) {
 				t.Errorf("expected and actual services differ: %s", cmp.Diff(tc.expectedService, got))
@@ -925,3 +934,415 @@ func TestStatefulSetForRack(t *testing.T) {
 		})
 	}
 }
+
+func TestMakeIngresses(t *testing.T) {
+	basicScyllaCluster := &scyllav1.ScyllaCluster{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "basic",
+			UID:  "the-uid",
+		},
+		Spec: scyllav1.ScyllaClusterSpec{
+			Datacenter: scyllav1.DatacenterSpec{
+				Name: "dc",
+				Racks: []scyllav1.RackSpec{
+					{
+						Name: "rack",
+						Storage: scyllav1.StorageSpec{
+							Capacity: "1Gi",
+						},
+					},
+				},
+			},
+		},
+	}
+
+	newIdentityService := func(name string) *corev1.Service {
+		return &corev1.Service{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: name,
+				Labels: map[string]string{
+					naming.ScyllaServiceTypeLabel: string(naming.ScyllaServiceTypeIdentity),
+				},
+			},
+		}
+	}
+
+	newMemberService := func(name, hostID string) *corev1.Service {
+		return &corev1.Service{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: name,
+				Annotations: map[string]string{
+					"internal.scylla-operator.scylladb.com/host-id": hostID,
+				},
+				Labels: map[string]string{
+					"scylla-operator.scylladb.com/scylla-service-type": "member",
+				},
+			},
+		}
+	}
+
+	pathTypePrefix := networkingv1.PathTypePrefix
+
+	tt := []struct {
+		name              string
+		cluster           *scyllav1.ScyllaCluster
+		services          map[string]*corev1.Service
+		expectedIngresses []*networkingv1.Ingress
+	}{
+		{
+			name:              "no ingresses when cluster isn't exposed",
+			cluster:           basicScyllaCluster,
+			services:          map[string]*corev1.Service{},
+			expectedIngresses: nil,
+		},
+		{
+			name: "no ingresses when ingresses are explicitly disabled",
+			cluster: func() *scyllav1.ScyllaCluster {
+				cluster := basicScyllaCluster.DeepCopy()
+				cluster.Spec.DNSDomains = []string{"public.scylladb.com", "private.scylladb.com"}
+				cluster.Spec.ExposeOptions = &scyllav1.ExposeOptions{
+					CQL: &scyllav1.CQLExposeOptions{
+						Ingress: &scyllav1.IngressOptions{
+							Disabled:         pointer.Bool(true),
+							IngressClassName: "cql-ingress-class",
+							Annotations: map[string]string{
+								"my-cql-custom-annotation": "my-cql-custom-annotation-value",
+							},
+						},
+					},
+				}
+
+				return cluster
+			}(),
+			services:          map[string]*corev1.Service{},
+			expectedIngresses: nil,
+		},
+		{
+			name: "ingress objects are generated for every domain",
+			services: map[string]*corev1.Service{
+				"any":    newIdentityService("any"),
+				"node-1": newMemberService("node-1", "host-id-1"),
+				"node-2": newMemberService("node-2", "host-id-2"),
+				"node-3": newMemberService("node-3", "host-id-3"),
+			},
+			cluster: func() *scyllav1.ScyllaCluster {
+				cluster := basicScyllaCluster.DeepCopy()
+				cluster.Spec.DNSDomains = []string{"public.scylladb.com", "private.scylladb.com"}
+				cluster.Spec.ExposeOptions = &scyllav1.ExposeOptions{
+					CQL: &scyllav1.CQLExposeOptions{
+						Ingress: &scyllav1.IngressOptions{
+							IngressClassName: "cql-ingress-class",
+							Annotations: map[string]string{
+								"my-cql-custom-annotation": "my-cql-custom-annotation-value",
+							},
+						},
+					},
+				}
+
+				return cluster
+			}(),
+			expectedIngresses: []*networkingv1.Ingress{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "any-cql",
+						Labels: map[string]string{
+							"app":                          "scylla",
+							"app.kubernetes.io/name":       "scylla",
+							"app.kubernetes.io/managed-by": "scylla-operator",
+							"scylla/cluster":               "basic",
+							"scylla-operator.scylladb.com/scylla-ingress-type": "AnyNode",
+						},
+						Annotations: map[string]string{
+							"my-cql-custom-annotation": "my-cql-custom-annotation-value",
+						},
+						OwnerReferences: []metav1.OwnerReference{
+							{
+								APIVersion:         "scylla.scylladb.com/v1",
+								Kind:               "ScyllaCluster",
+								Name:               "basic",
+								UID:                "the-uid",
+								Controller:         pointer.BoolPtr(true),
+								BlockOwnerDeletion: pointer.BoolPtr(true),
+							},
+						},
+					},
+					Spec: networkingv1.IngressSpec{
+						IngressClassName: pointer.String("cql-ingress-class"),
+						Rules: []networkingv1.IngressRule{
+							{
+								Host: "any.cql.public.scylladb.com",
+								IngressRuleValue: networkingv1.IngressRuleValue{
+									HTTP: &networkingv1.HTTPIngressRuleValue{
+										Paths: []networkingv1.HTTPIngressPath{
+											{
+												Path:     "/",
+												PathType: &pathTypePrefix,
+												Backend: networkingv1.IngressBackend{
+													Service: &networkingv1.IngressServiceBackend{
+														Name: "any",
+														Port: networkingv1.ServiceBackendPort{
+															Name: "cql-ssl",
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+							{
+								Host: "any.cql.private.scylladb.com",
+								IngressRuleValue: networkingv1.IngressRuleValue{
+									HTTP: &networkingv1.HTTPIngressRuleValue{
+										Paths: []networkingv1.HTTPIngressPath{
+											{
+												Path:     "/",
+												PathType: &pathTypePrefix,
+												Backend: networkingv1.IngressBackend{
+													Service: &networkingv1.IngressServiceBackend{
+														Name: "any",
+														Port: networkingv1.ServiceBackendPort{
+															Name: "cql-ssl",
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "node-1-cql",
+						Labels: map[string]string{
+							"app":                          "scylla",
+							"app.kubernetes.io/name":       "scylla",
+							"app.kubernetes.io/managed-by": "scylla-operator",
+							"scylla/cluster":               "basic",
+							"scylla-operator.scylladb.com/scylla-ingress-type": "Node",
+						},
+						Annotations: map[string]string{
+							"my-cql-custom-annotation": "my-cql-custom-annotation-value",
+						},
+						OwnerReferences: []metav1.OwnerReference{
+							{
+								APIVersion:         "scylla.scylladb.com/v1",
+								Kind:               "ScyllaCluster",
+								Name:               "basic",
+								UID:                "the-uid",
+								Controller:         pointer.BoolPtr(true),
+								BlockOwnerDeletion: pointer.BoolPtr(true),
+							},
+						},
+					},
+					Spec: networkingv1.IngressSpec{
+						IngressClassName: pointer.String("cql-ingress-class"),
+						Rules: []networkingv1.IngressRule{
+							{
+								Host: "host-id-1.cql.public.scylladb.com",
+								IngressRuleValue: networkingv1.IngressRuleValue{
+									HTTP: &networkingv1.HTTPIngressRuleValue{
+										Paths: []networkingv1.HTTPIngressPath{
+											{
+												Path:     "/",
+												PathType: &pathTypePrefix,
+												Backend: networkingv1.IngressBackend{
+													Service: &networkingv1.IngressServiceBackend{
+														Name: "node-1",
+														Port: networkingv1.ServiceBackendPort{
+															Name: "cql-ssl",
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+							{
+								Host: "host-id-1.cql.private.scylladb.com",
+								IngressRuleValue: networkingv1.IngressRuleValue{
+									HTTP: &networkingv1.HTTPIngressRuleValue{
+										Paths: []networkingv1.HTTPIngressPath{
+											{
+												Path:     "/",
+												PathType: &pathTypePrefix,
+												Backend: networkingv1.IngressBackend{
+													Service: &networkingv1.IngressServiceBackend{
+														Name: "node-1",
+														Port: networkingv1.ServiceBackendPort{
+															Name: "cql-ssl",
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "node-2-cql",
+						Labels: map[string]string{
+							"app":                          "scylla",
+							"app.kubernetes.io/name":       "scylla",
+							"app.kubernetes.io/managed-by": "scylla-operator",
+							"scylla/cluster":               "basic",
+							"scylla-operator.scylladb.com/scylla-ingress-type": "Node",
+						},
+						Annotations: map[string]string{
+							"my-cql-custom-annotation": "my-cql-custom-annotation-value",
+						},
+						OwnerReferences: []metav1.OwnerReference{
+							{
+								APIVersion:         "scylla.scylladb.com/v1",
+								Kind:               "ScyllaCluster",
+								Name:               "basic",
+								UID:                "the-uid",
+								Controller:         pointer.BoolPtr(true),
+								BlockOwnerDeletion: pointer.BoolPtr(true),
+							},
+						},
+					},
+					Spec: networkingv1.IngressSpec{
+						IngressClassName: pointer.String("cql-ingress-class"),
+						Rules: []networkingv1.IngressRule{
+							{
+								Host: "host-id-2.cql.public.scylladb.com",
+								IngressRuleValue: networkingv1.IngressRuleValue{
+									HTTP: &networkingv1.HTTPIngressRuleValue{
+										Paths: []networkingv1.HTTPIngressPath{
+											{
+												Path:     "/",
+												PathType: &pathTypePrefix,
+												Backend: networkingv1.IngressBackend{
+													Service: &networkingv1.IngressServiceBackend{
+														Name: "node-2",
+														Port: networkingv1.ServiceBackendPort{
+															Name: "cql-ssl",
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+							{
+								Host: "host-id-2.cql.private.scylladb.com",
+								IngressRuleValue: networkingv1.IngressRuleValue{
+									HTTP: &networkingv1.HTTPIngressRuleValue{
+										Paths: []networkingv1.HTTPIngressPath{
+											{
+												Path:     "/",
+												PathType: &pathTypePrefix,
+												Backend: networkingv1.IngressBackend{
+													Service: &networkingv1.IngressServiceBackend{
+														Name: "node-2",
+														Port: networkingv1.ServiceBackendPort{
+															Name: "cql-ssl",
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "node-3-cql",
+						Labels: map[string]string{
+							"app":                          "scylla",
+							"app.kubernetes.io/name":       "scylla",
+							"app.kubernetes.io/managed-by": "scylla-operator",
+							"scylla/cluster":               "basic",
+							"scylla-operator.scylladb.com/scylla-ingress-type": "Node",
+						},
+						Annotations: map[string]string{
+							"my-cql-custom-annotation": "my-cql-custom-annotation-value",
+						},
+						OwnerReferences: []metav1.OwnerReference{
+							{
+								APIVersion:         "scylla.scylladb.com/v1",
+								Kind:               "ScyllaCluster",
+								Name:               "basic",
+								UID:                "the-uid",
+								Controller:         pointer.BoolPtr(true),
+								BlockOwnerDeletion: pointer.BoolPtr(true),
+							},
+						},
+					},
+					Spec: networkingv1.IngressSpec{
+						IngressClassName: pointer.String("cql-ingress-class"),
+						Rules: []networkingv1.IngressRule{
+							{
+								Host: "host-id-3.cql.public.scylladb.com",
+								IngressRuleValue: networkingv1.IngressRuleValue{
+									HTTP: &networkingv1.HTTPIngressRuleValue{
+										Paths: []networkingv1.HTTPIngressPath{
+											{
+												Path:     "/",
+												PathType: &pathTypePrefix,
+												Backend: networkingv1.IngressBackend{
+													Service: &networkingv1.IngressServiceBackend{
+														Name: "node-3",
+														Port: networkingv1.ServiceBackendPort{
+															Name: "cql-ssl",
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+							{
+								Host: "host-id-3.cql.private.scylladb.com",
+								IngressRuleValue: networkingv1.IngressRuleValue{
+									HTTP: &networkingv1.HTTPIngressRuleValue{
+										Paths: []networkingv1.HTTPIngressPath{
+											{
+												Path:     "/",
+												PathType: &pathTypePrefix,
+												Backend: networkingv1.IngressBackend{
+													Service: &networkingv1.IngressServiceBackend{
+														Name: "node-3",
+														Port: networkingv1.ServiceBackendPort{
+															Name: "cql-ssl",
+														},
+													},
+												},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for i := range tt {
+		tc := tt[i]
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			got := MakeIngresses(tc.cluster, tc.services)
+			if !apiequality.Semantic.DeepEqual(got, tc.expectedIngresses) {
+				t.Errorf("expected and actual Ingresses differ: %s", cmp.Diff(tc.expectedIngresses, got))
+			}
+		})
+	}
+}
diff --git a/pkg/resourceapply/networking_test.go b/pkg/resourceapply/networking_test.go
new file mode 100644
index 000000000..2f1a1d824
--- /dev/null
+++ b/pkg/resourceapply/networking_test.go
@@ -0,0 +1,493 @@
+package resourceapply
+
+import (
+	"context"
+	"fmt"
+	"reflect"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	networkingv1 "k8s.io/api/networking/v1"
+	"k8s.io/apimachinery/pkg/api/equality"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/runtime"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	"k8s.io/client-go/kubernetes/fake"
+	networkingv1listers "k8s.io/client-go/listers/networking/v1"
+	"k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/tools/record"
+	"k8s.io/utils/pointer"
+)
+
+func TestApplyIngress(t *testing.T) {
+	// Using a generating function prevents unwanted mutations.
+	newIngress := func() *networkingv1.Ingress {
+		return &networkingv1.Ingress{
+			ObjectMeta: metav1.ObjectMeta{
+				Namespace: "default",
+				Name:      "test",
+				Labels:    map[string]string{},
+				OwnerReferences: []metav1.OwnerReference{
+					{
+						Controller:         pointer.BoolPtr(true),
+						UID:                "abcdefgh",
+						APIVersion:         "scylla.scylladb.com/v1",
+						Kind:               "ScyllaCluster",
+						Name:               "basic",
+						BlockOwnerDeletion: pointer.BoolPtr(true),
+					},
+				},
+			},
+			Spec: networkingv1.IngressSpec{
+				DefaultBackend: &networkingv1.IngressBackend{
+					Service: &networkingv1.IngressServiceBackend{
+						Name: "some-service",
+						Port: networkingv1.ServiceBackendPort{
+							Number: 443,
+						},
+					},
+				},
+			},
+		}
+	}
+
+	newIngressWithHash := func() *networkingv1.Ingress {
+		ingress := newIngress()
+		utilruntime.Must(SetHashAnnotation(ingress))
+		return ingress
+	}
+
+	tt := []struct {
+		name            string
+		existing        []runtime.Object
+		cache           []runtime.Object // nil cache means autofill from the client
+		required        *networkingv1.Ingress
+		expectedIngress *networkingv1.Ingress
+		expectedChanged bool
+		expectedErr     error
+		expectedEvents  []string
+	}{
+		{
+			name:            "creates a new ingress when there is none",
+			existing:        nil,
+			required:        newIngress(),
+			expectedIngress: newIngressWithHash(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal IngressCreated Ingress default/test created"},
+		},
+		{
+			name: "does nothing if the same ingress already exists",
+			existing: []runtime.Object{
+				newIngressWithHash(),
+			},
+			required:        newIngress(),
+			expectedIngress: newIngressWithHash(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "does nothing if the same ingress already exists and required one has the hash",
+			existing: []runtime.Object{
+				newIngressWithHash(),
+			},
+			required:        newIngressWithHash(),
+			expectedIngress: newIngressWithHash(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "updates the ingress if it exists without the hash",
+			existing: []runtime.Object{
+				newIngress(),
+			},
+			required:        newIngress(),
+			expectedIngress: newIngressWithHash(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal IngressUpdated Ingress default/test updated"},
+		},
+		{
+			name:     "fails to create the ingress without a controllerRef",
+			existing: nil,
+			required: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.OwnerReferences = nil
+				return ingress
+			}(),
+			expectedIngress: nil,
+			expectedChanged: false,
+			expectedErr:     fmt.Errorf(`ingress "default/test" is missing controllerRef`),
+			expectedEvents:  nil,
+		},
+		{
+			name: "updates the ingress default backend differs",
+			existing: []runtime.Object{
+				newIngress(),
+			},
+			required: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.Spec.DefaultBackend.Service.Port.Number = 42
+				return ingress
+			}(),
+			expectedIngress: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.Spec.DefaultBackend.Service.Port.Number = 42
+				utilruntime.Must(SetHashAnnotation(ingress))
+				return ingress
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal IngressUpdated Ingress default/test updated"},
+		},
+		{
+			name: "updates the ingress if labels differ",
+			existing: []runtime.Object{
+				newIngressWithHash(),
+			},
+			required: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.Labels["foo"] = "bar"
+				return ingress
+			}(),
+			expectedIngress: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.Labels["foo"] = "bar"
+				utilruntime.Must(SetHashAnnotation(ingress))
+				return ingress
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal IngressUpdated Ingress default/test updated"},
+		},
+		{
+			name: "won't update the ingress if an admission changes the sts",
+			existing: []runtime.Object{
+				func() *networkingv1.Ingress {
+					ingress := newIngressWithHash()
+					// Simulate admission by changing a value after the hash is computed.
+					ingress.Spec.DefaultBackend.Service.Port.Number = 42
+					return ingress
+				}(),
+			},
+			required: newIngress(),
+			expectedIngress: func() *networkingv1.Ingress {
+				ingress := newIngressWithHash()
+				// Simulate admission by changing a value after the hash is computed.
+				ingress.Spec.DefaultBackend.Service.Port.Number = 42
+				return ingress
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			// We test propagating the RV from required in all the other tests.
+			name: "specifying no RV will use the one from the existing object",
+			existing: []runtime.Object{
+				func() *networkingv1.Ingress {
+					ingress := newIngressWithHash()
+					ingress.ResourceVersion = "21"
+					return ingress
+				}(),
+			},
+			required: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.ResourceVersion = ""
+				ingress.Labels["foo"] = "bar"
+				return ingress
+			}(),
+			expectedIngress: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.ResourceVersion = "21"
+				ingress.Labels["foo"] = "bar"
+				utilruntime.Must(SetHashAnnotation(ingress))
+				return ingress
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal IngressUpdated Ingress default/test updated"},
+		},
+		{
+			name:     "update fails if the ingress is missing but we still see it in the cache",
+			existing: nil,
+			cache: []runtime.Object{
+				newIngressWithHash(),
+			},
+			required: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.Labels["foo"] = "bar"
+				return ingress
+			}(),
+			expectedIngress: nil,
+			expectedChanged: false,
+			expectedErr:     fmt.Errorf("can't update ingress: %w", apierrors.NewNotFound(networkingv1.Resource("ingresses"), "test")),
+			expectedEvents:  []string{`Warning UpdateIngressFailed Failed to update Ingress default/test: ingresses.networking.k8s.io "test" not found`},
+		},
+		{
+			name: "update fails if the existing object has no ownerRef",
+			existing: []runtime.Object{
+				func() *networkingv1.Ingress {
+					ingress := newIngress()
+					ingress.OwnerReferences = nil
+					utilruntime.Must(SetHashAnnotation(ingress))
+					return ingress
+				}(),
+			},
+			required: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.Labels["foo"] = "bar"
+				return ingress
+			}(),
+			expectedIngress: nil,
+			expectedChanged: false,
+			expectedErr:     fmt.Errorf(`ingress "default/test" isn't controlled by us`),
+			expectedEvents:  []string{`Warning UpdateIngressFailed Failed to update Ingress default/test: ingress "default/test" isn't controlled by us`},
+		},
+		{
+			name: "update fails if the existing object is owned by someone else",
+			existing: []runtime.Object{
+				func() *networkingv1.Ingress {
+					ingress := newIngress()
+					ingress.OwnerReferences[0].UID = "42"
+					utilruntime.Must(SetHashAnnotation(ingress))
+					return ingress
+				}(),
+			},
+			required: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.Labels["foo"] = "bar"
+				return ingress
+			}(),
+			expectedIngress: nil,
+			expectedChanged: false,
+			expectedErr:     fmt.Errorf(`ingress "default/test" isn't controlled by us`),
+			expectedEvents:  []string{`Warning UpdateIngressFailed Failed to update Ingress default/test: ingress "default/test" isn't controlled by us`},
+		},
+		{
+			name: "all label and annotation keys are kept when the hash matches",
+			existing: []runtime.Object{
+				func() *networkingv1.Ingress {
+					ingress := newIngress()
+					ingress.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "",
+					}
+					ingress.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "",
+					}
+					utilruntime.Must(SetHashAnnotation(ingress))
+					ingress.Annotations["a-1"] = "a-alpha-changed"
+					ingress.Annotations["a-3"] = "a-resurrected"
+					ingress.Annotations["a-custom"] = "custom-value"
+					ingress.Labels["l-1"] = "l-alpha-changed"
+					ingress.Labels["l-3"] = "l-resurrected"
+					ingress.Labels["l-custom"] = "custom-value"
+					return ingress
+				}(),
+			},
+			required: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				ingress.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				return ingress
+			}(),
+			expectedIngress: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.Annotations = map[string]string{
+					"a-1":  "a-alpha",
+					"a-2":  "a-beta",
+					"a-3-": "",
+				}
+				ingress.Labels = map[string]string{
+					"l-1":  "l-alpha",
+					"l-2":  "l-beta",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(ingress))
+				ingress.Annotations["a-1"] = "a-alpha-changed"
+				ingress.Annotations["a-3"] = "a-resurrected"
+				ingress.Annotations["a-custom"] = "custom-value"
+				ingress.Labels["l-1"] = "l-alpha-changed"
+				ingress.Labels["l-3"] = "l-resurrected"
+				ingress.Labels["l-custom"] = "custom-value"
+				return ingress
+			}(),
+			expectedChanged: false,
+			expectedErr:     nil,
+			expectedEvents:  nil,
+		},
+		{
+			name: "only managed label and annotation keys are updated when the hash changes",
+			existing: []runtime.Object{
+				func() *networkingv1.Ingress {
+					ingress := newIngress()
+					ingress.Annotations = map[string]string{
+						"a-1":  "a-alpha",
+						"a-2":  "a-beta",
+						"a-3-": "a-resurrected",
+					}
+					ingress.Labels = map[string]string{
+						"l-1":  "l-alpha",
+						"l-2":  "l-beta",
+						"l-3-": "l-resurrected",
+					}
+					utilruntime.Must(SetHashAnnotation(ingress))
+					ingress.Annotations["a-1"] = "a-alpha-changed"
+					ingress.Annotations["a-custom"] = "a-custom-value"
+					ingress.Labels["l-1"] = "l-alpha-changed"
+					ingress.Labels["l-custom"] = "l-custom-value"
+					return ingress
+				}(),
+			},
+			required: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				ingress.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				return ingress
+			}(),
+			expectedIngress: func() *networkingv1.Ingress {
+				ingress := newIngress()
+				ingress.Annotations = map[string]string{
+					"a-1":  "a-alpha-x",
+					"a-2":  "a-beta-x",
+					"a-3-": "",
+				}
+				ingress.Labels = map[string]string{
+					"l-1":  "l-alpha-x",
+					"l-2":  "l-beta-x",
+					"l-3-": "",
+				}
+				utilruntime.Must(SetHashAnnotation(ingress))
+				delete(ingress.Annotations, "a-3-")
+				ingress.Annotations["a-custom"] = "a-custom-value"
+				delete(ingress.Labels, "l-3-")
+				ingress.Labels["l-custom"] = "l-custom-value"
+				return ingress
+			}(),
+			expectedChanged: true,
+			expectedErr:     nil,
+			expectedEvents:  []string{"Normal IngressUpdated Ingress default/test updated"},
+		},
+	}
+
+	for _, tc := range tt {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			// Client holds the state so it has to persists the iterations.
+			client := fake.NewSimpleClientset(tc.existing...)
+
+			// ApplyIngress needs to be reentrant so running it the second time should give the same results.
+			// (One of the common mistakes is editing the object after computing the hash so it differs the second time.)
+			iterations := 2
+			if tc.expectedErr != nil {
+				iterations = 1
+			}
+			for i := 0; i < iterations; i++ {
+				t.Run("", func(t *testing.T) {
+					ctx, ctxCancel := context.WithTimeout(context.Background(), 30*time.Second)
+					defer ctxCancel()
+
+					recorder := record.NewFakeRecorder(10)
+
+					ingressCache := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc})
+					ingressLister := networkingv1listers.NewIngressLister(ingressCache)
+
+					if tc.cache != nil {
+						for _, obj := range tc.cache {
+							err := ingressCache.Add(obj)
+							if err != nil {
+								t.Fatal(err)
+							}
+						}
+					} else {
+						ingressList, err := client.NetworkingV1().Ingresses("").List(ctx, metav1.ListOptions{
+							LabelSelector: labels.Everything().String(),
+						})
+						if err != nil {
+							t.Fatal(err)
+						}
+
+						for i := range ingressList.Items {
+							err := ingressCache.Add(&ingressList.Items[i])
+							if err != nil {
+								t.Fatal(err)
+							}
+						}
+					}
+
+					gotObj, gotChanged, gotErr := ApplyIngress(ctx, client.NetworkingV1(), ingressLister, recorder, tc.required)
+					if !reflect.DeepEqual(gotErr, tc.expectedErr) {
+						t.Fatalf("expected %v, got %v", tc.expectedErr, gotErr)
+					}
+
+					if !equality.Semantic.DeepEqual(gotObj, tc.expectedIngress) {
+						t.Errorf("expected %#v, got %#v, diff:\n%s", tc.expectedIngress, gotObj, cmp.Diff(tc.expectedIngress, gotObj))
+					}
+
+					// Make sure such object was actually created.
+					if gotObj != nil {
+						createdSts, err := client.NetworkingV1().Ingresses(gotObj.Namespace).Get(ctx, gotObj.Name, metav1.GetOptions{})
+						if err != nil {
+							t.Error(err)
+						}
+						if !equality.Semantic.DeepEqual(createdSts, gotObj) {
+							t.Errorf("created and returned pdbs differ:\n%s", cmp.Diff(createdSts, gotObj))
+						}
+					}
+
+					if i == 0 {
+						if gotChanged != tc.expectedChanged {
+							t.Errorf("expected %t, got %t", tc.expectedChanged, gotChanged)
+						}
+					} else {
+						if gotChanged {
+							t.Errorf("object changed in iteration %d", i)
+						}
+					}
+
+					close(recorder.Events)
+					var gotEvents []string
+					for e := range recorder.Events {
+						gotEvents = append(gotEvents, e)
+					}
+					if i == 0 {
+						if !reflect.DeepEqual(gotEvents, tc.expectedEvents) {
+							t.Errorf("expected %v, got %v, diff:\n%s", tc.expectedEvents, gotEvents, cmp.Diff(tc.expectedEvents, gotEvents))
+						}
+					} else {
+						if len(gotEvents) > 0 {
+							t.Errorf("unexpected events: %v", gotEvents)
+						}
+					}
+				})
+			}
+		})
+	}
+}
diff --git a/test/e2e/set/scyllacluster/scyllacluster_expose.go b/test/e2e/set/scyllacluster/scyllacluster_expose.go
new file mode 100644
index 000000000..2d0595870
--- /dev/null
+++ b/test/e2e/set/scyllacluster/scyllacluster_expose.go
@@ -0,0 +1,152 @@
+// Copyright (c) 2022 ScyllaDB
+
+package scyllacluster
+
+import (
+	"context"
+	"fmt"
+
+	g "github.com/onsi/ginkgo/v2"
+	o "github.com/onsi/gomega"
+	scyllav1 "github.com/scylladb/scylla-operator/pkg/api/scylla/v1"
+	"github.com/scylladb/scylla-operator/pkg/naming"
+	scyllafixture "github.com/scylladb/scylla-operator/test/e2e/fixture/scylla"
+	"github.com/scylladb/scylla-operator/test/e2e/framework"
+	"github.com/scylladb/scylla-operator/test/e2e/utils"
+	corev1 "k8s.io/api/core/v1"
+	networkingv1 "k8s.io/api/networking/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
+)
+
+var _ = g.Describe("ScyllaCluster Ingress", func() {
+	defer g.GinkgoRecover()
+
+	f := framework.NewFramework("scyllacluster")
+
+	g.It("should create ingress objects when ingress exposeOptions are provided", func() {
+		ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
+		defer cancel()
+
+		sc := scyllafixture.BasicScyllaCluster.ReadOrFail()
+		sc.Spec.Datacenter.Racks[0].Members = 1
+		sc.Spec.DNSDomains = []string{"private.nodes.scylladb.com", "public.nodes.scylladb.com"}
+		sc.Spec.ExposeOptions = &scyllav1.ExposeOptions{
+			CQL: &scyllav1.CQLExposeOptions{
+				Ingress: &scyllav1.IngressOptions{
+					IngressClassName: "my-cql-ingress-class",
+					Annotations: map[string]string{
+						"my-cql-annotation-key": "my-cql-annotation-value",
+					},
+				},
+			},
+		}
+
+		framework.By("Creating a ScyllaCluster")
+		sc, err := f.ScyllaClient().ScyllaV1().ScyllaClusters(f.Namespace()).Create(ctx, sc, metav1.CreateOptions{})
+		o.Expect(err).NotTo(o.HaveOccurred())
+
+		// TODO: In theory Ingresses may not be created when ScyllaCluster is rolled out making this test flaky.
+		//       Wait for condition when it's available.
+		framework.By("Waiting for ScyllaCluster to deploy")
+		waitCtx, waitCtxCancel := utils.ContextForRollout(ctx, sc)
+		defer waitCtxCancel()
+
+		sc, err = utils.WaitForScyllaClusterState(waitCtx, f.ScyllaClient().ScyllaV1(), sc.Namespace, sc.Name, utils.IsScyllaClusterRolledOut)
+		o.Expect(err).NotTo(o.HaveOccurred())
+
+		verifyScyllaCluster(ctx, f.KubeClient(), sc, nil)
+
+		framework.By("Verifying AnyNode Ingresses")
+		services, err := f.KubeClient().CoreV1().Services(sc.Namespace).List(ctx, metav1.ListOptions{
+			LabelSelector: labels.SelectorFromSet(map[string]string{
+				naming.ScyllaServiceTypeLabel: string(naming.ScyllaServiceTypeIdentity),
+			}).String(),
+		})
+		o.Expect(err).NotTo(o.HaveOccurred())
+		o.Expect(services.Items).To(o.HaveLen(1))
+
+		ingresses, err := f.KubeClient().NetworkingV1().Ingresses(sc.Namespace).List(ctx, metav1.ListOptions{
+			LabelSelector: labels.SelectorFromSet(map[string]string{
+				naming.ScyllaIngressTypeLabel: string(naming.ScyllaIngressTypeAnyNode),
+			}).String(),
+		})
+		o.Expect(err).NotTo(o.HaveOccurred())
+		o.Expect(ingresses.Items).To(o.HaveLen(1))
+
+		verifyIngress(
+			&ingresses.Items[0],
+			fmt.Sprintf("%s-cql", services.Items[0].Name),
+			sc.Spec.ExposeOptions.CQL.Ingress.Annotations,
+			sc.Spec.ExposeOptions.CQL.Ingress.IngressClassName,
+			&services.Items[0],
+			[]string{
+				"any.cql.private.nodes.scylladb.com",
+				"any.cql.public.nodes.scylladb.com",
+			},
+			"cql-ssl",
+		)
+
+		framework.By("Verifying Node Ingresses")
+		services, err = f.KubeClient().CoreV1().Services(sc.Namespace).List(ctx, metav1.ListOptions{
+			LabelSelector: labels.SelectorFromSet(map[string]string{
+				naming.ScyllaServiceTypeLabel: string(naming.ScyllaServiceTypeMember),
+			}).String(),
+		})
+		o.Expect(err).NotTo(o.HaveOccurred())
+		o.Expect(services.Items).To(o.HaveLen(1))
+
+		var nodeHostIDs []string
+		for _, svc := range services.Items {
+			o.Expect(svc.Annotations).To(o.HaveKey(naming.HostIDAnnotation))
+			nodeHostIDs = append(nodeHostIDs, svc.Annotations[naming.HostIDAnnotation])
+		}
+
+		ingresses, err = f.KubeClient().NetworkingV1().Ingresses(sc.Namespace).List(ctx, metav1.ListOptions{
+			LabelSelector: labels.SelectorFromSet(map[string]string{
+				naming.ScyllaIngressTypeLabel: string(naming.ScyllaIngressTypeNode),
+			}).String(),
+		})
+		o.Expect(err).NotTo(o.HaveOccurred())
+		o.Expect(ingresses.Items).To(o.HaveLen(1))
+
+		verifyIngress(
+			&ingresses.Items[0],
+			fmt.Sprintf("%s-cql", services.Items[0].Name),
+			sc.Spec.ExposeOptions.CQL.Ingress.Annotations,
+			sc.Spec.ExposeOptions.CQL.Ingress.IngressClassName,
+			&services.Items[0],
+			[]string{
+				fmt.Sprintf("%s.cql.private.nodes.scylladb.com", nodeHostIDs[0]),
+				fmt.Sprintf("%s.cql.public.nodes.scylladb.com", nodeHostIDs[0]),
+			},
+			"cql-ssl",
+		)
+
+		// TODO: extend the test with a connection to ScyllaCluster via these Ingresses.
+		//       Ref: https://github.com/scylladb/scylla-operator/issues/1015
+	})
+})
+
+func verifyIngress(ingress *networkingv1.Ingress, name string, annotations map[string]string, className string, service *corev1.Service, hosts []string, servicePort string) {
+	o.Expect(ingress.Name).To(o.Equal(name))
+	for k, v := range annotations {
+		o.Expect(ingress.Annotations).To(o.HaveKeyWithValue(k, v))
+	}
+
+	o.Expect(ingress.Spec.IngressClassName).ToNot(o.BeNil())
+	o.Expect(*ingress.Spec.IngressClassName).To(o.Equal(className))
+	o.Expect(ingress.Spec.Rules).To(o.HaveLen(len(hosts)))
+	for i := range hosts {
+		o.Expect(ingress.Spec.Rules[i].Host).To(o.Equal(hosts[i]))
+	}
+	for _, rule := range ingress.Spec.Rules {
+		o.Expect(rule.HTTP.Paths).To(o.HaveLen(1))
+		o.Expect(rule.HTTP.Paths[0].Path).To(o.Equal("/"))
+		o.Expect(rule.HTTP.Paths[0].PathType).ToNot(o.BeNil())
+		o.Expect(*rule.HTTP.Paths[0].PathType).To(o.Equal(networkingv1.PathTypePrefix))
+		o.Expect(rule.HTTP.Paths[0].Backend.Service).ToNot(o.BeNil())
+		o.Expect(rule.HTTP.Paths[0].Backend.Service.Name).To(o.Equal(service.Name))
+		o.Expect(rule.HTTP.Paths[0].Backend.Service.Port.Name).To(o.Equal(servicePort))
+	}
+}