Quellcode durchsuchen

Merge pull request #2434 from nginx-proxy/multiport-support

feat: multiport support
Nicolas Duchon vor 1 Jahr
Ursprung
Commit
db07d90ad8

+ 99 - 0
docs/README.md

@@ -54,6 +54,105 @@ For each host defined into `VIRTUAL_HOST`, the associated virtual port is retrie
 1. From the container's exposed port if there is only one
 1. From the default port 80 when none of the above methods apply
 
+### Multiple ports
+
+If your container expose more than one service on different ports and those services need to be proxied, you'll need to use the `VIRTUAL_HOST_MULTIPORTS` environment variable. This variable takes virtual host, path, port and dest definition in YAML (or JSON) form, and completely override the `VIRTUAL_HOST`, `VIRTUAL_PORT`, `VIRTUAL_PATH` and `VIRTUAL_DEST` environment variables on this container.
+
+The YAML syntax should be easier to write on Docker compose files, while the JSON syntax can be used for CLI invocation.
+
+The expected format is the following:
+
+```yaml
+hostname:
+  path:
+    port: int
+    dest: string
+```
+
+For each hostname entry, `path`, `port` and `dest` are optional and are assigned default values when missing:
+
+- `path` = "/"
+- `port` = default port
+- `dest` = ""
+
+The following examples use an hypothetical container running services on port 80, 8000 and 9000:
+
+#### Multiple ports routed to different hostnames
+
+```yaml
+services:
+  multiport-container:
+    image: somerepo/somecontainer
+    container_name: multiport-container
+    environment:
+      VIRTUAL_HOST_MULTIPORTS: |-
+        www.example.org:
+        service1.example.org:
+          "/":
+            port: 8000
+        service2.example.org:
+          "/":
+            port: 9000
+
+# There is no path dict specified for www.example.org, so it get the default values:
+# www.example.org:
+#   "/":
+#     port: 80 (default port)
+#     dest: ""
+
+# JSON equivalent:
+#     VIRTUAL_HOST_MULTIPORTS: |-
+#       {
+#         "www.example.org": {},
+#         "service1.example.org": { "/": { "port": 8000, "dest": "" } },
+#         "service2.example.org": { "/": { "port": 9000, "dest": "" } }
+#       }
+```
+
+This would result in the following proxy config:
+
+- `www.example.org` -> `multiport-container:80`
+- `service1.example.org` -> `multiport-container:8000`
+- `service2.example.org` -> `multiport-container:9000`
+
+#### Multiple ports routed to same hostname and different paths
+
+```yaml
+services:
+  multiport-container:
+    image: somerepo/somecontainer
+    container_name: multiport-container
+    environment:
+      VIRTUAL_HOST_MULTIPORTS: |-
+        www.example.org:
+          "/":
+          "/service1":
+            port: 8000
+            dest: "/"
+          "/service2":
+            port: 9000
+            dest: "/"
+
+# port and dest are not specified on the / path, so this path is routed
+# to the default port with the default dest value (empty string)
+
+# JSON equivalent:
+#     VIRTUAL_HOST_MULTIPORTS: |-
+#       {
+#         "www.example.org": {
+#           "/": {},
+#           "/service1": { "port": 8000, "dest": "/" },
+#           "/service2": { "port": 9000, "dest": "/" }
+#         }
+#       }
+```
+
+This would result in the following proxy config:
+
+- `www.example.org` -> `multiport-container:80`
+- `www.example.org/service1` -> `multiport-container:8000`
+- `www.example.org/service2` -> `multiport-container:9000`
+
 ⬆️ [back to table of contents](#table-of-contents)
 
 ## Path-based Routing

+ 131 - 83
nginx.tmpl

@@ -128,7 +128,7 @@
     #     exposed ports:{{ range sortObjectsByKeysAsc $.container.Addresses "Port" }} {{ .Port }}/{{ .Proto }}{{ else }} (none){{ end }}
     {{- $default_port := when (eq (len $.container.Addresses) 1) (first $.container.Addresses).Port "80" }}
     #     default port: {{ $default_port }}
-    {{- $port := when (eq $.port "legacy") (or $.container.Env.VIRTUAL_PORT $default_port) $.port }}
+    {{- $port := when (eq $.port "default") $default_port (when (eq $.port "legacy") (or $.container.Env.VIRTUAL_PORT $default_port) $.port) }}
     #     using port: {{ $port }}
     {{- $addr_obj := where $.container.Addresses "Port" $port | first }}
     {{- if and $addr_obj $addr_obj.HostPort }}
@@ -338,49 +338,6 @@ upstream {{ $vpath.upstream }} {
 }
 {{- end }}
 
-{{- /*
-     * Template used as a function to collect virtual path properties from
-     * the given containers.  These properties are "returned" by storing their
-     * values into the provided dot dict.
-     *
-     * The provided dot dict is expected to have the following entries:
-     *   - "Containers": List of container's RuntimeContainer struct.
-     *   - "Upstream_name"
-     *   - "Has_virtual_paths": boolean
-     *   - "Path"
-     *
-     * The return values will be added to the dot dict with keys:
-     * - "dest"
-     * - "proto"
-     * - "network_tag"
-     * - "upstream"
-     * - "loadbalance"
-     * - "keepalive"
-     */}}
-{{- define "get_path_info" }}
-    {{- /* Get the VIRTUAL_PROTO defined by containers w/ the same vhost-vpath, falling back to "http". */}}
-    {{- $proto := trim (or (first (groupByKeys $.Containers "Env.VIRTUAL_PROTO")) "http") }}
-    {{- /* Get the NETWORK_ACCESS defined by containers w/ the same vhost, falling back to "external". */}}
-    {{- $network_tag := or (first (groupByKeys $.Containers "Env.NETWORK_ACCESS")) "external" }}
-
-    {{- $loadbalance := first (keys (groupByLabel $.Containers "com.github.nginx-proxy.nginx-proxy.loadbalance")) }}
-    {{- $keepalive := coalesce (first (keys (groupByLabel $.Containers "com.github.nginx-proxy.nginx-proxy.keepalive"))) "disabled" }}
-
-    {{- $upstream := $.Upstream_name }}
-    {{- $dest := "" }}
-    {{- if $.Has_virtual_paths }}
-        {{- $sum := sha1 $.Path }}
-        {{- $upstream = printf "%s-%s" $upstream $sum }}
-        {{- $dest = or (first (groupByKeys $.Containers "Env.VIRTUAL_DEST")) "" }}
-    {{- end }}
-    {{- $_ := set $ "proto" $proto }}
-    {{- $_ := set $ "network_tag" $network_tag }}
-    {{- $_ := set $ "upstream" $upstream }}
-    {{- $_ := set $ "dest" $dest }}
-    {{- $_ := set $ "loadbalance" $loadbalance }}
-    {{- $_ := set $ "keepalive" $keepalive }}
-{{- end }}
-
 # If we receive X-Forwarded-Proto, pass it through; otherwise, pass along the
 # scheme used to connect to this server
 map $http_x_forwarded_proto $proxy_x_forwarded_proto {
@@ -500,15 +457,131 @@ proxy_set_header X-Original-URI $request_uri;
 proxy_set_header Proxy "";
 {{- end }}
 
-{{- /* Precompute some information about each vhost. */}}
+{{- /* Precompute and store some information about vhost that use VIRTUAL_HOST_MULTIPORTS. */}}
+{{- range $vhosts_yaml, $containers := groupBy $globals.containers "Env.VIRTUAL_HOST_MULTIPORTS" }}
+    {{- /* Print a warning in the config if VIRTUAL_HOST_MULTIPORTS can't be parsed. */}}
+    {{- $parsedVhosts := fromYaml $vhosts_yaml }}
+    {{- if (empty $parsedVhosts) }}
+        {{- $containerNames := list }}
+        {{- range $container := $containers }}
+            {{- $containerNames = append $containerNames $container.Name }}
+        {{- end }}
+# /!\ WARNING: the VIRTUAL_HOST_MULTIPORTS environment variable used for {{ len $containerNames | plural "this container" "those containers" }} is not a valid YAML string:
+# {{ $containerNames | join ", " }}
+        {{- continue }}
+    {{- end }}
+
+    {{- range $hostname, $vhost := $parsedVhosts }}
+        {{- $vhost_data := when (hasKey $globals.vhosts $hostname) (get $globals.vhosts $hostname) (dict) }}
+        {{- $paths := coalesce $vhost_data.paths (dict) }}
+        
+        {{- if (empty $vhost) }}
+            {{ $vhost = dict "/" (dict) }}
+        {{- end }}
+
+        {{- range $path, $vpath := $vhost }}
+            {{- if (empty $vpath) }}
+                {{- $vpath = dict "dest" "" "port" "default" }}
+            {{- end }}
+            {{- $dest := coalesce $vpath.dest "" }}
+            {{- $port := when (hasKey $vpath "port") (toString $vpath.port) "default" }}
+            {{- $path_data := when (hasKey $paths $path) (get $paths $path) (dict) }}
+            {{- $path_ports := when (hasKey $path_data "ports") (get $path_data "ports") (dict) }}
+            {{- $path_port_containers := when (hasKey $path_ports $port) (get $path_ports $port) (list) }}
+            {{- $path_port_containers = concat $path_port_containers $containers }}
+            {{- $_ := set $path_ports $port $path_port_containers }}
+            {{- $_ := set $path_data "ports" $path_ports }}
+            {{- if (not (hasKey $path_data "dest")) }}
+                {{- $_ := set $path_data "dest" $dest }}
+            {{- end }}
+            {{- $_ := set $paths $path $path_data }}
+        {{- end }}
+        {{- $_ := set $vhost_data "paths" $paths }}
+        {{- $is_regexp := hasPrefix "~" $hostname }}
+        {{- $_ := set $vhost_data "upstream_name" (when (or $is_regexp $globals.sha1_upstream_name) (sha1 $hostname) $hostname) }}
+        {{- $_ := set $globals.vhosts $hostname $vhost_data }}
+    {{- end }}
+{{- end }}
+
+{{- /* Precompute and store some information about vhost that use VIRTUAL_HOST. */}}
 {{- range $hostname, $containers := groupByMulti $globals.containers "Env.VIRTUAL_HOST" "," }}
+    {{- /* Ignore containers with VIRTUAL_HOST set to the empty string. */}}
     {{- $hostname = trim $hostname }}
     {{- if not $hostname }}
-        {{- /* Ignore containers with VIRTUAL_HOST set to the empty string. */}}
         {{- continue }}
     {{- end }}
 
-    {{- $certName := first (groupByKeys $containers "Env.CERT_NAME") }}
+    {{- /* Drop containers with both VIRTUAL_HOST and VIRTUAL_HOST_MULTIPORTS set
+         * (VIRTUAL_HOST_MULTIPORTS takes precedence thanks to the previous loop).
+         */}}
+    {{- range $_, $containers_to_drop := groupBy $containers "Env.VIRTUAL_HOST_MULTIPORTS" }}
+        {{- range $container := $containers_to_drop }}
+            {{- $containers = without $containers $container }}
+        {{- end }}
+    {{- end }}
+    {{- if (eq (len $containers) 0) }}
+        {{- continue }}
+    {{- end }}
+
+    {{- $vhost_data := when (hasKey $globals.vhosts $hostname) (get $globals.vhosts $hostname) (dict) }}
+    {{- $paths := coalesce $vhost_data.paths (dict) }}
+
+    {{- $tmp_paths := groupByWithDefault $containers "Env.VIRTUAL_PATH" "/" }}
+
+    {{- range $path, $containers := $tmp_paths }}
+        {{- $dest := or (first (groupByKeys $containers "Env.VIRTUAL_DEST")) "" }}
+        {{- $port := "legacy" }}
+        {{- $path_data := when (hasKey $paths $path) (get $paths $path) (dict) }}
+        {{- $path_ports := when (hasKey $path_data "ports") (get $path_data "ports") (dict) }}
+        {{- $path_port_containers := when (hasKey $path_ports $port) (get $path_ports $port) (list) }}
+        {{- $path_port_containers = concat $path_port_containers $containers }}
+        {{- $_ := set $path_ports $port $path_port_containers }}
+        {{- $_ := set $path_data "ports" $path_ports }}
+        {{- if (not (hasKey $path_data "dest")) }}
+            {{- $_ := set $path_data "dest" $dest }}
+        {{- end }}
+        {{- $_ := set $paths $path $path_data }}
+    {{- end }}
+    {{- $_ := set $vhost_data "paths" $paths }}
+    {{- $is_regexp := hasPrefix "~" $hostname }}
+    {{- $_ := set $vhost_data "upstream_name" (when (or $is_regexp $globals.sha1_upstream_name) (sha1 $hostname) $hostname) }}
+    {{- $_ := set $globals.vhosts $hostname $vhost_data }}
+{{- end }}
+
+{{- /* Loop over $globals.vhosts and update it with the remaining informations about each vhost. */}}
+{{- range $hostname, $vhost_data := $globals.vhosts }}
+    {{- $vhost_containers := list }}
+    {{- range $path, $vpath_data := $vhost_data.paths }}
+        {{- $vpath_containers := list }}
+        {{- range $port, $vport_containers := $vpath_data.ports }}
+            {{ $vpath_containers = concat $vpath_containers $vport_containers }}
+        {{- end }}
+
+        {{- /* Get the VIRTUAL_PROTO defined by containers w/ the same vhost-vpath, falling back to "http". */}}
+        {{- $proto := trim (or (first (groupByKeys $vpath_containers "Env.VIRTUAL_PROTO")) "http") }}
+        {{- /* Get the NETWORK_ACCESS defined by containers w/ the same vhost, falling back to "external". */}}
+        {{- $network_tag := or (first (groupByKeys $vpath_containers "Env.NETWORK_ACCESS")) "external" }}
+
+        {{- $loadbalance := first (keys (groupByLabel $vpath_containers "com.github.nginx-proxy.nginx-proxy.loadbalance")) }}
+        {{- $keepalive := coalesce (first (keys (groupByLabel $vpath_containers "com.github.nginx-proxy.nginx-proxy.keepalive"))) "disabled" }}
+
+        {{- $upstream := $vhost_data.upstream_name }}
+        {{- if (not (eq $path "/")) }}
+            {{- $sum := sha1 $path }}
+            {{- $upstream = printf "%s-%s" $upstream $sum }}
+        {{- end }}
+
+        {{- $_ := set $vpath_data "proto" $proto }}
+        {{- $_ := set $vpath_data "network_tag" $network_tag }}
+        {{- $_ := set $vpath_data "upstream" $upstream }}
+        {{- $_ := set $vpath_data "loadbalance" $loadbalance }}
+        {{- $_ := set $vpath_data "keepalive" $keepalive }}
+        {{- $_ := set $vhost_data.paths $path $vpath_data }}
+
+        {{ $vhost_containers = concat $vhost_containers $vpath_containers }}
+    {{- end }}
+
+    {{- $certName := first (groupByKeys $vhost_containers "Env.CERT_NAME") }}
     {{- $vhostCert := closest (dir "/etc/nginx/certs") (printf "%s.crt" $hostname) }}
     {{- $vhostCert = trimSuffix ".crt" $vhostCert }}
     {{- $vhostCert = trimSuffix ".key" $vhostCert }}
@@ -516,49 +589,23 @@ proxy_set_header Proxy "";
     {{- $cert_ok := and (ne $cert "") (exists (printf "/etc/nginx/certs/%s.crt" $cert)) (exists (printf "/etc/nginx/certs/%s.key" $cert)) }}
 
     {{- $default := eq $globals.Env.DEFAULT_HOST $hostname }}
-    {{- $https_method := or (first (groupByKeys $containers "Env.HTTPS_METHOD")) $globals.Env.HTTPS_METHOD "redirect" }}
-    {{- $http2_enabled := parseBool (or (first (keys (groupByLabel $containers "com.github.nginx-proxy.nginx-proxy.http2.enable"))) $globals.Env.ENABLE_HTTP2 "true")}}
-    {{- $http3_enabled := parseBool (or (first (keys (groupByLabel $containers "com.github.nginx-proxy.nginx-proxy.http3.enable"))) $globals.Env.ENABLE_HTTP3 "false")}}
-
-    {{- $is_regexp := hasPrefix "~" $hostname }}
-    {{- $upstream_name := when (or $is_regexp $globals.sha1_upstream_name) (sha1 $hostname) $hostname }}
+    {{- $https_method := or (first (groupByKeys $vhost_containers "Env.HTTPS_METHOD")) $globals.Env.HTTPS_METHOD "redirect" }}
+    {{- $http2_enabled := parseBool (or (first (keys (groupByLabel $vhost_containers "com.github.nginx-proxy.nginx-proxy.http2.enable"))) $globals.Env.ENABLE_HTTP2 "true")}}
+    {{- $http3_enabled := parseBool (or (first (keys (groupByLabel $vhost_containers "com.github.nginx-proxy.nginx-proxy.http3.enable"))) $globals.Env.ENABLE_HTTP3 "false")}}
 
     {{- /* Get the SERVER_TOKENS defined by containers w/ the same vhost, falling back to "". */}}
-    {{- $server_tokens := trim (or (first (groupByKeys $containers "Env.SERVER_TOKENS")) "") }}
+    {{- $server_tokens := trim (or (first (groupByKeys $vhost_containers "Env.SERVER_TOKENS")) "") }}
 
     {{- /* Get the SSL_POLICY defined by containers w/ the same vhost, falling back to empty string (use default). */}}
-    {{- $ssl_policy := or (first (groupByKeys $containers "Env.SSL_POLICY")) "" }}
+    {{- $ssl_policy := or (first (groupByKeys $vhost_containers "Env.SSL_POLICY")) "" }}
 
     {{- /* Get the HSTS defined by containers w/ the same vhost, falling back to "max-age=31536000". */}}
-    {{- $hsts := or (first (groupByKeys $containers "Env.HSTS")) (or $globals.Env.HSTS "max-age=31536000") }}
+    {{- $hsts := or (first (groupByKeys $vhost_containers "Env.HSTS")) (or $globals.Env.HSTS "max-age=31536000") }}
 
     {{- /* Get the VIRTUAL_ROOT By containers w/ use fastcgi root */}}
-    {{- $vhost_root := or (first (groupByKeys $containers "Env.VIRTUAL_ROOT")) "/var/www/public" }}
-
-
-    {{- $tmp_paths := groupBy $containers "Env.VIRTUAL_PATH" }}
-    {{- $has_virtual_paths := gt (len $tmp_paths) 0}}
-    {{- if not $has_virtual_paths }}
-        {{- $tmp_paths = dict "/" $containers }}
-    {{- end }}
+    {{- $vhost_root := or (first (groupByKeys $vhost_containers "Env.VIRTUAL_ROOT")) "/var/www/public" }}
 
-    {{- $paths := dict }}
-
-    {{- range $path, $containers := $tmp_paths }}
-        {{- $args := dict "Containers" $containers "Path" $path "Upstream_name" $upstream_name "Has_virtual_paths" $has_virtual_paths }}
-        {{- template "get_path_info" $args }}
-        {{- $_ := set $paths $path (dict
-            "ports" (dict "legacy" $containers)
-            "dest" $args.dest
-            "proto" $args.proto
-            "network_tag" $args.network_tag
-            "upstream" $args.upstream
-            "loadbalance" $args.loadbalance
-            "keepalive" $args.keepalive
-        ) }}
-    {{- end }}
-
-    {{- $_ := set $globals.vhosts $hostname (dict
+    {{- $vhost_data = merge $vhost_data (dict
         "cert" $cert
         "cert_ok" $cert_ok
         "default" $default
@@ -566,13 +613,14 @@ proxy_set_header Proxy "";
         "https_method" $https_method
         "http2_enabled" $http2_enabled
         "http3_enabled" $http3_enabled
-        "paths" $paths
         "server_tokens" $server_tokens
         "ssl_policy" $ssl_policy
         "vhost_root" $vhost_root
     ) }}
+    {{- $_ := set $globals.vhosts $hostname $vhost_data }}
 {{- end }}
 
+
 {{- /*
      * If needed, create a catch-all fallback server to send an error code to
      * clients that request something from an unknown vhost.
@@ -808,4 +856,4 @@ server {
     }
     {{- end }}
 }
-{{- end }}
+{{- end }}

+ 0 - 10
test/test_composev2.py

@@ -1,10 +0,0 @@
-import pytest
-
-def test_unknown_virtual_host(docker_compose, nginxproxy):
-    r = nginxproxy.get("http://nginx-proxy/")
-    assert r.status_code == 503
-
-def test_forwards_to_whoami(docker_compose, nginxproxy):
-    r = nginxproxy.get("http://web.nginx-proxy.example/port")
-    assert r.status_code == 200   
-    assert r.text == "answer from port 81\n"

+ 0 - 15
test/test_composev2.yml

@@ -1,15 +0,0 @@
-version: "2"
-
-services:
-  nginx-proxy:
-    image: nginxproxy/nginx-proxy:test
-    volumes:
-      - /var/run/docker.sock:/tmp/docker.sock:ro
-
-  web:
-    image: web
-    expose:
-      - "81"
-    environment:
-      WEB_PORTS: 81
-      VIRTUAL_HOST: web.nginx-proxy.example

+ 0 - 0
test/test_log_format.py → test/test_logs/test_log_format.py


+ 0 - 0
test/test_log_format.yml → test/test_logs/test_log_format.yml


+ 0 - 0
test/test_log_json.py → test/test_logs/test_log_json.py


+ 0 - 0
test/test_log_json.yml → test/test_logs/test_log_json.yml


+ 0 - 0
test/test_log_json_format.py → test/test_logs/test_log_json_format.py


+ 0 - 0
test/test_log_json_format.yml → test/test_logs/test_log_json_format.yml


+ 39 - 0
test/test_multiports/test_multiports-base-json.py

@@ -0,0 +1,39 @@
+import pytest
+
+
+def test_virtual_host_is_dropped_when_using_multiports(docker_compose, nginxproxy):
+    r = nginxproxy.get("http://notskipped.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 81\n" in r.text
+    r = nginxproxy.get("http://skipped.nginx-proxy.tld/")
+    assert r.status_code == 503
+
+
+def test_answer_is_served_from_port_80_by_default(docker_compose, nginxproxy):
+    r = nginxproxy.get("http://port80.a.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 80\n" in r.text
+    r = nginxproxy.get("http://port80.b.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 80\n" in r.text
+    r = nginxproxy.get("http://port80.c.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 80\n" in r.text
+
+
+def test_answer_is_served_from_chosen_ports(docker_compose, nginxproxy):
+    r = nginxproxy.get("http://port8080.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 8080\n" in r.text
+    r = nginxproxy.get("http://port9000.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 9000\n" in r.text
+
+
+def test_answer_is_served_from_chosen_ports_and_dest(docker_compose, nginxproxy):
+    r = nginxproxy.get("http://virtualpaths.nginx-proxy.tld/rootdest/port")
+    assert r.status_code == 200
+    assert "answer from port 10001\n" in r.text
+    r = nginxproxy.get("http://virtualpaths.nginx-proxy.tld/customdest")
+    assert r.status_code == 200
+    assert "answer from port 10002\n" in r.text

+ 77 - 0
test/test_multiports/test_multiports-base-json.yml

@@ -0,0 +1,77 @@
+version: "2"
+
+services:
+  skipvirtualhost:
+    image: web
+    expose:
+      - "81"
+    environment:
+      WEB_PORTS: "81"
+      VIRTUAL_HOST: skipped.nginx-proxy.tld
+      VIRTUAL_HOST_MULTIPORTS: |-
+        {
+          "notskipped.nginx-proxy.tld": {}
+        }
+
+  defaultport:
+    image: web
+    expose:
+      - "80"
+      - "8080"
+    environment:
+      WEB_PORTS: "80 8080"
+      VIRTUAL_HOST_MULTIPORTS: |-
+        {
+          "port80.a.nginx-proxy.tld": {},
+          "port80.b.nginx-proxy.tld": {},
+          "port80.c.nginx-proxy.tld": {
+            "/": {}
+          }
+        }
+
+  multiports:
+    image: web
+    expose:
+      - "8080"
+      - "9000"
+    environment:
+      WEB_PORTS: "8080 9000"
+      VIRTUAL_HOST_MULTIPORTS: |-
+        {
+          "port8080.nginx-proxy.tld": {
+            "/": {
+              "port": 8080
+            }
+          },
+          "port9000.nginx-proxy.tld": {
+            "/": {
+              "port": 9000
+            }
+          }
+        }
+  
+  virtualpath:
+    image: web
+    expose:
+      - "10001"
+      - "10002"
+    environment:
+      WEB_PORTS: "10001 10002"
+      VIRTUAL_HOST_MULTIPORTS: |-
+        {
+          "virtualpaths.nginx-proxy.tld": {
+            "/rootdest": {
+              "port": 10001,
+              "dest": "/"
+            },
+            "/customdest": {
+              "port": 10002,
+              "dest": "/port"
+            }
+          }
+        }
+
+  sut:
+    image: nginxproxy/nginx-proxy:test
+    volumes:
+      - /var/run/docker.sock:/tmp/docker.sock:ro

+ 39 - 0
test/test_multiports/test_multiports-base-yaml.py

@@ -0,0 +1,39 @@
+import pytest
+
+
+def test_virtual_host_is_dropped_when_using_multiports(docker_compose, nginxproxy):
+    r = nginxproxy.get("http://notskipped.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 81\n" in r.text
+    r = nginxproxy.get("http://skipped.nginx-proxy.tld/")
+    assert r.status_code == 503
+
+
+def test_answer_is_served_from_port_80_by_default(docker_compose, nginxproxy):
+    r = nginxproxy.get("http://port80.a.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 80\n" in r.text
+    r = nginxproxy.get("http://port80.b.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 80\n" in r.text
+    r = nginxproxy.get("http://port80.c.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 80\n" in r.text
+
+
+def test_answer_is_served_from_chosen_ports(docker_compose, nginxproxy):
+    r = nginxproxy.get("http://port8080.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 8080\n" in r.text
+    r = nginxproxy.get("http://port9000.nginx-proxy.tld/port")
+    assert r.status_code == 200
+    assert "answer from port 9000\n" in r.text
+
+
+def test_answer_is_served_from_chosen_ports_and_dest(docker_compose, nginxproxy):
+    r = nginxproxy.get("http://virtualpaths.nginx-proxy.tld/rootdest/port")
+    assert r.status_code == 200
+    assert "answer from port 10001\n" in r.text
+    r = nginxproxy.get("http://virtualpaths.nginx-proxy.tld/customdest")
+    assert r.status_code == 200
+    assert "answer from port 10002\n" in r.text

+ 61 - 0
test/test_multiports/test_multiports-base-yaml.yml

@@ -0,0 +1,61 @@
+version: "2"
+
+services:
+  skipvirtualhost:
+    image: web
+    expose:
+      - "81"
+    environment:
+      WEB_PORTS: "81"
+      VIRTUAL_HOST: skipped.nginx-proxy.tld
+      VIRTUAL_HOST_MULTIPORTS: |-
+        notskipped.nginx-proxy.tld:
+
+  defaultport:
+    image: web
+    expose:
+      - "80"
+      - "8080"
+    environment:
+      WEB_PORTS: "80 8080"
+      VIRTUAL_HOST_MULTIPORTS: |-
+        port80.a.nginx-proxy.tld:
+        port80.b.nginx-proxy.tld:
+        port80.c.nginx-proxy.tld:
+          "/":
+
+  multiports:
+    image: web
+    expose:
+      - "8080"
+      - "9000"
+    environment:
+      WEB_PORTS: "8080 9000"
+      VIRTUAL_HOST_MULTIPORTS: |-
+        port8080.nginx-proxy.tld:
+          "/":
+            port: 8080
+        port9000.nginx-proxy.tld:
+          "/":
+            port: 9000
+  
+  virtualpath:
+    image: web
+    expose:
+      - "10001"
+      - "10002"
+    environment:
+      WEB_PORTS: "10001 10002"
+      VIRTUAL_HOST_MULTIPORTS: |-
+        virtualpaths.nginx-proxy.tld:
+          "/rootdest":
+            port: 10001
+            dest: "/"
+          "/customdest":
+            port: 10002
+            dest: "/port"
+
+  sut:
+    image: nginxproxy/nginx-proxy:test
+    volumes:
+      - /var/run/docker.sock:/tmp/docker.sock:ro

+ 18 - 0
test/test_multiports/test_multiports-invalid-syntax.py

@@ -0,0 +1,18 @@
+import pytest
+import re
+
+
+def test_virtual_hosts_with_syntax_error_should_not_be_reachable(docker_compose, nginxproxy):
+    r = nginxproxy.get("http://test1.nginx-proxy.tld")
+    assert r.status_code == 503
+    r = nginxproxy.get("http://test2.nginx-proxy.tld")
+    assert r.status_code == 503
+
+
+def test_config_should_have_multiports_warning_comments(docker_compose, nginxproxy):
+    conf = nginxproxy.get_conf().decode('ASCII')
+    matches = re.findall(r"the VIRTUAL_HOST_MULTIPORTS environment variable used for this container is not a valid YAML string", conf)
+    assert len(matches) == 3
+    assert "# invalidsyntax" in conf
+    assert "# hostnamerepeat" in conf
+    assert "# pathrepeat" in conf

+ 44 - 0
test/test_multiports/test_multiports-invalid-syntax.yml

@@ -0,0 +1,44 @@
+version: "2"
+
+services:
+  invalidsyntax:
+    image: web
+    container_name: invalidsyntax
+    expose:
+      - "80"
+    environment:
+      WEB_PORTS: "80"
+      VIRTUAL_HOST_MULTIPORTS: |-
+        test1.nginx-proxy.tld
+        test2.nginx-proxy.tld:
+
+  hostnamerepeat:
+    image: web
+    container_name: hostnamerepeat
+    expose:
+      - "80"
+    environment:
+      WEB_PORTS: "80"
+      VIRTUAL_HOST_MULTIPORTS: |-
+        test1.nginx-proxy.tld:
+        test1.nginx-proxy.tld:
+
+  pathrepeat:
+    image: web
+    container_name: pathrepeat
+    expose:
+      - "8080"
+      - "9000"
+    environment:
+      WEB_PORTS: "8080 9000"
+      VIRTUAL_HOST_MULTIPORTS: |-
+        test1.nginx-proxy.tld:
+          "/":
+            port: 8080
+          "/":
+            port: 9000
+
+  sut:
+    image: nginxproxy/nginx-proxy:test
+    volumes:
+      - /var/run/docker.sock:/tmp/docker.sock:ro

+ 14 - 0
test/test_multiports/test_multiports-merge.py

@@ -0,0 +1,14 @@
+import backoff
+import pytest
+
+
+def test_multiports_and_legacy_configs_should_be_merged(docker_compose, nginxproxy):
+    @backoff.on_predicate(backoff.constant, lambda r: r == False, interval=.5, max_tries=20, jitter=None)
+    def answer_contains(answer, url):
+        return answer in nginxproxy.get(url).text
+
+    assert answer_contains("80", "http://merged.nginx-proxy.tld/port")
+    assert answer_contains("81", "http://merged.nginx-proxy.tld/port")
+
+    assert answer_contains("9090", "http://merged.nginx-proxy.tld/foo/port")
+    assert answer_contains("9191", "http://merged.nginx-proxy.tld/foo/port")

+ 41 - 0
test/test_multiports/test_multiports-merge.yml

@@ -0,0 +1,41 @@
+version: "2"
+
+services:
+  merged-singleport:
+    image: web
+    expose:
+      - "80"
+    environment:
+      WEB_PORTS: "80"
+      VIRTUAL_HOST: merged.nginx-proxy.tld
+
+  merged-singleport-virtual-path:
+    image: web
+    expose:
+      - "9090"
+    environment:
+      WEB_PORTS: "9090"
+      VIRTUAL_HOST: merged.nginx-proxy.tld
+      VIRTUAL_PORT: "9090"
+      VIRTUAL_PATH: "/foo"
+      VIRTUAL_DEST: "/"
+
+  merged-multiports:
+    image: web
+    expose:
+      - "81"
+      - "9191"
+    environment:
+      WEB_PORTS: "81 9191"
+      VIRTUAL_HOST_MULTIPORTS: |-
+        merged.nginx-proxy.tld:
+          "/":
+            port: 81
+          "/foo":
+            port: 9191
+            dest: "/"
+
+  sut:
+    image: nginxproxy/nginx-proxy:test
+    volumes:
+      - /var/run/docker.sock:/tmp/docker.sock:ro

+ 0 - 0
test/test_multiple-ports/test_VIRTUAL_PORT-single-different-from-single-port.py → test/test_ports/test_VIRTUAL_PORT-single-different-from-single-port.py


+ 0 - 0
test/test_multiple-ports/test_VIRTUAL_PORT-single-different-from-single-port.yml → test/test_ports/test_VIRTUAL_PORT-single-different-from-single-port.yml


+ 0 - 0
test/test_multiple-ports/test_VIRTUAL_PORT.py → test/test_ports/test_VIRTUAL_PORT.py


+ 0 - 0
test/test_multiple-ports/test_VIRTUAL_PORT.yml → test/test_ports/test_VIRTUAL_PORT.yml


+ 0 - 0
test/test_multiple-ports/test_default-80.py → test/test_ports/test_default-80.py


+ 0 - 0
test/test_multiple-ports/test_default-80.yml → test/test_ports/test_default-80.yml


+ 0 - 0
test/test_multiple-ports/test_single-port-not-80.py → test/test_ports/test_single-port-not-80.py


+ 0 - 0
test/test_multiple-ports/test_single-port-not-80.yml → test/test_ports/test_single-port-not-80.yml


+ 0 - 0
test/test_default-root-none.py → test/test_virtual-path/test_default-root-none.py


+ 0 - 0
test/test_default-root-none.yml → test/test_virtual-path/test_default-root-none.yml