diff --git a/crates/configuration/src/shared/errors.rs b/crates/configuration/src/shared/errors.rs
index 32007d6cb530a07b5fd583650e596db1150fe864..0ff10fdaa6a9674d58ea9fccb8f5abd8ed0d9392 100644
--- a/crates/configuration/src/shared/errors.rs
+++ b/crates/configuration/src/shared/errors.rs
@@ -1,4 +1,4 @@
-use super::types::ParaId;
+use super::types::{ParaId, Port};
 
 /// An error at the configuration level.
 #[derive(thiserror::Error, Debug)]
@@ -66,6 +66,18 @@ pub enum FieldError {
 
     #[error("limit_cpu: {0}")]
     LimitCpu(anyhow::Error),
+
+    #[error("ws_port: {0}")]
+    WsPort(anyhow::Error),
+
+    #[error("rpc_port: {0}")]
+    RpcPort(anyhow::Error),
+
+    #[error("prometheus_port: {0}")]
+    PrometheusPort(anyhow::Error),
+
+    #[error("p2p_port: {0}")]
+    P2pPort(anyhow::Error),
 }
 
 /// A conversion error for shared types across fields.
@@ -80,3 +92,10 @@ pub enum ConversionError {
     #[error("can't be empty")]
     CantBeEmpty,
 }
+
+/// A validation error for shared types across fields.
+#[derive(thiserror::Error, Debug, Clone)]
+pub enum ValidationError {
+    #[error("'{0}' is already used")]
+    PortAlreadyUsed(Port),
+}
diff --git a/crates/configuration/src/shared/helpers.rs b/crates/configuration/src/shared/helpers.rs
index 93a3dffb62e06209e45ac0564e24fc3a485dfe88..ae742289ff1d22dd9f5e38b2532227a2957d81b3 100644
--- a/crates/configuration/src/shared/helpers.rs
+++ b/crates/configuration/src/shared/helpers.rs
@@ -1,3 +1,10 @@
+use std::{cell::RefCell, rc::Rc};
+
+use super::{
+    errors::ValidationError,
+    types::{Port, ValidationContext},
+};
+
 pub fn merge_errors(errors: Vec<anyhow::Error>, new_error: anyhow::Error) -> Vec<anyhow::Error> {
     let mut errors = errors;
     errors.push(new_error);
@@ -17,3 +24,19 @@ pub fn merge_errors_vecs(
 
     errors
 }
+
+pub fn ensure_port_unique(
+    port: Port,
+    validation_context: Rc<RefCell<ValidationContext>>,
+) -> Result<(), anyhow::Error> {
+    let mut context = validation_context
+        .try_borrow_mut()
+        .expect("must be borrowable as mutable, this is a bug please report it: https://github.com/paritytech/zombienet-sdk/issues");
+
+    if !context.used_ports.contains(&port) {
+        context.used_ports.push(port);
+        return Ok(());
+    }
+
+    Err(ValidationError::PortAlreadyUsed(port).into())
+}
diff --git a/crates/configuration/src/shared/node.rs b/crates/configuration/src/shared/node.rs
index c1b752032fa87bf6eab643ab36b2af027fab9028..70fa330294a19e14c921e19a5c8c74cb52fd80c3 100644
--- a/crates/configuration/src/shared/node.rs
+++ b/crates/configuration/src/shared/node.rs
@@ -4,7 +4,7 @@ use multiaddr::Multiaddr;
 
 use super::{
     errors::FieldError,
-    helpers::{merge_errors, merge_errors_vecs},
+    helpers::{ensure_port_unique, merge_errors, merge_errors_vecs},
     macros::states,
     resources::ResourcesBuilder,
     types::{AssetLocation, ChainDefaultContext, Command, Image, ValidationContext},
@@ -422,50 +422,78 @@ impl NodeConfigBuilder<Buildable> {
 
     /// Set the websocket port that will be exposed. Uniqueness across config will be checked.
     pub fn with_ws_port(self, ws_port: Port) -> Self {
-        Self::transition(
-            NodeConfig {
-                ws_port: Some(ws_port),
-                ..self.config
-            },
-            self.validation_context,
-            self.errors,
-        )
+        match ensure_port_unique(ws_port, self.validation_context.clone()) {
+            Ok(_) => Self::transition(
+                NodeConfig {
+                    ws_port: Some(ws_port),
+                    ..self.config
+                },
+                self.validation_context,
+                self.errors,
+            ),
+            Err(error) => Self::transition(
+                self.config,
+                self.validation_context,
+                merge_errors(self.errors, FieldError::WsPort(error).into()),
+            ),
+        }
     }
 
     /// Set the RPC port that will be exposed. Uniqueness across config will be checked.
     pub fn with_rpc_port(self, rpc_port: Port) -> Self {
-        Self::transition(
-            NodeConfig {
-                rpc_port: Some(rpc_port),
-                ..self.config
-            },
-            self.validation_context,
-            self.errors,
-        )
+        match ensure_port_unique(rpc_port, self.validation_context.clone()) {
+            Ok(_) => Self::transition(
+                NodeConfig {
+                    rpc_port: Some(rpc_port),
+                    ..self.config
+                },
+                self.validation_context,
+                self.errors,
+            ),
+            Err(error) => Self::transition(
+                self.config,
+                self.validation_context,
+                merge_errors(self.errors, FieldError::RpcPort(error).into()),
+            ),
+        }
     }
 
     /// Set the prometheus port that will be exposed for metrics. Uniqueness across config will be checked.
     pub fn with_prometheus_port(self, prometheus_port: Port) -> Self {
-        Self::transition(
-            NodeConfig {
-                prometheus_port: Some(prometheus_port),
-                ..self.config
-            },
-            self.validation_context,
-            self.errors,
-        )
+        match ensure_port_unique(prometheus_port, self.validation_context.clone()) {
+            Ok(_) => Self::transition(
+                NodeConfig {
+                    prometheus_port: Some(prometheus_port),
+                    ..self.config
+                },
+                self.validation_context,
+                self.errors,
+            ),
+            Err(error) => Self::transition(
+                self.config,
+                self.validation_context,
+                merge_errors(self.errors, FieldError::PrometheusPort(error).into()),
+            ),
+        }
     }
 
-    /// Set the P2P port that will be exposed. Uniqueness across will be checked.
+    /// Set the P2P port that will be exposed. Uniqueness across config will be checked.
     pub fn with_p2p_port(self, p2p_port: Port) -> Self {
-        Self::transition(
-            NodeConfig {
-                p2p_port: Some(p2p_port),
-                ..self.config
-            },
-            self.validation_context,
-            self.errors,
-        )
+        match ensure_port_unique(p2p_port, self.validation_context.clone()) {
+            Ok(_) => Self::transition(
+                NodeConfig {
+                    p2p_port: Some(p2p_port),
+                    ..self.config
+                },
+                self.validation_context,
+                self.errors,
+            ),
+            Err(error) => Self::transition(
+                self.config,
+                self.validation_context,
+                merge_errors(self.errors, FieldError::P2pPort(error).into()),
+            ),
+        }
     }
 
     /// Set the P2P cert hash that will be used as part of the multiaddress