diff --git a/roles/download/tasks/prep_kubeadm_images.yml b/roles/download/tasks/prep_kubeadm_images.yml
index c1e8c6cdcbaf5a19db0bc3106dd3c04638ad2fe3..8725393edc314fe027ff4e161ac84703b8897c8a 100644
--- a/roles/download/tasks/prep_kubeadm_images.yml
+++ b/roles/download/tasks/prep_kubeadm_images.yml
@@ -7,14 +7,6 @@
     - not skip_downloads | default(false)
     - downloads.kubeadm.enabled
 
-- name: Prep_kubeadm_images | Create kubeadm config
-  template:
-    src: "kubeadm-images.yaml.j2"
-    dest: "{{ kube_config_dir }}/kubeadm-images.yaml"
-    mode: "0644"
-  when:
-    - not skip_kubeadm_images | default(false)
-
 - name: Prep_kubeadm_images | Copy kubeadm binary from download dir to system path
   copy:
     src: "{{ downloads.kubeadm.dest }}"
@@ -22,11 +14,14 @@
     mode: "0755"
     remote_src: true
 
-- name: Prep_kubeadm_images | Set kubeadm binary permissions
-  file:
-    path: "{{ bin_dir }}/kubeadm"
-    mode: "0755"
-    state: file
+- name: Prep_kubeadm_images | Create kubeadm config
+  template:
+    src: "kubeadm-images.yaml.j2"
+    dest: "{{ kube_config_dir }}/kubeadm-images.yaml"
+    mode: "0644"
+    validate: "{{ bin_dir }}/kubeadm config validate --config %s"
+  when:
+    - not skip_kubeadm_images | default(false)
 
 - name: Prep_kubeadm_images | Generate list of required images
   shell: "set -o pipefail && {{ bin_dir }}/kubeadm config images list --config={{ kube_config_dir }}/kubeadm-images.yaml | grep -Ev 'coredns|pause'"
diff --git a/roles/kubernetes/control-plane/tasks/kubeadm-secondary.yml b/roles/kubernetes/control-plane/tasks/kubeadm-secondary.yml
index 58a229816e4437c970d680f2cb02a6e02fc31bf8..6dbf4df1bb608ef7ef02fe592b8161cbe0822fcf 100644
--- a/roles/kubernetes/control-plane/tasks/kubeadm-secondary.yml
+++ b/roles/kubernetes/control-plane/tasks/kubeadm-secondary.yml
@@ -36,6 +36,7 @@
     dest: "{{ kube_config_dir }}/kubeadm-controlplane.yaml"
     mode: "0640"
     backup: true
+    validate: "{{ bin_dir }}/kubeadm config validate --config %s"
   when:
     - inventory_hostname != first_kube_control_plane
     - not kubeadm_already_run.stat.exists
@@ -87,7 +88,7 @@
   command: >-
     {{ bin_dir }}/kubeadm join
     --config {{ kube_config_dir }}/kubeadm-controlplane.yaml
-    --ignore-preflight-errors=all
+    --ignore-preflight-errors={{ kubeadm_ignore_preflight_errors | join(',') }}
     --skip-phases={{ kubeadm_join_phases_skip | join(',') }}
   environment:
     PATH: "{{ bin_dir }}:{{ ansible_env.PATH }}"
diff --git a/roles/kubernetes/control-plane/tasks/kubeadm-setup.yml b/roles/kubernetes/control-plane/tasks/kubeadm-setup.yml
index 5e8a677e50c10da579512053c46289022ecf4c86..ae7b7506fe58173a6a94a00d3f5854b56a59a1ad 100644
--- a/roles/kubernetes/control-plane/tasks/kubeadm-setup.yml
+++ b/roles/kubernetes/control-plane/tasks/kubeadm-setup.yml
@@ -93,6 +93,7 @@
     src: "kubeadm-config.{{ kubeadm_config_api_version }}.yaml.j2"
     dest: "{{ kube_config_dir }}/kubeadm-config.yaml"
     mode: "0640"
+    validate: "{{ bin_dir }}/kubeadm config validate --config %s"
 
 - name: Kubeadm | Create directory to store admission control configurations
   file:
@@ -168,7 +169,7 @@
     timeout -k {{ kubeadm_init_timeout }} {{ kubeadm_init_timeout }}
     {{ bin_dir }}/kubeadm init
     --config={{ kube_config_dir }}/kubeadm-config.yaml
-    --ignore-preflight-errors=all
+    --ignore-preflight-errors={{ kubeadm_ignore_preflight_errors | join(',') }}
     --skip-phases={{ kubeadm_init_phases_skip | join(',') }}
     {{ kube_external_ca_mode | ternary('', '--upload-certs') }}
   register: kubeadm_init
diff --git a/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml b/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
index c9dbabd44723a4f5aa0713401ad8e91464f0f866..d8afd69dcf65a538bec77c56216eec8443d3a95d 100644
--- a/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
+++ b/roles/kubernetes/control-plane/tasks/kubeadm-upgrade.yml
@@ -15,9 +15,9 @@
     {{ bin_dir }}/kubeadm
     upgrade apply -y {{ kube_version }}
     --certificate-renewal={{ kubeadm_upgrade_auto_cert_renewal }}
-    --ignore-preflight-errors=all
+    --ignore-preflight-errors={{ kubeadm_ignore_preflight_errors | join(',') }}
     --allow-experimental-upgrades
-    --etcd-upgrade={{ (etcd_deployment_type == "kubeadm") | bool | lower }}
+    --etcd-upgrade={{ (etcd_deployment_type == "kubeadm") | lower }}
     {% if kubeadm_patches | length > 0 %}--patches={{ kubeadm_patches_dir }}{% endif %}
     --force
   register: kubeadm_upgrade
@@ -36,9 +36,9 @@
     {{ bin_dir }}/kubeadm
     upgrade apply -y {{ kube_version }}
     --certificate-renewal={{ kubeadm_upgrade_auto_cert_renewal }}
-    --ignore-preflight-errors=all
+    --ignore-preflight-errors={{ kubeadm_ignore_preflight_errors | join(',') }}
     --allow-experimental-upgrades
-    --etcd-upgrade={{ (etcd_deployment_type == "kubeadm") | bool | lower }}
+    --etcd-upgrade={{ (etcd_deployment_type == "kubeadm") | lower }}
     {% if kubeadm_patches | length > 0 %}--patches={{ kubeadm_patches_dir }}{% endif %}
     --force
   register: kubeadm_upgrade
diff --git a/roles/kubernetes/control-plane/templates/kubeadm-controlplane.yaml.j2 b/roles/kubernetes/control-plane/templates/kubeadm-controlplane.yaml.j2
index 24a6c23c009c4b5c538c1975ecec622443d19f26..d057256fc2437b553e3b15434c600004a5a4f2d1 100644
--- a/roles/kubernetes/control-plane/templates/kubeadm-controlplane.yaml.j2
+++ b/roles/kubernetes/control-plane/templates/kubeadm-controlplane.yaml.j2
@@ -14,8 +14,14 @@ discovery:
     token: {{ kubeadm_token }}
     unsafeSkipCAVerification: true
 {% endif %}
-  timeout: {{ discovery_timeout }}
   tlsBootstrapToken: {{ kubeadm_token }}
+{# TODO: drop the if when we drop support for k8s<1.31 #}
+{% if kubeadm_config_api_version == 'v1beta3' %}
+  timeout: {{ discovery_timeout }}
+{% else %}
+timeouts:
+  discovery: {{ discovery_timeout }}
+{% endif %}
 controlPlane:
   localAPIEndpoint:
     advertiseAddress: {{ kube_apiserver_address }}
diff --git a/roles/kubernetes/kubeadm/tasks/kubeadm_etcd_node.yml b/roles/kubernetes/kubeadm/tasks/kubeadm_etcd_node.yml
index 38fd8378b90373ebc969e3be6b629d1a0255ce47..2805e192e4f3b33eac3f5349ae3e9178b6fd300a 100644
--- a/roles/kubernetes/kubeadm/tasks/kubeadm_etcd_node.yml
+++ b/roles/kubernetes/kubeadm/tasks/kubeadm_etcd_node.yml
@@ -9,6 +9,7 @@
     src: "kubeadm-client.conf.j2"
     dest: "{{ kube_config_dir }}/kubeadm-cert-controlplane.conf"
     mode: "0640"
+    validate: "{{ bin_dir }}/kubeadm config validate --config %s"
   vars:
     kubeadm_cert_controlplane: true
 
diff --git a/roles/kubernetes/kubeadm/tasks/main.yml b/roles/kubernetes/kubeadm/tasks/main.yml
index 6b575df0f491a665747e09a6f47d1d81b67a3799..bc69d78240e225f5c6e79e6b881029e0f8c7db2c 100644
--- a/roles/kubernetes/kubeadm/tasks/main.yml
+++ b/roles/kubernetes/kubeadm/tasks/main.yml
@@ -77,6 +77,7 @@
     dest: "{{ kube_config_dir }}/kubeadm-client.conf"
     backup: true
     mode: "0640"
+    validate: "{{ bin_dir }}/kubeadm config validate --config %s"
   when: ('kube_control_plane' not in group_names)
 
 - name: Join to cluster if needed
@@ -85,38 +86,16 @@
   when:
     - ('kube_control_plane' not in group_names)
     - not kubelet_conf.stat.exists
-  block:
-
-    - name: Join to cluster
-      command: >-
-        timeout -k {{ kubeadm_join_timeout }} {{ kubeadm_join_timeout }}
-        {{ bin_dir }}/kubeadm join
-        --config {{ kube_config_dir }}/kubeadm-client.conf
-        --ignore-preflight-errors=DirAvailable--etc-kubernetes-manifests
-        --skip-phases={{ kubeadm_join_phases_skip | join(',') }}
-      register: kubeadm_join
-      changed_when: kubeadm_join is success
-
-  rescue:
-
-    - name: Join to cluster with ignores
-      command: >-
-        timeout -k {{ kubeadm_join_timeout }} {{ kubeadm_join_timeout }}
-        {{ bin_dir }}/kubeadm join
-        --config {{ kube_config_dir }}/kubeadm-client.conf
-        --ignore-preflight-errors=all
-        --skip-phases={{ kubeadm_join_phases_skip | join(',') }}
-      register: kubeadm_join
-      changed_when: kubeadm_join is success
-
-  always:
-
-    - name: Display kubeadm join stderr if any
-      when: kubeadm_join is failed
-      debug:
-        msg: |
-          Joined with warnings
-          {{ kubeadm_join.stderr_lines }}
+  vars:
+    ignored:
+      - DirAvailable--etc-kubernetes-manifests
+      - "{{ kubeadm_ignore_preflight_errors }}"
+  command: >-
+    timeout -k {{ kubeadm_join_timeout }} {{ kubeadm_join_timeout }}
+    {{ bin_dir }}/kubeadm join
+    --config {{ kube_config_dir }}/kubeadm-client.conf
+    --ignore-preflight-errors={{ ignored | flatten | join(',') }}
+    --skip-phases={{ kubeadm_join_phases_skip | join(',') }}
 
 - name: Update server field in kubelet kubeconfig
   lineinfile:
diff --git a/roles/kubernetes/kubeadm/templates/kubeadm-client.conf.j2 b/roles/kubernetes/kubeadm/templates/kubeadm-client.conf.j2
index a4e014ca72b3f2ee57c9a68c13a670c0cbf05c63..3735936f93d5b17447f4a36808931979156b5121 100644
--- a/roles/kubernetes/kubeadm/templates/kubeadm-client.conf.j2
+++ b/roles/kubernetes/kubeadm/templates/kubeadm-client.conf.j2
@@ -20,8 +20,14 @@ discovery:
     unsafeSkipCAVerification: true
 {% endif %}
 {% endif %}
-  timeout: {{ discovery_timeout }}
   tlsBootstrapToken: {{ kubeadm_token }}
+{# TODO: drop the if when we drop support for k8s<1.31 #}
+{% if kubeadm_config_api_version == 'v1beta3' %}
+  timeout: {{ discovery_timeout }}
+{% else %}
+timeouts:
+  discovery: {{ discovery_timeout }}
+{% endif %}
 caCertPath: {{ kube_cert_dir }}/ca.crt
 {% if kubeadm_cert_controlplane is defined and kubeadm_cert_controlplane %}
 controlPlane:
diff --git a/roles/kubernetes/kubeadm_common/defaults/main.yml b/roles/kubernetes/kubeadm_common/defaults/main.yml
index acbcdcf5fae4b545a634a284ad0e1dcdf181074f..7051f730cd3f30b7bdf872c251be6fd1f9df4e0a 100644
--- a/roles/kubernetes/kubeadm_common/defaults/main.yml
+++ b/roles/kubernetes/kubeadm_common/defaults/main.yml
@@ -18,3 +18,6 @@ kubeadm_patches: []
 #        example.com/prod_level: "{{ prod_level }}"
 # - ...
 # Patches are applied in the order they are specified.
+
+# List of errors to ignore during kubeadm preflight checks
+kubeadm_ignore_preflight_errors: []
diff --git a/tests/files/packet_ubuntu22-calico-all-in-one.yml b/tests/files/packet_ubuntu22-calico-all-in-one.yml
index 61553010753b08310de5b92d701fd623dbae05e9..5d3db3794cc1e0642270234c953b5c6301dee897 100644
--- a/tests/files/packet_ubuntu22-calico-all-in-one.yml
+++ b/tests/files/packet_ubuntu22-calico-all-in-one.yml
@@ -6,6 +6,8 @@ vm_memory: 1600
 
 # Kubespray settings
 auto_renew_certificates: true
+kubeadm_ignore_preflight_errors:
+  - Mem
 
 # Currently ipvs not available on KVM: https://packages.ubuntu.com/search?suite=focal&arch=amd64&mode=exactfilename&searchon=contents&keywords=ip_vs_sh.ko
 kube_proxy_mode: iptables
diff --git a/tests/files/packet_ubuntu24-calico-etcd-datastore.yml b/tests/files/packet_ubuntu24-calico-etcd-datastore.yml
index 7a27ce566101f507c8ce07d791281f3f3b2691d0..f4a5dada08ef3fae63318c70fc9a53e63be08941 100644
--- a/tests/files/packet_ubuntu24-calico-etcd-datastore.yml
+++ b/tests/files/packet_ubuntu24-calico-etcd-datastore.yml
@@ -6,6 +6,8 @@ vm_memory: 1600
 
 # Kubespray settings
 auto_renew_certificates: true
+kubeadm_ignore_preflight_errors:
+  - Mem
 
 # Currently ipvs not available on KVM: https://packages.ubuntu.com/search?suite=noble&arch=amd64&mode=exactfilename&searchon=contents&keywords=ip_vs_sh.ko
 kube_proxy_mode: iptables