Workshop wiki · English

Kubernetes Workshop: Intermediate – Exercises with Solutions

Reference: 02-intermediate-EN.md Difficulty level: Intermediate Format: Hands-on lab with sample solutions and walkthroughs

Workshop examples as ZIP files
Learning progress in the web app

Mark chapters as read, view reading progress and check off practice modules after enabling the optional web app. Workshop progress is calculated from chapters across all document pages. Your choices stay on this device; there is no account or server sync. Installation is not required. Withdrawing your choice in Privacy settings deletes the progress.

Reading progress0%
0%
0 / 23 chapters read
Other language versionGerman ↗Greek ↗

Kubernetes Workshop: Intermediate – Exercises with Solutions

Reference: 02-intermediate-EN.md
Difficulty level: Intermediate
Format: Hands-on lab with sample solutions and walkthroughs

Goal of this exercise document

This exercise document complements the Intermediate workshop with practical tasks, solutions, and walkthroughs. The focus is on workload selection, Init Containers, sidecars, Ephemeral Containers, probes, graceful shutdown, PodDisruptionBudgets, requests, limits, QoS, LimitRange, ResourceQuota, scheduling, NetworkPolicy, DNS, Ingress or Gateway API, storage, Helm, Kustomize, and systematic troubleshooting.

01 / 23

Prerequisites

You need:

  • a lab cluster with a working CNI
  • CoreDNS
  • kubectl
  • optional: helm
  • optional: kustomize or kubectl kustomize
  • optional: a StorageClass
  • optional: an Ingress Controller or Gateway Controller

Recommended Namespace:

bash
export NS=workshop-intermediate
kubectl create namespace "$NS"
kubectl config set-context --current --namespace="$NS"
02 / 23

Exercise overview

No. Topic Result
1 Workload selection Deployment, Job, and CronJob distinguished technically
2 Init Container and sidecar Pod lifecycles traced
3 Probes and lifecycle Startup, Readiness, and Liveness cleanly separated
4 Resources, QoS, LimitRange, Quota Namespace boundaries and scheduling impact checked
5 Scheduling Node Labels, nodeSelector, Taints, and Tolerations tested
6 NetworkPolicy and DNS Default Deny plus targeted allows created
7 Ingress or Gateway HTTP app exposed externally or cleanly classified
8 PVC and StatefulSet persistent state tested
9 Helm and Kustomize rendered manifests compared
10 Troubleshooting course typical errors systematically fixed

Part A – Exercises

03 / 23

Exercise 1: Select the workload resource

Goal

You decide whether Deployment, Job, or CronJob is technically appropriate.

Scenario

You need to model three requirements:

  1. A web server must run continuously and be updated with rolling updates.
  2. A data migration must run once and then finish.
  3. A cleanup task must run every hour.

Task

  1. Create a minimal manifest for each case.
  2. Apply the manifests.
  3. Check status and Events.
  4. Document why the selected resource fits.

04 / 23

Exercise 2: Classify Init Container and sidecar

Goal

You understand when an Init Container must run before the application and when an additional container shares the same Pod lifecycle.

Task

Create a Pod where:

  • an Init Container writes a file to an emptyDir,
  • the App Container reads this file,
  • a sidecar container periodically writes a heartbeat file.

Check:

  1. container startup order,
  2. logs of all containers,
  3. contents of the shared volume.

05 / 23

Exercise 3: Separate probes correctly

Goal

You correct a faulty probe configuration that causes restarts.

Scenario

A sample app starts slowly. The Liveness Probe checks the same path as Readiness too early.

Task

  1. Create a Deployment with an intentionally too aggressive Liveness Probe.
  2. Observe Events and restarts.
  3. Add a Startup Probe.
  4. Separate Readiness and Liveness conceptually.
  5. Explain why Readiness controls traffic and Liveness triggers restarts.

06 / 23

Exercise 4: Resources, QoS, LimitRange, and ResourceQuota

Goal

You set requests and limits, observe QoS classes, and create Namespace boundaries.

Task

  1. Create a LimitRange with default requests and limits.
  2. Create a ResourceQuota for CPU, memory, and Pod count.
  3. Roll out three Pods:
    • without explicit resources,
    • with requests lower than limits,
    • with requests equal to limits.
  4. Determine the QoS classes.
  5. Intentionally create a Quota violation.

07 / 23

Exercise 5: Scheduling with Node Labels, nodeSelector, Taint, and Toleration

Goal

You control Pod placement and interpret Pending Events.

Task

  1. Label a lab Node with workshop-role=apps.
  2. Create a Pod with a matching nodeSelector.
  3. Create a Pod with a non-matching nodeSelector.
  4. Set a Taint on the Node.
  5. Create one Pod without a Toleration and one with a Toleration.
  6. Document the Events.

08 / 23

Exercise 6: NetworkPolicy Default Deny plus DNS allow

Goal

You create a Default Deny policy and then explicitly allow DNS and app traffic.

Task

  1. Create two Deployments: frontend and backend.
  2. Create Services for both.
  3. Check that frontend can reach backend.
  4. Create a Default Deny policy for Ingress and Egress.
  5. Check that traffic is blocked.
  6. Allow DNS Egress.
  7. Allow frontend to backend on port 8080.
  8. Check again.

09 / 23

Exercise 7: Provide Ingress or Gateway

Goal

You expose an HTTP application through Ingress or alternatively describe the Gateway API resources if no controller is installed.

Task

  1. Check whether an Ingress Controller or Gateway Controller is present.
  2. Deploy an HTTP app.
  3. Create either an Ingress resource or a Gateway/HTTPRoute example.
  4. Check controller Events, backend Service, and routing.
  5. Document why an Ingress resource has no effect without a controller.

10 / 23

Exercise 8: Use PVC and StatefulSet

Goal

You run a small stateful workload with a PVC and check whether data survives Pod recreation.

Task

  1. Check existing StorageClasses.
  2. Create a StatefulSet with volumeClaimTemplates.
  3. Write a file to the volume.
  4. Delete the Pod.
  5. After recreation, check whether the file still exists.
  6. Document why a hostPath lab is not a production assumption.

11 / 23

Exercise 9: Compare Helm and Kustomize

Goal

You render manifests before applying them and compare Helm with Kustomize.

Task

  1. Create a small Kustomize base and a dev overlay.
  2. Render the overlay.
  3. Create a minimal Helm chart or use a local sample chart.
  4. Render the Helm chart.
  5. Compare reviewability, parameterization, and error risk.

12 / 23

Exercise 10: Troubleshooting course

Goal

You examine five typical error patterns methodically.

Error patterns

  1. Pod stays Pending because of an incorrect nodeSelector.
  2. Pod shows ImagePullBackOff because of a wrong image tag.
  3. Pod shows CrashLoopBackOff because of a faulty startup command.
  4. PVC stays Pending because of an incorrect StorageClass.
  5. Service has no Endpoints because of a wrong Selector.

Task

For each error, document:

  • symptom,
  • diagnostic command,
  • relevant Events or logs,
  • cause,
  • fix,
  • validation.

Part B – Solutions and walkthroughs

13 / 23

Solution 1: Select the workload resource

Deployment for a continuously running web server

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: workshop-intermediate
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: nginx:1.27
          ports:
            - containerPort: 80

Job for a one-time migration

yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: migration
  namespace: workshop-intermediate
spec:
  template:
    spec:
      restartPolicy: OnFailure
      containers:
        - name: migration
          image: busybox:1.36
          command: ["sh", "-c", "echo Migration running; sleep 3; echo Migration complete"]

CronJob for hourly cleanup

yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: cleanup
  namespace: workshop-intermediate
spec:
  schedule: "0 * * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: cleanup
              image: busybox:1.36
              command: ["sh", "-c", "date; echo cleanup"]

Check:

bash
kubectl apply -f deployment.yml
kubectl apply -f job.yml
kubectl apply -f cronjob.yml
kubectl get deployment,job,cronjob,pod
kubectl logs job/migration

Technical decision:

Requirement Resource Reason
continuously running HTTP service Deployment replicas, rollouts, rollbacks
one-time migration Job completed state is technically desired
hourly cleanup CronJob time-based Job creation

14 / 23

Solution 2: Init Container and sidecar

Manifest:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: lifecycle-demo
  namespace: workshop-intermediate
  labels:
    app: lifecycle-demo
spec:
  volumes:
    - name: shared
      emptyDir: {}
  initContainers:
    - name: init-write
      image: busybox:1.36
      command: ["sh", "-c", "echo prepared > /shared/init.txt"]
      volumeMounts:
        - name: shared
          mountPath: /shared
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "while true; do cat /shared/init.txt; sleep 30; done"]
      volumeMounts:
        - name: shared
          mountPath: /shared
    - name: sidecar
      image: busybox:1.36
      command: ["sh", "-c", "while true; do date > /shared/heartbeat.txt; sleep 10; done"]
      volumeMounts:
        - name: shared
          mountPath: /shared

Check:

bash
kubectl apply -f lifecycle-demo.yml
kubectl get pod lifecycle-demo
kubectl describe pod lifecycle-demo
kubectl logs lifecycle-demo -c init-write
kubectl logs lifecycle-demo -c app
kubectl exec lifecycle-demo -c app -- ls -l /shared
kubectl exec lifecycle-demo -c app -- cat /shared/heartbeat.txt

Explanation:

  • Init Containers run before App Containers and must finish successfully.
  • Sidecars run in parallel with the application.
  • emptyDir shares files within the same Pod, but it does not survive Pod deletion.

15 / 23

Solution 3: Correct probes

Robust sample manifest:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: probe-demo
  namespace: workshop-intermediate
spec:
  replicas: 1
  selector:
    matchLabels:
      app: probe-demo
  template:
    metadata:
      labels:
        app: probe-demo
    spec:
      containers:
        - name: app
          image: registry.k8s.io/e2e-test-images/agnhost:2.53
          args: ["netexec", "--http-port=8080"]
          ports:
            - containerPort: 8080
          startupProbe:
            httpGet:
              path: /healthz
              port: 8080
            failureThreshold: 30
            periodSeconds: 2
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 2
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 10

Check:

bash
kubectl apply -f probe-demo.yml
kubectl rollout status deployment/probe-demo
kubectl describe pod -l app=probe-demo
kubectl get pods -w

Sample explanation:

  • Startup Probe protects slow starts; while it is not successful, Liveness and Readiness are not active.
  • Readiness decides whether a Pod receives Endpoints for Services.
  • Liveness decides whether kubelet restarts the container.
  • A too aggressive Liveness Probe creates restart loops.

16 / 23

Solution 4: Resources, QoS, LimitRange, and ResourceQuota

LimitRange:

yaml
apiVersion: v1
kind: LimitRange
metadata:
  name: defaults
  namespace: workshop-intermediate
spec:
  limits:
    - type: Container
      defaultRequest:
        cpu: 100m
        memory: 128Mi
      default:
        cpu: 500m
        memory: 256Mi
      max:
        cpu: "1"
        memory: 512Mi

ResourceQuota:

yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: namespace-quota
  namespace: workshop-intermediate
spec:
  hard:
    pods: "5"
    requests.cpu: "1"
    requests.memory: 1Gi
    limits.cpu: "3"
    limits.memory: 2Gi

Check:

bash
kubectl apply -f limitrange.yml
kubectl apply -f quota.yml
kubectl describe limitrange defaults
kubectl describe resourcequota namespace-quota

Pod with Burstable QoS:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: burstable-pod
  namespace: workshop-intermediate
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "sleep 3600"]
      resources:
        requests:
          cpu: 100m
          memory: 128Mi
        limits:
          cpu: 500m
          memory: 256Mi

Pod with Guaranteed QoS:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: guaranteed-pod
  namespace: workshop-intermediate
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "sleep 3600"]
      resources:
        requests:
          cpu: 100m
          memory: 128Mi
        limits:
          cpu: 100m
          memory: 128Mi

Check QoS:

bash
kubectl apply -f burstable-pod.yml
kubectl apply -f guaranteed-pod.yml
kubectl get pod burstable-pod -o jsonpath='{.status.qosClass}{"\n"}'
kubectl get pod guaranteed-pod -o jsonpath='{.status.qosClass}{"\n"}'

Create a Quota violation:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: too-large
  namespace: workshop-intermediate
spec:
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "sleep 3600"]
      resources:
        requests:
          cpu: "2"
          memory: 2Gi
        limits:
          cpu: "2"
          memory: 2Gi

Expected result:

bash
kubectl apply -f too-large.yml

The API Server rejects the resource because of ResourceQuota.


17 / 23

Solution 5: Scheduling

Select Node:

bash
NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')
kubectl label node "$NODE" workshop-role=apps
kubectl get nodes --show-labels | grep workshop-role

Pod with matching nodeSelector:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: scheduled-ok
  namespace: workshop-intermediate
spec:
  nodeSelector:
    workshop-role: apps
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "sleep 3600"]

Pod with non-matching nodeSelector:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: scheduled-pending
  namespace: workshop-intermediate
spec:
  nodeSelector:
    workshop-role: does-not-exist
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "sleep 3600"]

Check:

bash
kubectl apply -f scheduled-ok.yml
kubectl apply -f scheduled-pending.yml
kubectl get pods -o wide
kubectl describe pod scheduled-pending

Set Taint:

bash
kubectl taint node "$NODE" dedicated=apps:NoSchedule

Pod with Toleration:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: tolerated
  namespace: workshop-intermediate
spec:
  nodeSelector:
    workshop-role: apps
  tolerations:
    - key: dedicated
      operator: Equal
      value: apps
      effect: NoSchedule
  containers:
    - name: app
      image: busybox:1.36
      command: ["sh", "-c", "sleep 3600"]

Cleanup:

bash
kubectl delete pod scheduled-ok scheduled-pending tolerated --ignore-not-found
kubectl taint node "$NODE" dedicated=apps:NoSchedule-
kubectl label node "$NODE" workshop-role-

Explanation:

  • nodeSelector is hard: no matching Node means Pending.
  • A Taint rejects Pods.
  • A Toleration allows a Pod to tolerate a Taint; by itself, it does not force placement.

18 / 23

Solution 6: NetworkPolicy and DNS

Sample app:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
  namespace: workshop-intermediate
spec:
  replicas: 1
  selector:
    matchLabels:
      app: backend
  template:
    metadata:
      labels:
        app: backend
    spec:
      containers:
        - name: backend
          image: registry.k8s.io/e2e-test-images/agnhost:2.53
          args: ["netexec", "--http-port=8080"]
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: backend
  namespace: workshop-intermediate
spec:
  selector:
    app: backend
  ports:
    - name: http
      port: 8080
      targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
  namespace: workshop-intermediate
spec:
  replicas: 1
  selector:
    matchLabels:
      app: frontend
  template:
    metadata:
      labels:
        app: frontend
    spec:
      containers:
        - name: frontend
          image: curlimages/curl:8.8.0
          command: ["sh", "-c", "sleep 3600"]

Test before Policy:

bash
kubectl apply -f net-demo.yml
kubectl wait --for=condition=Available deployment/backend --timeout=90s
kubectl wait --for=condition=Available deployment/frontend --timeout=90s
FRONTEND=$(kubectl get pod -l app=frontend -o jsonpath='{.items[0].metadata.name}')
kubectl exec "$FRONTEND" -- curl -s http://backend:8080/hostname

Default Deny:

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: workshop-intermediate
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

Allow DNS Egress:

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: workshop-intermediate
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

Allow frontend to backend:

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-frontend-to-backend
  namespace: workshop-intermediate
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend
      ports:
        - protocol: TCP
          port: 8080
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-frontend-egress-to-backend
  namespace: workshop-intermediate
spec:
  podSelector:
    matchLabels:
      app: frontend
  policyTypes:
    - Egress
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: backend
      ports:
        - protocol: TCP
          port: 8080

Check:

bash
kubectl apply -f default-deny.yml
kubectl apply -f allow-dns-egress.yml
kubectl apply -f allow-frontend-backend.yml
kubectl exec "$FRONTEND" -- nslookup backend
kubectl exec "$FRONTEND" -- curl -s --max-time 5 http://backend:8080/hostname

Note:

NetworkPolicies are effective only if the CNI plugin enforces them.


19 / 23

Solution 7: Ingress or Gateway

Check controller:

bash
kubectl get ingressclass
kubectl get gatewayclass 2>/dev/null || true
kubectl get pods -A | grep -Ei 'ingress|gateway|traefik|nginx|envoy|haproxy'

Ingress example:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  namespace: workshop-intermediate
spec:
  rules:
    - host: web.localtest.me
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: backend
                port:
                  number: 8080

Check:

bash
kubectl apply -f ingress.yml
kubectl describe ingress web
kubectl get ingress web

Gateway API example if Gateway API is installed:

yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: web
  namespace: workshop-intermediate
spec:
  parentRefs:
    - name: shared-gateway
      namespace: gateway-system
  hostnames:
    - web.localtest.me
  rules:
    - backendRefs:
        - name: backend
          port: 8080

Technical explanation:

  • Ingress is only a rule description.
  • An Ingress Controller must translate that rule into proxy/load-balancer configuration.
  • Gateway API separates infrastructure responsibility more clearly from application routing.

20 / 23

Solution 8: PVC and StatefulSet

Check StorageClass:

bash
kubectl get storageclass

StatefulSet:

yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: data-demo
  namespace: workshop-intermediate
spec:
  serviceName: data-demo
  replicas: 1
  selector:
    matchLabels:
      app: data-demo
  template:
    metadata:
      labels:
        app: data-demo
    spec:
      containers:
        - name: app
          image: busybox:1.36
          command: ["sh", "-c", "while true; do sleep 3600; done"]
          volumeMounts:
            - name: data
              mountPath: /data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 1Gi

Apply and test:

bash
kubectl apply -f statefulset.yml
kubectl rollout status statefulset/data-demo
kubectl exec data-demo-0 -- sh -c 'echo persistence-test > /data/test.txt'
kubectl delete pod data-demo-0
kubectl wait --for=condition=Ready pod/data-demo-0 --timeout=120s
kubectl exec data-demo-0 -- cat /data/test.txt

Expected state:

The file still exists after Pod recreation because the PVC is reattached to data-demo-0.

Classification:

  • StatefulSets provide stable Pod names and stable PVC assignment.
  • A local hostPath volume in a lab is convenient, but it is not portable and not production storage.

21 / 23

Solution 9: Helm and Kustomize

Kustomize structure:

text
kustomize-demo/
  base/
    deployment.yml
    service.yml
    kustomization.yml
  overlays/
    dev/
      kustomization.yml

base/kustomization.yml:

yaml
resources:
  - deployment.yml
  - service.yml

overlays/dev/kustomization.yml:

yaml
resources:
  - ../../base
namePrefix: dev-
labels:
  - pairs:
      environment: dev
images:
  - name: nginx
    newTag: "1.27"

Render:

bash
kubectl kustomize kustomize-demo/overlays/dev
kubectl apply -k kustomize-demo/overlays/dev --dry-run=server

Minimal Helm chart:

bash
helm create webchart
helm template web ./webchart
helm lint ./webchart

Comparison:

Criterion Helm Kustomize
Model package with templates and values base/overlay without template language
Strength many parameters, releases, chart ecosystem close to YAML, easy to review
Risk complex templates can be hard to verify overlays can become confusing
Mandatory check helm template, helm lint kubectl kustomize, dry-run

22 / 23

Solution 10: Troubleshooting course

Sample diagnosis:

Error pattern Diagnosis Cause Fix
Pending kubectl describe pod no Node matches nodeSelector correct Label or remove Selector
ImagePullBackOff Events in the Pod image/tag/registry wrong set valid image
CrashLoopBackOff kubectl logs --previous process exits with an error correct startup command/app configuration
PVC Pending kubectl describe pvc StorageClass missing or provisioner missing correct or provide StorageClass
Service without Endpoints kubectl get endpoints, check Labels Selector does not match correct Selector or Pod Labels

Standard sequence:

bash
kubectl get all
kubectl get pod -o wide
kubectl describe pod <pod>
kubectl get events --sort-by=.metadata.creationTimestamp
kubectl logs <pod>
kubectl logs <pod> --previous
kubectl get svc,endpoints,endpointslices
kubectl describe pvc <pvc>

Cleanup

bash
kubectl delete namespace workshop-intermediate

Assessment checklist

Criterion Completed?
Workload resources distinguished technically ☐
Init Container, sidecar, and App Container explained ☐
Probes separated correctly ☐
Requests, limits, QoS, LimitRange, and Quota applied ☐
Scheduling Events interpreted ☐
NetworkPolicy including DNS considered ☐
Ingress/Gateway Controller role explained ☐
PVC and StatefulSet tested ☐
Helm and Kustomize rendered ☐
Troubleshooting course documented ☐
23 / 23

K8S · Orthevs Academy

31 modules as one continuous learning path.

The sequence remains unchanged: from the lab skeleton through technical foundations and operating models to final troubleshooting. The technical directory names are deliberately preserved.

Orthevs – Engineering the foundation for what comes next.

Orthevs as an app

Install Orthevs

The Orthevs web app can be installed on supported mobile devices and desktop browsers.

iPhone and iPad

Open Orthevs in Safari. Tap Share, then Add to Home Screen. Enable Open as Web App if offered, then tap Add.

Android

For the web app, open the browser menu and choose Install app or Add to Home screen. Confirm the installation.

Desktop browser

Open the browser menu or the install icon in the address bar, then choose Install app or Add to Dock.

Web app installation depends on your browser. If no install prompt appears, follow the browser steps shown above.