diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e6e9dde9430b89b4e9117a60ce9ee1a23c87b913..07c6893d5b8e215aaa3a34d2fb07055ec76a30e2 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -33,8 +33,6 @@ variables:
   CACHE: auto
 
 build-push:
-  tags:
-    - staging
   extends: .kaniko-build
   before_script:
     - >
diff --git a/.gitlab/lint.yaml b/.gitlab/lint.yaml
index 1fdaaaf300b10ff597f4fc358fdd23ce4fca0c1e..998269c005e1a22cb2f3a920d75b41d7ceccb0a1 100644
--- a/.gitlab/lint.yaml
+++ b/.gitlab/lint.yaml
@@ -1,4 +1,7 @@
 ---
+include:
+  - remote: https://gitlab.com/ethz-hpc/pipelines/-/raw/main/scripts/chart/kube-linter.yaml
+
 yamllint:
   stage: lint
   image: registry.gitlab.com/pipeline-components/yamllint:0.31.2@sha256:82f082414bad17ec04f4d271262a6dcaf64883bb4f1ce73923380125af8b94ee
@@ -21,7 +24,7 @@ markdownlint:
   stage: lint
   image: registry.gitlab.com/pipeline-components/markdownlint:0.13.3@sha256:05e98b078e72c637e90a15094d012ed63108d101a941c2526833717ae50eb802
   script:
-    - mdl --style all --warnings .
+    - mdl --warnings .
   rules:
     - if: >-
         $CI_PIPELINE_SOURCE !~ /^(?:push|merge_request_event|schedule|pipeline)$/ &&
@@ -34,6 +37,8 @@ markdownlint:
           - "*.MD"
           - "**/*.md"
           - "**/*.MD"
+          - ".mdlrc"
+          - ".markdown.style.rb"
 
 hadolint:
   stage: lint
@@ -50,3 +55,27 @@ hadolint:
           - Dockerfile*
           - "**/Dockerfile*"
           - .gitlab-ci.yaml
+          - .gitlab/lint.yaml
+
+iperf-kube-lint:
+  extends: .kube-linter
+  image: registry.gitlab.com/ethz-hpc/pipelines/kube-linter:latest
+  stage: lint
+  script:
+    - set -eo pipefail
+    - cd charts/iperf
+    - >-
+      helm template iperf-server . |
+        awk -v o=/dev/stderr '/^(apiVersion:|---)/ { o="/dev/stdout" } { print >o }' |
+        tee /dev/stderr |
+        kube-linter lint --fail-if-no-objects-found --fail-on-invalid-resource -
+  rules:
+    - if: >-
+        $CI_PIPELINE_SOURCE !~ /^(?:push|merge_request_event|schedule|pipeline)$/ &&
+        $RENOVATE == "true"
+      when: never
+    - changes:
+        paths:
+          - .gitlab-ci.yaml
+          - .gitlab/lint.yaml
+          - charts/**/*.yaml
diff --git a/.markdown.style.rb b/.markdown.style.rb
new file mode 100644
index 0000000000000000000000000000000000000000..12c1d99b743ade6cf1b61f575bf147bf46bb45f0
--- /dev/null
+++ b/.markdown.style.rb
@@ -0,0 +1,2 @@
+all
+rule 'MD013', :line_length => 150, :code_block_line_length => 150
diff --git a/.mdlrc b/.mdlrc
new file mode 100644
index 0000000000000000000000000000000000000000..14d936c396099028ff6be84b8ccbb8abad90c1f5
--- /dev/null
+++ b/.mdlrc
@@ -0,0 +1 @@
+style "./.markdown.style.rb"
diff --git a/.yamllint b/.yamllint
index 7c7ea0b2ce4a2b934a9babf851ff2935b2250931..0c0dc62228358511aaa69055cb6d7c6741bf0489 100644
--- a/.yamllint
+++ b/.yamllint
@@ -12,4 +12,9 @@ rules:
     max: 150  # due to digest pinning
     level: warning
 
+ignore:
+  - charts/iperf/templates/deployment.yaml
+  - charts/iperf/templates/networkpolicy.yaml
+  - charts/iperf/templates/service.yaml
+
 # ex: ft=yaml et ts=2 sw=2 :
diff --git a/Dockerfile b/Dockerfile
index 046ab7cdd958cd2ca382cc484b98bd3018f46fe0..ad2503248d925a2e7d8e634811dee4b09cfdda10 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,6 +16,18 @@ ARG CURL_VERSION=8.5.0-2ubuntu10.1
 ARG NMAP_VERSION=7.94+git20230807.3be01efb1+dfsg-3build2
 # renovate: datasource=repology depName=ubuntu_24_04/tini versioning=semver-coerced
 ARG TINI_VERSION=0.19.0-1
+# renovate: datasource=repology depName=ubuntu_24_04/traceroute versioning=semver-coerced
+ARG TRACEROUTE_VERSION=1:2.1.5-1
+# renovate: datasource=repology depName=ubuntu_24_04/inetutils-ping versioning=semver-coerced
+ARG INETUTILS_PING_VERSION=2:2.5-3ubuntu4
+# renovate: datasource=repology depName=ubuntu_24_04/iftop versioning=semver-coerced
+ARG IFTOP=1.0~pre4-9build2
+# renovate: datasource=repology depName=ubuntu_24_04/tcpdump versioning=semver-coerced
+ARG TCPDUMP_VERSION=4.99.4-3ubuntu4
+# renovate: datasource=repology depName=ubuntu_24_04/mtr-tiny versioning=semver-coerced
+ARG MTR_TINY_VERSION=0.95-1.1build2
+# renovate: datasource=repology depName=ubuntu_24_04/iputils-tracepath versioning=semver-coerced
+ARG IPUTILS_TRACEPATH=3:20240117-1build1
 
 # renovate: datasource=docker
 ARG UBUNTU_IMAGE_TAG=24.04
@@ -25,12 +37,17 @@ ARG REVISION=""
 # hadolint ignore=DL3015
 RUN apt-get update \
  && DEBIAN_FRONTEND=noninteractive apt-get install -y \
-        iproute2="${IPROUTE2_VERSION}" \
-        bind9-utils="${BIND9_UTILS_VERSION}" \
         bind9-dnsutils="${BIND9_UTILS_VERSION}" \
-        tini="${TINI_VERSION}" \
+        bind9-utils="${BIND9_UTILS_VERSION}" \
         curl="${CURL_VERSION}" \
+        inetutils-ping="${INETUTILS_PING_VERSION}" \
+        iproute2="${IPROUTE2_VERSION}" \
+        iputils-tracepath="${IPUTILS_TRACEPATH}" \
+        mtr-tiny="${MTR_TINY_VERSION}" \
         nmap="${NMAP_VERSION}" \
+        tcpdump="${TCPDUMP_VERSION}" \
+        tini="${TINI_VERSION}" \
+        traceroute="${TRACEROUTE_VERSION}" \
  && if [ "${WITH_IPERF:-0}" = "1" ]; then apt-get install -y iperf3="${IPERF3_VERSION}"; fi \
  && rm -rf /var/cache/apt/*
 
diff --git a/README.md b/README.md
index deb3c0f41bc3a0b65b2e406b25969708c5452d28..8db58b3bc704ccbfd09791c116af649dff188ffc 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,71 @@
 # Network utilities container image
 
 Useful for debugging network issues.
+
+## Usage
+
+Create an ad-hoc "net-debug" pod in the default namespace on the pod network:
+
+```sh
+$ kubectl run -it --rm -n default \
+    --image=registry.ethz.ch/hpc-registry/netutils net-debug
+root@debug:/# nslookup kubernetes.default
+Server:         10.205.209.10
+Address:        10.205.209.10#53
+
+Name:   kubernetes.default.svc.cluster.local
+Address: 10.205.209.1
+```
+
+The same on the host network:
+
+```sh
+$ kubectl run -it --rm -n default --overrides='{"spec":{"hostNetwork":true}}' \
+    --image=registry.ethz.ch/hpc-registry/netutils net-debug
+root@eu-k8s-dev-10:/# ip a show access
+8: access: <BROADCAST,MULTICAST,MASTER,UP,LOWER_UP> mtu 9000 qdisc noqueue state UP group default qlen 1000
+    link/ether d6:6a:36:07:83:02 brd ff:ff:ff:ff:ff:ff
+    inet 10.205.160.40/20 brd 10.205.175.255 scope global access
+       valid_lft forever preferred_lft forever
+```
+
+### Iperf server-client
+
+An ad-hoc pod on a specific node running iperf server:
+
+```sh
+$ kubectl run --attach --rm -n default \
+    --overrides='{"spec":{"nodeName":"eu-k8s-dev-09.euler.ethz.ch"}}' \
+    --image=registry.ethz.ch/hpc-registry/netutils \
+    iperf-server --command -- iperf3 --server -4 --one-off
+```
+
+*Note*: The pod will terminate and will be deleted after the first handled
+connection thanks to `--one-off`.
+
+Determine its IP:
+
+```sh
+$ kubectl get pod -o wide -n default iperf-server
+NAME           READY   STATUS    RESTARTS   AGE    IP               NODE                          NOMINATED NODE   READINESS GATES
+iperf-server   1/1     Running   0          4m2s   10.205.244.143   eu-k8s-dev-09.euler.ethz.ch   <none>           <none>
+```
+
+And run a client on a different node (on pod network):
+
+```sh
+$ kubectl run --attach --rm -n default \
+    --overrides='{"spec":{"nodeName":"eu-k8s-dev-10.euler.ethz.ch"}}' \
+    --image registry.ethz.ch/hpc-registry/netutils \
+    iperf-client --command -- iperf3 --bidir -P 4 -4 --client 10.205.244.143
+...
+[ 17][RX-C]   0.00-10.00  sec  3.79 GBytes  3.25 Gbits/sec  2725             sender
+[ 17][RX-C]   0.00-10.00  sec  3.79 GBytes  3.25 Gbits/sec                  receiver
+[ 19][RX-C]   0.00-10.00  sec  3.81 GBytes  3.27 Gbits/sec  3012             sender
+[ 19][RX-C]   0.00-10.00  sec  3.80 GBytes  3.27 Gbits/sec                  receiver
+[SUM][RX-C]   0.00-10.00  sec  22.2 GBytes  19.1 Gbits/sec  11059             sender
+[SUM][RX-C]   0.00-10.00  sec  22.2 GBytes  19.0 Gbits/sec                  receiver
+
+iperf Done.
+pod "iperf-client" deleted
+```
diff --git a/charts/iperf/.kube-linter.yaml b/charts/iperf/.kube-linter.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..d7327abf548da58758f93d3da21319134ca1913c
--- /dev/null
+++ b/charts/iperf/.kube-linter.yaml
@@ -0,0 +1,5 @@
+---
+checks:
+  exclude:
+    - latest-tag
+    - drop-net-raw-capability
diff --git a/charts/iperf/Chart.yaml b/charts/iperf/Chart.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..88b7185070a268623330879b3a2eda54303722fc
--- /dev/null
+++ b/charts/iperf/Chart.yaml
@@ -0,0 +1,10 @@
+---
+apiVersion: v2
+name: iperf
+description: Iperf3 server for network bandwidth testing
+
+type: application
+version: 0.1.0
+appVersion: 0.1.0
+
+dependencies: []
diff --git a/charts/iperf/Makefile b/charts/iperf/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..fcf704f3009edcc957a2530bf3c57ea733fcdb6b
--- /dev/null
+++ b/charts/iperf/Makefile
@@ -0,0 +1,31 @@
+NAMESPACE    ?= default
+CHART_NAME   ?= iperf-server
+HELM_ARGS    := $(CHART_NAME) . -n $(NAMESPACE) -f values.yaml $(HELM_ARGS)
+
+.PHONY: lint
+lint:
+	make render | awk -v o=/dev/stderr '/^(apiVersion:|---)/ { o="/dev/stdout" } { print >o }' | \
+		kube-linter lint --fail-if-no-objects-found --fail-on-invalid-resource -
+
+.PHONY: render
+render:
+	helm template $(HELM_ARGS)
+
+.PHONY: install
+install:
+	helm install --create-namespace $(HELM_ARGS)
+
+upgrade:
+	helm upgrade $(HELM_ARGS)
+
+.PHONY: diff
+# requires helm diff plugin
+diff:
+	helm diff upgrade $(HELM_ARGS)
+
+.PHONY: kdiff
+kdiff:
+	make render | awk -v o=/dev/stderr '/^(apiVersion:|---)/ { o="/dev/stdout" } { print >o }' | \
+		kubectl diff -n "$(NAMESPACE)" -f - ||:
+
+# ex: ft=make noet ts=4 sw=4 :
diff --git a/charts/iperf/templates/deployment.yaml b/charts/iperf/templates/deployment.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..00f577bc0eb52cc3160e9e05ba6d0e74679b6ace
--- /dev/null
+++ b/charts/iperf/templates/deployment.yaml
@@ -0,0 +1,82 @@
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  annotations:
+  labels:
+    app.kubernetes.io/name: iperf-server
+    app.kubernetes.io/part-of: iperf-server
+  name: iperf-server
+  namespace: "{{ .Release.Namespace }}"
+spec:
+  replicas: {{ .Values.replicas | default 1 }}
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: iperf-server
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/name: iperf-server
+        app.kubernetes.io/part-of: iperf-server
+    spec:
+      containers:
+        - command:
+            - /usr/bin/iperf3
+          args:
+            - --server
+{{- range $_, $arg := (index (default (list) .Values.server) "args") }}
+            - {{ $arg }}
+{{- end }}
+          image: {{ .Values.image.name |
+              default "registry.ethz.ch/hpc-registry/netutils" }}:{{
+              .Values.image.tag | default "latest" }}
+          imagePullPolicy: Always
+          name: iperf-server
+          ports:
+            - containerPort: {{ .Values.server.port | default 5201 }}
+              name: iperf-server
+              protocol: TCP
+          resources:
+            limits:
+              cpu: 2
+              ephemeral-storage: 64Mi
+              memory: 512Mi
+            requests:
+              cpu: 200m
+              ephemeral-storage: 2Mi
+              memory: 64Mi
+          securityContext:
+            allowPrivilegeEscalation: false
+            readOnlyRootFilesystem: true
+            capabilities:
+              add:
+                - NET_ADMIN
+          volumeMounts:
+            - name: tmp
+              mountPath: /tmp
+{{- if .Values.mountHost | default false }}
+            - name: host
+              mountPath: /host
+{{- end }}
+{{- if .Values.nodeSelector }}
+      nodeSelector:
+{{ .Values.nodeSelector | indent 10 }}
+{{- end }}
+      restartPolicy: Always
+      hostNetwork: {{ .Values.hostNetwork | default false }}
+      securityContext:
+        runAsNonRoot: true
+      serviceAccountName: iperf
+{{- if .Values.tolerations }}
+      tolerations:
+{{ .Values.tolerations | indent 10 }}
+{{- end }}
+      volumes:
+        - name: tmp
+          emptyDir:
+            sizeLimit: 56Mi
+{{- if .Values.mountHost | default false }}
+        - hostPath:
+            path: /
+          name: host
+{{- end }}
diff --git a/charts/iperf/templates/networkpolicy.yaml b/charts/iperf/templates/networkpolicy.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..86c4ee764cc240e8ad3728d08d54dd9a78a6f2f0
--- /dev/null
+++ b/charts/iperf/templates/networkpolicy.yaml
@@ -0,0 +1,27 @@
+{{- if index (default (dict) .Values.networkPolicy) "enabled" | default false }}
+---
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+  labels:
+    app.kubernetes.io/name: iperf-server
+    app.kubernetes.io/part-of: iperf-server
+  name: iperf-server
+  namespace: "{{ .Release.Namespace }}"
+spec:
+  ingress:
+    - from:
+{{- range $_, $cidr := .Values.networkPolicy.ingress.CIDRs }}
+        - ipBlock:
+            cidr: "{{ $cidr }}"
+{{- end }}
+      ports:
+        - port: {{ .Values.server.port | default 5201 }}
+          protocol: TCP
+  podSelector:
+    matchLabels:
+      app.kubernetes.io/name: iperf-server
+      app.kubernetes.io/part-of: iperf-server
+  policyTypes:
+    - Ingress
+{{- end }}
diff --git a/charts/iperf/templates/sa.yaml b/charts/iperf/templates/sa.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..fbaa719251fe685e988d44cd7a8664d3add54d95
--- /dev/null
+++ b/charts/iperf/templates/sa.yaml
@@ -0,0 +1,8 @@
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: iperf
+  namespace: "{{ .Release.Namespace }}"
+  labels:
+    app.kubernetes.io/part-of: iperf-server
diff --git a/charts/iperf/templates/service.yaml b/charts/iperf/templates/service.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..b8a20d99bd0c0f6e4c6ff9a75067b55d19539ec6
--- /dev/null
+++ b/charts/iperf/templates/service.yaml
@@ -0,0 +1,23 @@
+---
+apiVersion: v1
+kind: Service
+metadata:
+{{- if index (default (dict) .Values.service) "annotations" }}
+  annotations:
+{{ .Values.service.annotations | toYaml | indent 4 }}
+{{- end }}
+  labels:
+    app.kubernetes.io/name: iperf-server
+    app.kubernetes.io/part-of: iperf-server
+  name: iperf-server
+  namespace: "{{ .Release.Namespace }}"
+spec:
+  externalTrafficPolicy: "{{ .Values.service.externalTrafficPolicy | default "Local" }}"
+  ports:
+    - name: iperf-server
+      port: {{ .Values.server.port | default 5201 }}
+      targetPort: iperf-server
+      protocol: TCP
+  selector:
+    app.kubernetes.io/name: iperf-server
+  type: LoadBalancer
diff --git a/charts/iperf/values.yaml b/charts/iperf/values.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..605b9039c8f35647f7a7f06c324cb7199a7cf5fb
--- /dev/null
+++ b/charts/iperf/values.yaml
@@ -0,0 +1,22 @@
+---
+image:
+  name: registry.ethz.ch/hpc-registry/netutils
+  tag: latest
+replicas: 1
+## mount / of the host at /host in the container
+mountHost: false
+hostNetwork: false
+# nodeSelector: {}
+service:
+  annotations:
+    metallb.universe.tf/address-pool: public
+    metallb.universe.tf/ip-allocated-from-pool: public
+    metallb.universe.tf/loadBalancerIPs: CHANGEME
+  externalTrafficPolicy: Local
+server:
+  args: []
+  port: 5201
+networkPolicy:
+  enabled: false
+  ingress:
+    CIDRs: []