Repository: lima-vm/lima

Issue:
Plain mode: support port forwarding (opt-in)
`limactl create --plain` is useful to disable Lima's opinionated extensions, but it still makes sense to allow port forwarding as an opt-in feature.

In the case of plain mode, the port list should be obtained from the explicit list in YAML/CLI, not from the guest agent (which shouldn't be running at all in the case of plain mode).

### Workaround
The current workaround is to run `ssh -L` manually:
```bash
ssh -q -f -N -L 6443:127.0.0.1:6443 -F ~/.lima/host0/ssh.config lima-host0
```

Discussion:
@Ranjandas: On the same note, it would be great if the environment variables (`/mnt/lima-cidata/etc_environment`)were also fed to the scripts that are run with `provision.mode: user`. Currently, in plain mode, looks like the user script don't see these variables and we are forced to run everything with `provision.mode: system`@Horiodino: /assign @AkihiroSuda  is this issue still open?@jandubois: In plain note there should be a warning if a port forwarding rule covers a range of more than maybe 10 ports[^1], as each port will now have to be forwarded unconditionally upfront.

This is fine when we have a guest agent because only ports actually opened are forwarded. But without the guest agent you would have to forward them all.

[^1]: I know our port forwarding breaks when you set up a largish number of forwards. You can verify that by exposing a large number of ports on a container, even if the container doesn't use them.

Solution patch:
diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml
index f9561c16..c93cc95b 100644
--- a/.github/workflows/codeql.yaml
+++ b/.github/workflows/codeql.yaml
@@ -32,7 +32,7 @@ jobs:
       uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@3c3833e0f8c1c83d449a7478aa59c036a9165498  # v3.29.5
+      uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d  # v3.29.5
       with:
         languages: ${{ matrix.language }}
         build-mode: ${{ matrix.build-mode }}
@@ -48,6 +48,6 @@ jobs:
         exit 1
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@3c3833e0f8c1c83d449a7478aa59c036a9165498  # v3.29.5
+      uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d  # v3.29.5
       with:
         category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index f3406e91..5c1d32ed 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -60,6 +60,6 @@ jobs:
     # Upload the results to GitHub's code scanning dashboard (optional).
     # Commenting out will disable upload of results to your repo's Code Scanning dashboard
     - name: "Upload to code-scanning"
-      uses: github/codeql-action/upload-sarif@3c3833e0f8c1c83d449a7478aa59c036a9165498  # v3.29.5
+      uses: github/codeql-action/upload-sarif@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d  # v3.29.5
       with:
         sarif_file: results.sarif
diff --git a/cmd/limactl/editflags/editflags.go b/cmd/limactl/editflags/editflags.go
index 7a0f5a07..6a2a9994 100644
--- a/cmd/limactl/editflags/editflags.go
+++ b/cmd/limactl/editflags/editflags.go
@@ -104,6 +104,11 @@ func RegisterCreate(cmd *cobra.Command, commentPrefix string) {
 	})
 
 	flags.Bool("plain", false, commentPrefix+"Plain mode. Disables mounts, port forwarding, containerd, etc.")
+
+	flags.StringArray("port-forward", nil, commentPrefix+"Port forwards (host:guest), e.g., '8080:80' or '9090:9090,static=true' for static port-forwards")
+	_ = cmd.RegisterFlagCompletionFunc("port-forward", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
+		return []string{"8080:80", "3000:3000", "8080:80,static=true"}, cobra.ShellCompDirectiveNoFileComp
+	})
 }
 
 func defaultExprFunc(expr string) func(v *flag.Flag) (string, error) {
@@ -112,6 +117,56 @@ func defaultExprFunc(expr string) func(v *flag.Flag) (string, error) {
 	}
 }
 
+func ParsePortForward(spec string) (hostPort, guestPort string, isStatic bool, err error) {
+	parts := strings.Split(spec, ",")
+	if len(parts) > 2 {
+		return "", "", false, fmt.Errorf("invalid port forward format %q, expected HOST:GUEST or HOST:GUEST,static=true", spec)
+	}
+
+	portParts := strings.Split(strings.TrimSpace(parts[0]), ":")
+	if len(portParts) != 2 {
+		return "", "", false, fmt.Errorf("invalid port forward format %q, expected HOST:GUEST", parts[0])
+	}
+
+	hostPort = strings.TrimSpace(portParts[0])
+	guestPort = strings.TrimSpace(portParts[1])
+
+	if len(parts) == 2 {
+		staticPart := strings.TrimSpace(parts[1])
+		if strings.HasPrefix(staticPart, "static=") {
+			staticValue := strings.TrimPrefix(staticPart, "static=")
+			isStatic, err = strconv.ParseBool(staticValue)
+			if err != nil {
+				return "", "", false, fmt.Errorf("invalid value for static parameter: %q", staticValue)
+			}
+		} else {
+			return "", "", false, fmt.Errorf("invalid parameter %q, expected 'static=' followed by a boolean value", staticPart)
+		}
+	}
+
+	return hostPort, guestPort, isStatic, nil
+}
+
+func BuildPortForwardExpression(portForwards []string) (string, error) {
+	if len(portForwards) == 0 {
+		return "", nil
+	}
+
+	expr := `.portForwards += [`
+	for i, spec := range portForwards {
+		hostPort, guestPort, isStatic, err := ParsePortForward(spec)
+		if err != nil {
+			return "", err
+		}
+		expr += fmt.Sprintf(`{"guestPort": %q, "hostPort": %q, "static": %v}`, guestPort, hostPort, isStatic)
+		if i < len(portForwards)-1 {
+			expr += ","
+		}
+	}
+	expr += `]`
+	return expr, nil
+}
+
 // YQExpressions returns YQ expressions.
 func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 	type def struct {
@@ -215,6 +270,7 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 			false,
 			false,
 		},
+
 		{
 			"rosetta",
 			func(_ *flag.Flag) (string, error) {
@@ -270,6 +326,18 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 		},
 		{"disk", d(".disk= \"%sGiB\""), false, false},
 		{"plain", d(".plain = %s"), true, false},
+		{
+			"port-forward",
+			func(_ *flag.Flag) (string, error) {
+				ss, err := flags.GetStringArray("port-forward")
+				if err != nil {
+					return "", err
+				}
+				return BuildPortForwardExpression(ss)
+			},
+			false,
+			false,
+		},
 	}
 	var exprs []string
 	for _, def := range defs {
diff --git a/go.mod b/go.mod
index 22ba70e1..9340fc6d 100644
--- a/go.mod
+++ b/go.mod
@@ -38,14 +38,14 @@ require (
 	github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
 	github.com/sethvargo/go-password v0.3.1
 	github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af
-	github.com/spf13/cobra v1.9.1 // gomodjail:unconfined
+	github.com/spf13/cobra v1.10.1 // gomodjail:unconfined
 	github.com/spf13/pflag v1.0.9
 	github.com/wk8/go-ordered-map/v2 v2.1.8
 	golang.org/x/net v0.43.0
 	golang.org/x/sync v0.16.0
 	golang.org/x/sys v0.35.0 // gomodjail:unconfined
 	golang.org/x/text v0.28.0
-	google.golang.org/grpc v1.74.2
+	google.golang.org/grpc v1.75.0
 	google.golang.org/protobuf v1.36.8 // gomodjail:unconfined
 	gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473
 	gotest.tools/v3 v3.5.2
@@ -119,7 +119,7 @@ require (
 	golang.org/x/term v0.34.0 // indirect
 	golang.org/x/time v0.9.0 // indirect
 	golang.org/x/tools v0.35.0 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
 	gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
diff --git a/go.sum b/go.sum
index cd9d4390..2d41c31b 100644
--- a/go.sum
+++ b/go.sum
@@ -242,9 +242,8 @@ github.com/sethvargo/go-password v0.3.1 h1:WqrLTjo7X6AcVYfC6R7GtSyuUQR9hGyAj/f1P
 github.com/sethvargo/go-password v0.3.1/go.mod h1:rXofC1zT54N7R8K/h1WDUdkf9BOx5OptoxrMBcrXzvs=
 github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
 github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
-github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
-github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
+github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -274,16 +273,16 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M
 github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
-go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
-go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
-go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
-go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
-go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
-go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
-go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
-go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
-go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
+go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
+go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
+go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
 go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
@@ -416,10 +415,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
-google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
-google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
+gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
+gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
+google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
 google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
 google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/hack/test-nonplain-static-port-forward.sh b/hack/test-nonplain-static-port-forward.sh
new file mode 100755
index 00000000..92a94b95
--- /dev/null
+++ b/hack/test-nonplain-static-port-forward.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+set -euxo pipefail
+
+INSTANCE=nonplain-static-port-forward
+TEMPLATE=hack/test-templates/static-port-forward.yaml
+
+limactl delete -f $INSTANCE || true
+
+limactl start --name=$INSTANCE --tty=false $TEMPLATE
+
+limactl shell $INSTANCE -- bash -c 'until [ -e /run/nginx.pid ]; do sleep 1; done'
+limactl shell $INSTANCE -- bash -c 'until systemctl is-active --quiet test-server-9080; do sleep 1; done'
+limactl shell $INSTANCE -- bash -c 'until systemctl is-active --quiet test-server-9070; do sleep 1; done'
+
+curl -sSf http://127.0.0.1:9090 | grep -i 'nginx' && echo 'Static port forwarding (9090) works in normal mode!'
+curl -sSf http://127.0.0.1:9080 | grep -i 'Dynamic port 9080' && echo 'Dynamic port forwarding (9080) works in normal mode!'
+curl -sSf http://127.0.0.1:9070 | grep -i 'Dynamic port 9070' && echo 'Dynamic port forwarding (9070) works in normal mode!'
+
+limactl delete -f $INSTANCE
+echo "All tests passed for normal mode - both static and dynamic ports work!"
+# EOF
diff --git a/hack/test-plain-static-port-forward.sh b/hack/test-plain-static-port-forward.sh
new file mode 100755
index 00000000..fbe70d62
--- /dev/null
+++ b/hack/test-plain-static-port-forward.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+set -euxo pipefail
+
+INSTANCE=plain-static-port-forward
+TEMPLATE=hack/test-templates/static-port-forward.yaml
+
+limactl delete -f $INSTANCE || true
+
+limactl start --name=$INSTANCE --plain=true --tty=false $TEMPLATE
+
+limactl shell $INSTANCE -- bash -c 'until [ -e /run/nginx.pid ]; do sleep 1; done'
+
+curl -sSf http://127.0.0.1:9090 | grep -i 'nginx' && echo 'Static port forwarding (9090) works in plain mode!'
+
+if curl -sSf http://127.0.0.1:9080 2>/dev/null; then
+	echo 'ERROR: Dynamic port 9080 should not be forwarded in plain mode!'
+	exit 1
+else
+	echo 'Dynamic port 9080 is correctly NOT forwarded in plain mode.'
+fi
+
+if curl -sSf http://127.0.0.1:9070 2>/dev/null; then
+	echo 'ERROR: Dynamic port 9070 should not be forwarded in plain mode!'
+	exit 1
+else
+	echo 'Dynamic port 9070 is correctly NOT forwarded in plain mode.'
+fi
+
+limactl delete -f $INSTANCE
+echo "All tests passed for plain mode - only static ports work!"
+# EOF
diff --git a/hack/test-templates.sh b/hack/test-templates.sh
index 32eda98c..7faa5f2a 100755
--- a/hack/test-templates.sh
+++ b/hack/test-templates.sh
@@ -51,6 +51,7 @@ declare -A CHECKS=(
 	["snapshot-offline"]=""
 	["clone"]=""
 	["port-forwards"]="1"
+	["static-port-forwards"]=""
 	["vmnet"]=""
 	["disk"]=""
 	["user-v2"]=""
@@ -95,6 +96,12 @@ case "$NAME" in
 	CHECKS["param-env-variables"]="1"
 	CHECKS["set-user"]="1"
 	;;
+"static-port-forward")
+	CHECKS["static-port-forwards"]="1"
+	CHECKS["port-forwards"]=""
+	CHECKS["container-engine"]=""
+	CHECKS["restart"]=""
+	;;
 "docker")
 	CONTAINER_ENGINE="docker"
 	;;
@@ -405,6 +412,13 @@ if [[ -n ${CHECKS["port-forwards"]} ]]; then
 	set +x
 fi
 
+if [[ -n ${CHECKS["static-port-forwards"]} ]]; then
+	INFO "Testing static port forwarding functionality"
+	"${scriptdir}/test-plain-static-port-forward.sh" "$NAME"
+	"${scriptdir}/test-nonplain-static-port-forward.sh" "$NAME"
+	INFO "All static port forwarding tests passed!"
+fi
+
 if [[ -n ${CHECKS["vmnet"]} ]]; then
 	INFO "Testing vmnet functionality"
 	guestip="$(limactl shell "$NAME" ip -4 -j addr show dev lima0 | jq -r '.[0].addr_info[0].local')"
diff --git a/hack/test-templates/static-port-forward.yaml b/hack/test-templates/static-port-forward.yaml
new file mode 100644
index 00000000..67998d54
--- /dev/null
+++ b/hack/test-templates/static-port-forward.yaml
@@ -0,0 +1,64 @@
+images:
+- location: "https://cloud-images.ubuntu.com/releases/22.04/release/ubuntu-22.04-server-cloudimg-amd64.img"
+  arch: "x86_64"
+
+provision:
+- mode: system
+  script: |
+    apt-get update
+    apt-get install -y nginx python3
+    systemctl enable nginx
+    systemctl start nginx
+
+    cat > /etc/systemd/system/test-server-9080.service << 'EOF'
+    [Unit]
+    Description=Test Server on Port 9080
+    After=network.target
+
+    [Service]
+    Type=simple
+    User=root
+    ExecStart=/usr/bin/python3 -m http.server 9080 --bind 127.0.0.1
+    Restart=always
+
+    [Install]
+    WantedBy=multi-user.target
+    EOF
+
+    cat > /etc/systemd/system/test-server-9070.service << 'EOF'
+    [Unit]
+    Description=Test Server on Port 9070
+    After=network.target
+
+    [Service]
+    Type=simple
+    User=root
+    ExecStart=/usr/bin/python3 -m http.server 9070 --bind 127.0.0.1
+    Restart=always
+
+    [Install]
+    WantedBy=multi-user.target
+    EOF
+
+    mkdir -p /var/www/html-9080
+    mkdir -p /var/www/html-9070
+
+    echo '<html><body><h1>Dynamic port 9080</h1></body></html>' > /var/www/html-9080/index.html
+    echo '<html><body><h1>Dynamic port 9070</h1></body></html>' > /var/www/html-9070/index.html
+
+    systemctl daemon-reload
+    systemctl enable test-server-9080
+    systemctl enable test-server-9070
+    systemctl start test-server-9080
+    systemctl start test-server-9070
+
+portForwards:
+- guestPort: 80
+  hostPort: 9090
+  static: true
+- guestPort: 9080
+  hostPort: 9080
+  static: false
+- guestPort: 9070
+  hostPort: 9070
+  static: false
diff --git a/pkg/driver/qemu/qemu_driver.go b/pkg/driver/qemu/qemu_driver.go
index 19187488..e468e55b 100644
--- a/pkg/driver/qemu/qemu_driver.go
+++ b/pkg/driver/qemu/qemu_driver.go
@@ -36,6 +36,7 @@ import (
 	"github.com/lima-vm/lima/v2/pkg/networks/usernet"
 	"github.com/lima-vm/lima/v2/pkg/osutil"
 	"github.com/lima-vm/lima/v2/pkg/ptr"
+	"github.com/lima-vm/lima/v2/pkg/reflectutil"
 	"github.com/lima-vm/lima/v2/pkg/version/versionutil"
 )
 
@@ -97,6 +98,18 @@ func validateConfig(ctx context.Context, cfg *limatype.LimaYAML) error {
 		return err
 	}
 
+	for i, nw := range cfg.Networks {
+		if unknown := reflectutil.UnknownNonEmptyFields(nw,
+			"Lima",
+			"Socket",
+			"MACAddress",
+			"Metric",
+			"Interface",
+		); len(unknown) > 0 {
+			logrus.Warnf("vmType %s: ignoring networks[%d]: %+v", *cfg.VMType, i, unknown)
+		}
+	}
+
 	if cfg.VMOpts.QEMU.MinimumVersion != nil {
 		if _, err := semver.NewVersion(*cfg.VMOpts.QEMU.MinimumVersion); err != nil {
 			return fmt.Errorf("field `vmOpts.qemu.minimumVersion` must be a semvar value, got %q: %w", *cfg.VMOpts.QEMU.MinimumVersion, err)
diff --git a/pkg/driver/vz/vz_driver_darwin.go b/pkg/driver/vz/vz_driver_darwin.go
index 0125de1d..235557e5 100644
--- a/pkg/driver/vz/vz_driver_darwin.go
+++ b/pkg/driver/vz/vz_driver_darwin.go
@@ -238,25 +238,6 @@ func validateConfig(_ context.Context, cfg *limatype.LimaYAML) error {
 		); len(unknown) > 0 {
 			logrus.Warnf("vmType %s: ignoring networks[%d]: %+v", *cfg.VMType, i, unknown)
 		}
-
-		field := fmt.Sprintf("networks[%d]", i)
-		switch {
-		case nw.Lima != "":
-			if nw.VZNAT != nil && *nw.VZNAT {
-				return fmt.Errorf("field `%s.lima` and field `%s.vzNAT` are mutually exclusive", field, field)
-			}
-		case nw.Socket != "":
-			if nw.VZNAT != nil && *nw.VZNAT {
-				return fmt.Errorf("field `%s.socket` and field `%s.vzNAT` are mutually exclusive", field, field)
-			}
-		case nw.VZNAT != nil && *nw.VZNAT:
-			if nw.Lima != "" {
-				return fmt.Errorf("field `%s.vzNAT` and field `%s.lima` are mutually exclusive", field, field)
-			}
-			if nw.Socket != "" {
-				return fmt.Errorf("field `%s.vzNAT` and field `%s.socket` are mutually exclusive", field, field)
-			}
-		}
 	}
 
 	switch audioDevice := *cfg.Audio.Device; audioDevice {
diff --git a/pkg/guestagent/api/client/credentials.go b/pkg/guestagent/api/client/credentials.go
index a64f8ae7..d3dca596 100644
--- a/pkg/guestagent/api/client/credentials.go
+++ b/pkg/guestagent/api/client/credentials.go
@@ -41,6 +41,10 @@ func (c *secureTC) Clone() credentials.TransportCredentials {
 }
 
 func (c *secureTC) OverrideServerName(serverNameOverride string) error {
+	// SA1019: c.info.ServerName is deprecated:
+	// Users should use grpc.WithAuthority to override the authority on a channel instead of configuring the credentials.
+
+	//nolint:staticcheck // c.info.ServerName is used for compatibility reason
 	c.info.ServerName = serverNameOverride
 	return nil
 }
diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go
index b5ca940e..c1c73d90 100644
--- a/pkg/hostagent/hostagent.go
+++ b/pkg/hostagent/hostagent.go
@@ -469,7 +469,14 @@ func (a *HostAgent) Info(_ context.Context) (*hostagentapi.Info, error) {
 
 func (a *HostAgent) startHostAgentRoutines(ctx context.Context) error {
 	if *a.instConfig.Plain {
-		logrus.Info("Running in plain mode. Mounts, port forwarding, containerd, etc. will be ignored. Guest agent will not be running.")
+		msg := "Running in plain mode. Mounts, dynamic port forwarding, containerd, etc. will be ignored. Guest agent will not be running."
+		for _, port := range a.instConfig.PortForwards {
+			if port.Static {
+				msg += " Static port forwarding is allowed."
+				break
+			}
+		}
+		logrus.Info(msg)
 	}
 	a.onClose = append(a.onClose, func() error {
 		logrus.Debugf("shutting down the SSH master")
@@ -527,7 +534,14 @@ sudo chown -R "${USER}" /run/host-services`
 			return errors.Join(unlockErrs...)
 		})
 	}
+
 	if !*a.instConfig.Plain {
+		staticPortForwards, err := a.separateStaticPortForwards()
+		if err != nil {
+			errs = append(errs, err)
+		}
+		a.addStaticPortForwardsFromList(ctx, staticPortForwards)
+
 		go a.watchGuestAgentEvents(ctx)
 		if a.showProgress {
 			cloudInitDone := make(chan struct{})
@@ -670,6 +684,47 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
 	}
 }
 
+func (a *HostAgent) addStaticPortForwardsFromList(ctx context.Context, staticPortForwards []limatype.PortForward) {
+	for _, rule := range staticPortForwards {
+		if rule.GuestSocket == "" {
+			guest := &guestagentapi.IPPort{
+				Ip:       rule.GuestIP.String(),
+				Port:     int32(rule.GuestPort),
+				Protocol: rule.Proto,
+			}
+			local, remote := a.portForwarder.forwardingAddresses(guest)
+			if local != "" {
+				logrus.Infof("Setting up static TCP forwarding from %s to %s", remote, local)
+				if err := forwardTCP(ctx, a.sshConfig, a.sshLocalPort, local, remote, verbForward); err != nil {
+					logrus.WithError(err).Warnf("failed to set up static TCP forwarding %s -> %s", remote, local)
+				}
+			}
+		}
+	}
+}
+
+func (a *HostAgent) separateStaticPortForwards() ([]limatype.PortForward, error) {
+	staticPortForwards := make([]limatype.PortForward, 0, len(a.instConfig.PortForwards))
+	nonStaticPortForwards := make([]limatype.PortForward, 0, len(a.instConfig.PortForwards))
+
+	for i := range len(a.instConfig.PortForwards) {
+		rule := a.instConfig.PortForwards[i]
+		if rule.Static {
+			logrus.Debugf("Found static port forward: guest=%d host=%d", rule.GuestPort, rule.HostPort)
+			staticPortForwards = append(staticPortForwards, rule)
+		} else {
+			logrus.Debugf("Found non-static port forward: guest=%d host=%d", rule.GuestPort, rule.HostPort)
+			nonStaticPortForwards = append(nonStaticPortForwards, rule)
+		}
+	}
+
+	logrus.Debugf("Static port forwards: %d, Non-static port forwards: %d", len(staticPortForwards), len(nonStaticPortForwards))
+
+	a.instConfig.PortForwards = nonStaticPortForwards
+
+	return staticPortForwards, nil
+}
+
 func isGuestAgentSocketAccessible(ctx context.Context, client *guestagentclient.GuestAgentClient) bool {
 	_, err := client.Info(ctx)
 	return err == nil
diff --git a/pkg/limatype/lima_yaml.go b/pkg/limatype/lima_yaml.go
index 8ad1a655..367f5bc9 100644
--- a/pkg/limatype/lima_yaml.go
+++ b/pkg/limatype/lima_yaml.go
@@ -300,6 +300,7 @@ type PortForward struct {
 	Proto             Proto  `yaml:"proto,omitempty" json:"proto,omitempty"`
 	Reverse           bool   `yaml:"reverse,omitempty" json:"reverse,omitempty"`
 	Ignore            bool   `yaml:"ignore,omitempty" json:"ignore,omitempty"`
+	Static            bool   `yaml:"static,omitempty" json:"static,omitempty"`
 }
 
 type CopyToHost struct {
diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go
index 25febcd2..ae915b5b 100644
--- a/pkg/limayaml/defaults.go
+++ b/pkg/limayaml/defaults.go
@@ -823,8 +823,8 @@ func fixUpForPlainMode(y *limatype.LimaYAML) {
 	if !*y.Plain {
 		return
 	}
+	deleteNonStaticPortForwards(&y.PortForwards)
 	y.Mounts = nil
-	y.PortForwards = nil
 	y.Containerd.System = ptr.Of(false)
 	y.Containerd.User = ptr.Of(false)
 	y.VMOpts.VZ.Rosetta.BinFmt = ptr.Of(false)
@@ -832,6 +832,17 @@ func fixUpForPlainMode(y *limatype.LimaYAML) {
 	y.TimeZone = ptr.Of("")
 }
 
+// deleteNonStaticPortForwards removes all non-static port forwarding rules in case of Plain mode.
+func deleteNonStaticPortForwards(portForwards *[]limatype.PortForward) {
+	staticPortForwards := make([]limatype.PortForward, 0, len(*portForwards))
+	for _, rule := range *portForwards {
+		if rule.Static {
+			staticPortForwards = append(staticPortForwards, rule)
+		}
+	}
+	*portForwards = staticPortForwards
+}
+
 func executeGuestTemplate(format, instDir string, user limatype.User, param map[string]string) (bytes.Buffer, error) {
 	tmpl, err := template.New("").Parse(format)
 	if err == nil {
diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go
index 33b7a55c..d38d55e1 100644
--- a/pkg/limayaml/validate.go
+++ b/pkg/limayaml/validate.go
@@ -402,6 +402,16 @@ func Validate(y *limatype.LimaYAML, warn bool) error {
 			}
 		}
 	}
+	if y.Plain != nil && *y.Plain {
+		const portRangeWarnThreshold = 10
+		for i, rule := range y.PortForwards {
+			guestRange := rule.GuestPortRange[1] - rule.GuestPortRange[0] + 1
+			hostRange := rule.HostPortRange[1] - rule.HostPortRange[0] + 1
+			if guestRange > portRangeWarnThreshold || hostRange > portRangeWarnThreshold {
+				logrus.Warnf("[plain mode] portForwards[%d] covers a range of more than %d ports (guest: %d, host: %d). All ports will be forwarded unconditionally, which may be inefficient.", i, portRangeWarnThreshold, guestRange, hostRange)
+			}
+		}
+	}
 
 	return errs
 }
@@ -449,12 +459,25 @@ func validateNetwork(y *limatype.LimaYAML) error {
 			if nw.Socket != "" {
 				errs = errors.Join(errs, fmt.Errorf("field `%s.lima` and field `%s.socket` are mutually exclusive", field, field))
 			}
+			if nw.VZNAT != nil && *nw.VZNAT {
+				errs = errors.Join(errs, fmt.Errorf("field `%s.lima` and field `%s.vzNAT` are mutually exclusive", field, field))
+			}
 		case nw.Socket != "":
+			if nw.VZNAT != nil && *nw.VZNAT {
+				errs = errors.Join(errs, fmt.Errorf("field `%s.socket` and field `%s.vzNAT` are mutually exclusive", field, field))
+			}
 			if fi, err := os.Stat(nw.Socket); err != nil && !errors.Is(err, os.ErrNotExist) {
 				errs = errors.Join(errs, err)
 			} else if err == nil && fi.Mode()&os.ModeSocket == 0 {
 				errs = errors.Join(errs, fmt.Errorf("field `%s.socket` %q points to a non-socket file", field, nw.Socket))
 			}
+		case nw.VZNAT != nil && *nw.VZNAT:
+			if nw.Lima != "" {
+				errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.lima` are mutually exclusive", field, field))
+			}
+			if nw.Socket != "" {
+				errs = errors.Join(errs, fmt.Errorf("field `%s.vzNAT` and field `%s.socket` are mutually exclusive", field, field))
+			}
 		default:
 			errs = errors.Join(errs, fmt.Errorf("field `%s.lima` or  field `%s.socket must be set", field, field))
 		}
diff --git a/pkg/portfwd/forward.go b/pkg/portfwd/forward.go
index d20d9a7e..1bd5bba3 100644
--- a/pkg/portfwd/forward.go
+++ b/pkg/portfwd/forward.go
@@ -36,6 +36,10 @@ func NewPortForwarder(rules []limatype.PortForward, ignoreTCP, ignoreUDP bool) *
 
 func (fw *Forwarder) OnEvent(ctx context.Context, client *guestagentclient.GuestAgentClient, ev *api.Event) {
 	for _, f := range ev.AddedLocalPorts {
+		// Before forwarding, check if any static rule matches this port otherwise it will be forwarded twice and cause a port conflict
+		if fw.isPortStaticallyForwarded(f) {
+			continue
+		}
 		local, remote := fw.forwardingAddresses(f)
 		if local == "" {
 			if !fw.ignoreTCP && f.Protocol == "tcp" {
@@ -92,6 +96,18 @@ func (fw *Forwarder) forwardingAddresses(guest *api.IPPort) (hostAddr, guestAddr
 	return "", guest.HostString()
 }
 
+func (fw *Forwarder) isPortStaticallyForwarded(guest *api.IPPort) bool {
+	for _, rule := range fw.rules {
+		if !rule.Static {
+			continue
+		}
+		if guest.Port >= int32(rule.GuestPortRange[0]) && guest.Port <= int32(rule.GuestPortRange[1]) {
+			return true
+		}
+	}
+	return false
+}
+
 func hostAddress(rule limatype.PortForward, guest *api.IPPort) string {
 	if rule.HostSocket != "" {
 		return rule.HostSocket



Test patch:
diff --git a/cmd/limactl/editflags/editflags_test.go b/cmd/limactl/editflags/editflags_test.go
index 2dceac15..1634da82 100644
--- a/cmd/limactl/editflags/editflags_test.go
+++ b/cmd/limactl/editflags/editflags_test.go
@@ -23,3 +23,137 @@ func TestCompleteMemoryGiB(t *testing.T) {
 	assert.DeepEqual(t, []float32{1, 2, 4}, completeMemoryGiB(8<<30))
 	assert.DeepEqual(t, []float32{1, 2, 4, 8, 10}, completeMemoryGiB(20<<30))
 }
+
+func TestBuildPortForwardExpression(t *testing.T) {
+	tests := []struct {
+		name         string
+		portForwards []string
+		expected     string
+		expectError  bool
+	}{
+		{
+			name:         "empty port forwards",
+			portForwards: []string{},
+			expected:     "",
+		},
+		{
+			name:         "single dynamic port forward",
+			portForwards: []string{"8080:80"},
+			expected:     `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": false}]`,
+		},
+		{
+			name:         "single static port forward",
+			portForwards: []string{"8080:80,static=true"},
+			expected:     `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": true}]`,
+		},
+		{
+			name:         "multiple mixed port forwards",
+			portForwards: []string{"8080:80", "2222:22,static=true", "3000:3000"},
+			expected:     `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": false},{"guestPort": "22", "hostPort": "2222", "static": true},{"guestPort": "3000", "hostPort": "3000", "static": false}]`,
+		},
+		{
+			name:         "invalid format - missing colon",
+			portForwards: []string{"8080"},
+			expectError:  true,
+		},
+		{
+			name:         "invalid format - too many colons",
+			portForwards: []string{"8080:80:extra"},
+			expectError:  true,
+		},
+		{
+			name:         "invalid static parameter",
+			portForwards: []string{"8080:80,invalid=true"},
+			expectError:  true,
+		},
+		{
+			name:         "too many parameters",
+			portForwards: []string{"8080:80,static=true,extra=value"},
+			expectError:  true,
+		},
+		{
+			name:         "whitespace handling",
+			portForwards: []string{" 8080 : 80 , static=true "},
+			expected:     `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": true}]`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := BuildPortForwardExpression(tt.portForwards)
+			if tt.expectError {
+				assert.Check(t, err != nil)
+			} else {
+				assert.NilError(t, err)
+				assert.Equal(t, tt.expected, result)
+			}
+		})
+	}
+}
+
+func TestParsePortForward(t *testing.T) {
+	tests := []struct {
+		name        string
+		spec        string
+		hostPort    string
+		guestPort   string
+		isStatic    bool
+		expectError bool
+	}{
+		{
+			name:      "dynamic port forward",
+			spec:      "8080:80",
+			hostPort:  "8080",
+			guestPort: "80",
+			isStatic:  false,
+		},
+		{
+			name:      "static port forward",
+			spec:      "8080:80,static=true",
+			hostPort:  "8080",
+			guestPort: "80",
+			isStatic:  true,
+		},
+		{
+			name:      "whitespace handling",
+			spec:      " 8080 : 80 , static=true ",
+			hostPort:  "8080",
+			guestPort: "80",
+			isStatic:  true,
+		},
+		{
+			name:        "invalid format - missing colon",
+			spec:        "8080",
+			expectError: true,
+		},
+		{
+			name:        "invalid format - too many colons",
+			spec:        "8080:80:extra",
+			expectError: true,
+		},
+		{
+			name:        "invalid parameter",
+			spec:        "8080:80,invalid=true",
+			expectError: true,
+		},
+		{
+			name:        "too many parameters",
+			spec:        "8080:80,static=true,extra=value",
+			expectError: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			hostPort, guestPort, isStatic, err := ParsePortForward(tt.spec)
+			if tt.expectError {
+				assert.Check(t, err != nil)
+			} else {
+				assert.NilError(t, err)
+				assert.Equal(t, tt.hostPort, hostPort)
+				assert.Equal(t, tt.guestPort, guestPort)
+				assert.Equal(t, tt.isStatic, isStatic)
+			}
+		})
+	}
+}
diff --git a/pkg/limayaml/defaults_test.go b/pkg/limayaml/defaults_test.go
index f0b2c644..83857f50 100644
--- a/pkg/limayaml/defaults_test.go
+++ b/pkg/limayaml/defaults_test.go
@@ -688,3 +688,120 @@ func TestContainerdDefault(t *testing.T) {
 	archives := defaultContainerdArchives()
 	assert.Assert(t, len(archives) > 0)
 }
+
+func TestStaticPortForwarding(t *testing.T) {
+	tests := []struct {
+		name     string
+		config   limatype.LimaYAML
+		expected []limatype.PortForward
+	}{
+		{
+			name: "plain mode with static port forwards",
+			config: limatype.LimaYAML{
+				Plain: ptr.Of(true),
+				PortForwards: []limatype.PortForward{
+					{
+						GuestPort: 8080,
+						HostPort:  8080,
+						Static:    true,
+					},
+					{
+						GuestPort: 9000,
+						HostPort:  9000,
+						Static:    false,
+					},
+					{
+						GuestPort: 8081,
+						HostPort:  8081,
+					},
+				},
+			},
+			expected: []limatype.PortForward{
+				{
+					GuestPort: 8080,
+					HostPort:  8080,
+					Static:    true,
+				},
+			},
+		},
+		{
+			name: "non-plain mode with static port forwards",
+			config: limatype.LimaYAML{
+				Plain: ptr.Of(false),
+				PortForwards: []limatype.PortForward{
+					{
+						GuestPort: 8080,
+						HostPort:  8080,
+						Static:    true,
+					},
+					{
+						GuestPort: 9000,
+						HostPort:  9000,
+						Static:    false,
+					},
+				},
+			},
+			expected: []limatype.PortForward{
+				{
+					GuestPort: 8080,
+					HostPort:  8080,
+					Static:    true,
+				},
+				{
+					GuestPort: 9000,
+					HostPort:  9000,
+					Static:    false,
+				},
+			},
+		},
+		{
+			name: "plain mode with no static port forwards",
+			config: limatype.LimaYAML{
+				Plain: ptr.Of(true),
+				PortForwards: []limatype.PortForward{
+					{
+						GuestPort: 8080,
+						HostPort:  8080,
+						Static:    false,
+					},
+					{
+						GuestPort: 9000,
+						HostPort:  9000,
+					},
+				},
+			},
+			expected: []limatype.PortForward{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fixUpForPlainMode(&tt.config)
+
+			if *tt.config.Plain {
+				for _, pf := range tt.config.PortForwards {
+					if !pf.Static {
+						t.Errorf("Non-static port forward found in plain mode: %+v", pf)
+					}
+				}
+			}
+
+			assert.Equal(t, len(tt.config.PortForwards), len(tt.expected),
+				"Expected %d port forwards, got %d", len(tt.expected), len(tt.config.PortForwards))
+
+			for i, expected := range tt.expected {
+				if i >= len(tt.config.PortForwards) {
+					t.Errorf("Missing port forward at index %d", i)
+					continue
+				}
+				actual := tt.config.PortForwards[i]
+				assert.Equal(t, expected.Static, actual.Static,
+					"Port forward %d: expected Static=%v, got %v", i, expected.Static, actual.Static)
+				assert.Equal(t, expected.GuestPort, actual.GuestPort,
+					"Port forward %d: expected GuestPort=%d, got %d", i, expected.GuestPort, actual.GuestPort)
+				assert.Equal(t, expected.HostPort, actual.HostPort,
+					"Port forward %d: expected HostPort=%d, got %d", i, expected.HostPort, actual.HostPort)
+			}
+		})
+	}
+}