1. Back up a filtered subset, never a whole namespace. Your headline command is velero backup create wave1-web-20260816 –include-namespaces wave1-web –include-resources deployments,services,configmaps,secrets,ingresses –snapshot-volumes=false.
2. A restore that reports Completed tells you the objects landed. It says nothing about whether a single pod will start.
3. Admission is the whole game. Expect forbidden: unable to validate against any security context constraint on your first attempt and treat it as normal, not as a defect.
4. Restoring an Ingress makes OpenShift generate a managed Route automatically, and that Route inherits the hostname still pointing at your NSX-T load balancer. Build a second Route on a test hostname and cut DNS only after it serves.
5. Rollback is free at this stage. Nothing on TKGI is modified, so backing out is one oc delete project.
Migrating a stateless application is not a data problem, and treating it as one is why first attempts take a full day instead of forty minutes. There is nothing to copy except a few megabytes of YAML. Every hour you will lose goes to three things: objects you should never have carried across, an admission controller that rejects images which ran unquestioned on TKGI for years, and a hostname that resolves to the wrong load balancer. Velero and OADP, which stands for OpenShift API for Data Protection and is simply Velero packaged as an Operator, are the easy part of this Part.
My reference migration uses wave1-web: three Deployments, eleven pods, no persistent volume claims, an NSX-T backed LoadBalancer Service, and one Ingress serving a public hostname. It is deliberately the second namespace I moved, not the first, because wave1-api from Part 17 had no ingress path and taught me almost nothing about cutover.
Preflight and What Counts as Stateless
Stateless in a migration context is narrower than most teams assume. No PersistentVolumeClaim is necessary but not sufficient. A Deployment that writes to an emptyDir cache and rebuilds it on start is stateless. A Deployment that writes session data to local disk and expects it back is not, and it will pass every check below and then fail in production when a user gets bounced to a different replica. Ask the application owner one question before you touch anything: if I delete every pod right now, what is lost.
Pin your versions before the first command, because Velero only tests one minor version of skew and OADP moves on its own cadence.
That last command is worth more than the rest of the preflight combined. Two of my three Deployments carry a hardcoded UID, one of them root. On TKGI, with pod admission effectively switched off, nobody ever noticed. On OpenShift both will be rejected, and knowing that before you start converts a mysterious outage into a planned ten minute edit. Part 7 covers why the restricted-v2 Security Context Constraint behaves this way, so I will not repeat it here.
Part 7, Security Context Constraints and Pod Admission is the prerequisite reading for everything in step 3 below.
Step 1, Backing Up wave1-web on TKGI
Almost every tutorial you will find reaches for –include-namespaces and stops there. On a TKGI source that is the wrong default, and it is the single thing in this Part that contradicts the common advice most directly. A whole namespace backup drags across ServiceAccounts bound to UAA identities that do not exist on OpenShift, image pull Secrets for a registry you are about to re-point, Services carrying an allocated ClusterIP from a Service CIDR OpenShift does not use, NSX-T annotations that OVN-Kubernetes will ignore, and an Ingress whose host names a load balancer you plan to switch off. You then spend an afternoon deleting objects you deliberately created.
Restore what the application owns. Rebuild what the platform owns. That distinction is the reference artifact of this Part, and it is the table I still paste into every migration ticket.
| Object | Restore or rebuild | Why |
|---|---|---|
| Deployment, ReplicaSet owner | Restore | Application owned. Expect to patch securityContext after landing. |
| ConfigMap | Restore | Application owned. Grep it for old cluster hostnames before you trust it. |
| Secret, application type Opaque | Restore | Application owned, and Part 21 covers moving these properly. |
| Secret, type kubernetes.io/service-account-token or dockercfg | Rebuild | Bound to a TKGI ServiceAccount and a TKGI issuer. Worthless on OpenShift. |
| ServiceAccount | Rebuild | OpenShift creates its own, and SCC grants attach to it. Restoring one confuses both. |
| Service, ClusterIP type | Restore | Velero strips the allocated ClusterIP for you. Headless Services keep None correctly. |
| Service, LoadBalancer type | Rebuild as ClusterIP | NCP provisioned the NSX-T virtual server. On OpenShift a Route replaces it. |
| Ingress | Rebuild as Route | Restoring it auto generates a Route on the old public hostname. See step 4. |
| NetworkPolicy | Rebuild | Part 16 already wrote the OVN-Kubernetes equivalents. Do not double up. |
| ResourceQuota, LimitRange | Rebuild | Part 14 set project level quotas. A restored quota silently overrides them. |
With that decided, the backup command writes itself.
Forty one seconds and thirty one objects. An unfiltered backup of the same namespace produced two hundred and nine objects, most of them endpoint slices and token Secrets that would have been noise at best and actively harmful at worst. Note –snapshot-volumes=false: there is nothing to snapshot, and leaving the default on wastes a CSI round trip against vSphere that can hang for minutes if the source storage provider is unhappy.
Step 2, Preparing an OpenShift Project That Will Accept the Pods
Create the project before the restore, not during it. If Velero creates the namespace it will do so without the annotations OpenShift relies on to allocate a UID range, and you will chase a much stranger class of failure. A project made by oc new-project gets its range immediately.
Read that UID range and write it down: 1001120000/10000. Any pod in this project that asks for a specific user outside 1001120000 through 1001129999 will be rejected, and my web Deployment asks for 1001. Two of these values are now on a collision course, and neither Velero nor OADP will warn you.
Step 3, Restoring with OADP, Then Fixing Admission
I use a Restore custom resource rather than the Velero CLI on the OpenShift side, for one practical reason: the file goes into git, and the next twenty five namespaces are a copy with one name changed. I also prefer oc over kubectl throughout the OpenShift half of this series, because oc understands Routes, Projects, SCC and oc adm policy, and dropping to kubectl for those means hand writing RBAC that one subcommand does correctly.
Phase Completed. Zero errors, zero warnings, zero running pods. This is the moment that catches people, and it is not a bug in either tool. Velero restored eleven object definitions faithfully; OpenShift admission then declined to create the pods those definitions asked for. Both statements are true simultaneously.
Here is what that learning curve looked like in wall clock across my first four namespaces. Note that it got worse before it got better, because namespace two was the one with a hardcoded UID and a registry certificate problem stacked on top of each other.
Step 4, Ingress to Route and DNS Cutover
OpenShift automatically creates a managed Route object whenever an Ingress object is created, and deletes it again when the Ingress goes away. That behaviour is convenient in normal operation and dangerous during a migration, because the generated Route copies the host field verbatim. My restored Ingress named shop.example.com, a hostname that still resolves to an NSX-T virtual server carrying live production traffic. OpenShift now claims it too, and whichever answer DNS gives is the one your users get.
flowchart TD A[Ingress restored into project] --> B[OpenShift generates a managed Route] B --> C[Generated Route claims the live production hostname] C --> D[Create a second Route on a test hostname] D --> E[Validate through the OpenShift ingress controller] E --> F[Switch the DNS record to the OpenShift VIP] F --> G[Delete the restored Ingress and its managed Route] E --> H[Fallback, leave DNS pointing at NSX and investigate]
That Harbor certificate patch deserves its own warning. It is one line, it looks harmless, and it triggers a Machine Config rollout that drains and restarts every node in the cluster one at a time. On my six node cluster that took roughly forty minutes, during which the other pilot namespace I was mid way through migrating kept getting evicted. Do the registry trust configuration once, on day zero, before any workload arrives. Part 15 covers registry setup properly, and I should have finished it before starting this.
Rollback deserves a sentence of its own too, because it is genuinely easy here and it will not be in Part 19, where persistent volumes arrive. Until DNS moves, TKGI is still serving every request. Deleting the OpenShift project costs you nothing except the twenty minutes you spent on it. Take advantage of that while it lasts.
Verification, Rollback and Common Failures
Green means four things, and replica counts are only the first. Every Deployment reports its full replica count Ready. Every pod has restarted zero times after the first sixty seconds, which rules out a crash loop that replica count alone will hide for a while. Your test Route returns the same status code and a comparable response time as the production hostname. And oc get events in the project is empty of Warning entries. If any one of those four is off, do not touch DNS.
Six failures cover nearly everything I hit moving nine stateless workloads. Keep this next to the runbook.
| Symptom | Error you will see | Remediation |
|---|---|---|
| Restore Completed, Deployment stuck at 0 replicas | forbidden: unable to validate against any security context constraint: [provider restricted-v2: .spec.securityContext.runAsUser: Invalid value: 1001] | Remove runAsUser from the pod template. Grant nonroot-v2 to a dedicated ServiceAccount only if the image truly needs a fixed UID. |
| Pods created but ImagePullBackOff | x509: certificate signed by unknown authority | ConfigMap of the Harbor CA in openshift-config, referenced from spec.additionalTrustedCA. Expect a rolling node restart. |
| Pods created but ImagePullBackOff with a 401 | unauthorized: authentication required | The restored dockercfg Secret is a TKGI artifact. Rebuild it and link it to the project default ServiceAccount for pull. |
| Restore PartiallyFailed on the Service | Service is invalid: spec.ports[0].nodePort: Invalid value: 31940: provided port is already allocated | Exclude the LoadBalancer Service from the backup and recreate it as ClusterIP behind a Route. |
| Two Routes claim the same hostname | HostAlreadyClaimed | Delete the restored Ingress, which removes its generated Route through the owner reference. Keep only the Route you wrote. |
| Application starts, then fails on its first outbound call | dial tcp: lookup web-bff.wave1-web.svc.cluster.local: no such host | A ConfigMap carried a fully qualified TKGI service name. Grep every restored ConfigMap for the old cluster domain before you declare success. |
One more verification step earns its keep, and it is embarrassingly low tech. Before DNS moves, grep every restored ConfigMap for three strings: your old pod CIDR, your old cluster DNS domain, and your old registry hostname. A loop over oc -n wave1-web get cm -o yaml piped through grep takes eleven seconds and needs no tooling. It has found something in four of the nine namespaces I have moved, and in three of those four the application would have started, passed every probe, and misbehaved only under real traffic.
Four questions come up on every wave, so here are my answers rather than a link to a mailing list thread.
You can, and for one namespace of three Deployments the difference is small. It stops scaling around the fourth namespace. Velero gives you a timestamped copy in object storage that you can restore repeatedly with different exclusions while you iterate, it applies its own restore logic for details like allocated ClusterIPs, and it leaves a log another engineer can read. A folder of exported YAML gives you none of that, and it invites hand editing, which is precisely the untraceable manual patching I argued against above.
Keep it unless you have a concrete reason not to. Namespace names leak into service DNS, into ConfigMaps, into dashboards, into alert routing and into other teams’ firewall allowlists. That is why my restore maps wave1-web onto wave1-web rather than inventing something tidier. Use namespaceMapping for real collisions, such as consolidating three TKGI clusters that each own a namespace called web onto one larger OpenShift cluster, which is the consolidation Part 2 recommended. Renaming for neatness alone costs you a week of chasing references.
Less than people fear, more than zero. TKGI 1.18 runs Kubernetes 1.30, so genuinely ancient objects are long gone from your estate already. What still catches teams is an old HorizontalPodAutoscaler manifest pinned to autoscaling/v2beta2, or a PodDisruptionBudget on policy/v1beta1 inside a Helm chart nobody has re-rendered since 2021. Velero restores exactly what it captured. If your target API server no longer serves that version, the restore lands in PartiallyFailed and names the resource. Re-render the chart on the OpenShift side rather than editing the backup.
Steps 1, 2 and 4 barely change. Step 3 changes completely. restorePVs: false becomes the wrong answer, real data has to cross between two storage stacks, StorageClass names almost never match, and rollback stops being one oc delete project because the source database has kept accepting writes while you worked. Budget several times the effort per namespace. That is Part 19, and it is the hardest Part in this series.
Field Note from a Deployment That Restored Clean and Never Started
My worst day on this was not the SCC rejection, which is loud and self explaining once you have read it once. It was a Deployment that restored, started, passed its readiness probe, served my curl, and then broke for real users about ninety minutes later.
What I had missed was a ConfigMap key holding a comma separated allowlist of source CIDRs. On TKGI those CIDRs described the NSX-T pod network, 172.16.0.0/16. On OpenShift the OVN-Kubernetes cluster network is a different range entirely, and every internal call from web-bff to web was arriving from an address the application considered untrusted. It did not reject those calls. It downgraded them to an anonymous session, silently, exactly as it had been written to do six years earlier. My health check hit a path that did not care, so everything looked green.
Three hours to find, four minutes to fix, and the reason I now run a blunt grep across every restored ConfigMap for the old pod CIDR, the old cluster domain and the old registry hostname before I let anyone near DNS. That grep takes eleven seconds and it has caught something in four of the nine namespaces I have moved. Restore verification that only looks at pod status is verification of the platform, not of the application.
Migrate Every Namespace Twice Before You Cut DNS
My recommendation from nine of these is simple and slightly unpopular with project managers. Run the full backup and restore loop, find your admission and configuration problems, then delete the project and run it again from the same backup with your fixes folded into the restore. Second pass on wave1-web took four minutes and produced a namespace with no accumulated manual patching in it, which means the runbook you hand to the next engineer actually works as written. A namespace you fixed by hand is a namespace nobody else can reproduce.
On Monday, take your smallest customer facing stateless namespace and run only the preflight from step 1: list its Deployments and print runAsUser for each. That one command tells you how much of this Part applies to your estate, and it costs nothing and changes nothing. Part 19 adds persistent volumes, where rollback stops being free.
References
Red Hat, OADP Application backup and restore, OpenShift Container Platform 4.19
Red Hat, Routes, Ingress and load balancing, OpenShift Container Platform 4.19


DrJha