diff --git a/ovn/controller/binding.c b/ovn/controller/binding.c
index a07c327..dcfcd23 100644
--- a/ovn/controller/binding.c
+++ b/ovn/controller/binding.c
@@ -154,6 +154,15 @@ binding_run(struct controller_ctx *ctx, const struct ovsrec_bridge *br_int,
                 }
                 sbrec_port_binding_set_chassis(binding_rec, chassis_rec);
             }
+        } else if (!strcmp(binding_rec->type, "l2gateway")
+                   && binding_rec->chassis == chassis_rec) {
+            /* A locally bound gateway port.
+             *
+             * ovn-controller does not bind gateway ports itself.
+             * Choosing a chassis for a gateway port is left
+             * up to an entity external to OVN. */
+            sset_add(all_lports, binding_rec->logical_port);
+            add_local_datapath(local_datapaths, binding_rec);
         } else if (chassis_rec && binding_rec->chassis == chassis_rec
                    && strcmp(binding_rec->type, "gateway")) {
             if (ctx->ovnsb_idl_txn) {
diff --git a/ovn/controller/ovn-controller.8.xml b/ovn/controller/ovn-controller.8.xml
index 1ee3a6e..228a8cd 100644
--- a/ovn/controller/ovn-controller.8.xml
+++ b/ovn/controller/ovn-controller.8.xml
@@ -184,10 +184,10 @@
           The presence of this key identifies a patch port as one created by
           <code>ovn-controller</code> to connect the integration bridge and
           another bridge to implement a <code>localnet</code> logical port.
-          Its value is the name of the logical port with type=localnet that
-          the port implements.
-          See <code>external_ids:ovn-bridge-mappings</code>, above,
-          for more information.
+          Its value is the name of the logical port with <code>type</code>
+          set to <code>localnet</code> that the port implements. See
+          <code>external_ids:ovn-bridge-mappings</code>, above, for more
+          information.
         </p>
 
         <p>
@@ -199,6 +199,29 @@
       </dd>
 
       <dt>
+        <code>external-ids:ovn-gateway-port</code> in the <code>Port</code>
+        table
+      </dt>
+      <dd>
+        <p>
+          The presence of this key identifies a patch port as one created by
+          <code>ovn-controller</code> to connect the integration bridge and
+          another bridge to implement a <code>gateway</code> logical port.
+          Its value is the name of the logical port with <code>type</code>
+          set to <code>gateway</code> that the port implements. See
+          <code>external_ids:ovn-bridge-mappings</code>, above, for more
+          information.
+        </p>
+
+        <p>
+          Each <code>gateway</code> logical port is implemented as a pair
+          of patch ports, one in the integration bridge, one in a different
+          bridge, with the same <code>external-ids:ovn-gateway-port</code>
+          value.
+        </p>
+      </dd>
+
+      <dt>
         <code>external-ids:ovn-logical-patch-port</code> in the
         <code>Port</code> table
       </dt>
diff --git a/ovn/controller/patch.c b/ovn/controller/patch.c
index 652466b..edf3baf 100644
--- a/ovn/controller/patch.c
+++ b/ovn/controller/patch.c
@@ -134,7 +134,8 @@ static void
 add_bridge_mappings(struct controller_ctx *ctx,
                     const struct ovsrec_bridge *br_int,
                     struct shash *existing_ports,
-                    struct hmap *local_datapaths)
+                    struct hmap *local_datapaths,
+                    const char *chassis_id)
 {
     /* Get ovn-bridge-mappings. */
     const char *mappings_cfg = "";
@@ -175,6 +176,7 @@ add_bridge_mappings(struct controller_ctx *ctx,
 
     const struct sbrec_port_binding *binding;
     SBREC_PORT_BINDING_FOR_EACH (binding, ctx->ovnsb_idl) {
+        const char *patch_port_id;
         if (!strcmp(binding->type, "localnet")) {
             struct local_datapath *ld
                 = get_local_datapath(local_datapaths,
@@ -195,31 +197,40 @@ add_bridge_mappings(struct controller_ctx *ctx,
                 continue;
             }
             ld->localnet_port = binding;
+            patch_port_id = "ovn-localnet-port";
+        } else if (!strcmp(binding->type, "l2gateway")) {
+            if (!binding->chassis || strcmp(chassis_id, binding->chassis->name)) {
+                /* This gateway port is not bound to this chassis, so we should
+                 * not create any patch ports for it. */
+                continue;
+            }
+            patch_port_id = "ovn-gateway-port";
         } else {
-            /* Not a binding for a localnet port. */
+            /* not a localnet or gateway port. */
             continue;
         }
 
         const char *network = smap_get(&binding->options, "network_name");
         if (!network) {
             static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 1);
-            VLOG_ERR_RL(&rl, "localnet port '%s' has no network name.",
-                         binding->logical_port);
+            VLOG_ERR_RL(&rl, "%s port '%s' has no network name.",
+                         binding->type, binding->logical_port);
             continue;
         }
         struct ovsrec_bridge *br_ln = shash_find_data(&bridge_mappings, network);
         if (!br_ln) {
             static struct vlog_rate_limit rl = VLOG_RATE_LIMIT_INIT(5, 1);
-            VLOG_ERR_RL(&rl, "bridge not found for localnet port '%s' "
-                    "with network name '%s'", binding->logical_port, network);
+            VLOG_ERR_RL(&rl, "bridge not found for %s port '%s' "
+                    "with network name '%s'",
+                    binding->type, binding->logical_port, network);
             continue;
         }
 
         char *name1 = patch_port_name(br_int->name, binding->logical_port);
         char *name2 = patch_port_name(binding->logical_port, br_int->name);
-        create_patch_port(ctx, "ovn-localnet-port", binding->logical_port,
+        create_patch_port(ctx, patch_port_id, binding->logical_port,
                           br_int, name1, br_ln, name2, existing_ports);
-        create_patch_port(ctx, "ovn-localnet-port", binding->logical_port,
+        create_patch_port(ctx, patch_port_id, binding->logical_port,
                           br_ln, name2, br_int, name1, existing_ports);
         free(name1);
         free(name2);
@@ -327,8 +338,9 @@ patch_run(struct controller_ctx *ctx, const struct ovsrec_bridge *br_int,
     struct shash existing_ports = SHASH_INITIALIZER(&existing_ports);
     const struct ovsrec_port *port;
     OVSREC_PORT_FOR_EACH (port, ctx->ovs_idl) {
-        if (smap_get(&port->external_ids, "ovn-localnet-port") ||
-            smap_get(&port->external_ids, "ovn-logical-patch-port")) {
+        if (smap_get(&port->external_ids, "ovn-localnet-port")
+            || smap_get(&port->external_ids, "ovn-gateway-port")
+            || smap_get(&port->external_ids, "ovn-logical-patch-port")) {
             shash_add(&existing_ports, port->name, port);
         }
     }
@@ -336,7 +348,7 @@ patch_run(struct controller_ctx *ctx, const struct ovsrec_bridge *br_int,
     /* Create in the database any patch ports that should exist.  Remove from
      * 'existing_ports' any patch ports that do exist in the database and
      * should be there. */
-    add_bridge_mappings(ctx, br_int, &existing_ports, local_datapaths);
+    add_bridge_mappings(ctx, br_int, &existing_ports, local_datapaths, chassis_id);
     add_logical_patch_ports(ctx, br_int, chassis_id, &existing_ports,
                             patched_datapaths);
 
diff --git a/ovn/controller/physical.c b/ovn/controller/physical.c
index 85528e0..d83048e 100644
--- a/ovn/controller/physical.c
+++ b/ovn/controller/physical.c
@@ -169,6 +169,8 @@ physical_run(struct controller_ctx *ctx, enum mf_field_id mff_ovn_geneve,
 
         const char *localnet = smap_get(&port_rec->external_ids,
                                         "ovn-localnet-port");
+        const char *gateway = smap_get(&port_rec->external_ids,
+                                        "ovn-gateway-port");
         const char *logpatch = smap_get(&port_rec->external_ids,
                                         "ovn-logical-patch-port");
 
@@ -191,6 +193,10 @@ physical_run(struct controller_ctx *ctx, enum mf_field_id mff_ovn_geneve,
                 /* localnet patch ports can be handled just like VIFs. */
                 simap_put(&localvif_to_ofport, localnet, ofport);
                 break;
+            } else if (is_patch && gateway) {
+                /* gateway patch ports can be handled just like VIFs. */
+                simap_put(&localvif_to_ofport, gateway, ofport);
+                break;
             } else if (is_patch && logpatch) {
                 /* Logical patch ports can be handled just like VIFs. */
                 simap_put(&localvif_to_ofport, logpatch, ofport);
@@ -269,13 +275,13 @@ physical_run(struct controller_ctx *ctx, enum mf_field_id mff_ovn_geneve,
          *       OpenFlow port for the VIF.  'tun' will be NULL.
          *
          *       The same logic handles logical patch ports, as well as
-         *       localnet patch ports.
+         *       localnet and gateway patch ports.
          *
          *       For a container nested inside a VM and accessible via a VLAN,
          *       'tag' is the VLAN ID; otherwise 'tag' is 0.
          *
-         *       For a localnet patch port, if a VLAN ID was configured, 'tag'
-         *       is set to that VLAN ID; otherwise 'tag' is 0.
+         *       For a localnet or gateway patch port, if a VLAN ID was
+         *       configured, 'tag' is set to that VLAN ID; otherwise 'tag' is 0.
          *
          *     - If the port is on a remote chassis, the OpenFlow port for a
          *       tunnel to the VIF's remote chassis.  'tun' identifies that
@@ -297,7 +303,9 @@ physical_run(struct controller_ctx *ctx, enum mf_field_id mff_ovn_geneve,
         } else {
             ofport = u16_to_ofp(simap_get(&localvif_to_ofport,
                                           binding->logical_port));
-            if (!strcmp(binding->type, "localnet") && ofport && binding->tag) {
+            if ((!strcmp(binding->type, "localnet")
+                 || !strcmp(binding->type, "l2gateway"))
+                && ofport && binding->tag) {
                 tag = *binding->tag;
             }
         }
@@ -355,7 +363,8 @@ physical_run(struct controller_ctx *ctx, enum mf_field_id mff_ovn_geneve,
             /* Match a VLAN tag and strip it, including stripping priority tags
              * (e.g. VLAN ID 0).  In the latter case we'll add a second flow
              * for frames that lack any 802.1Q header later. */
-            if (tag || !strcmp(binding->type, "localnet")) {
+            if (tag || !strcmp(binding->type, "localnet")
+                || !strcmp(binding->type, "l2gateway")) {
                 match_set_dl_vlan(&match, htons(tag));
                 ofpact_put_STRIP_VLAN(&ofpacts);
             }
@@ -392,7 +401,8 @@ physical_run(struct controller_ctx *ctx, enum mf_field_id mff_ovn_geneve,
             ofctrl_add_flow(flow_table, OFTABLE_PHY_TO_LOG,
                             tag ? 150 : 100, &match, &ofpacts);
 
-            if (!tag && !strcmp(binding->type, "localnet")) {
+            if (!tag && (!strcmp(binding->type, "localnet")
+                         || !strcmp(binding->type, "l2gateway"))) {
                 /* Add a second flow for frames that lack any 802.1Q
                  * header.  For these, drop the OFPACT_STRIP_VLAN
                  * action. */
diff --git a/ovn/ovn-nb.xml b/ovn/ovn-nb.xml
index 6355c44..17881f5 100644
--- a/ovn/ovn-nb.xml
+++ b/ovn/ovn-nb.xml
@@ -134,6 +134,11 @@
             to model direct connectivity to an existing network.
           </dd>
 
+          <dt><code>l2gateway</code></dt>
+          <dd>
+            A connection to a physical network.
+          </dd>
+
           <dt><code>vtep</code></dt>
           <dd>
             A port to a logical switch on a VTEP gateway.
@@ -175,6 +180,20 @@
         </column>
       </group>
 
+      <group title="Options for l2gateway ports">
+        <p>
+          These options apply when <ref column="type"/> is
+          <code>l2gateway</code>.
+        </p>
+
+        <column name="options" key="network_name">
+          Required.  The name of the network to which the <code>l2gateway</code>
+          port is connected.  The gateway, via <code>ovn-controller</code>,
+          uses its local configuration to determine exactly how to connect to
+          this network.
+        </column>
+      </group>
+
       <group title="Options for vtep ports">
         <p>
           These options apply when <ref column="type"/> is <code>vtep</code>.
@@ -658,7 +677,7 @@
         </p>
       </column>
     </group>
-    
+
     <group title="Common Columns">
       <column name="external_ids">
         See <em>External IDs</em> at the beginning of this document.
diff --git a/ovn/ovn-sb.xml b/ovn/ovn-sb.xml
index e9353f3..0387ed1 100644
--- a/ovn/ovn-sb.xml
+++ b/ovn/ovn-sb.xml
@@ -1295,10 +1295,39 @@ tcp.flags = RST;
       </column>
 
       <column name="chassis">
-        The physical location of the logical port.  To successfully identify a
-        chassis, this column must be a <ref table="Chassis"/> record.  This is
-        populated by
-        <code>ovn-controller</code>/<code>ovn-controller-vtep</code>.
+        The meaning of this column depends on the value of the <ref column="type"/>
+        column.  This is the meaning for each <ref column="type"/>
+
+        <dl>
+          <dt>(empty string)</dt>
+          <dd>
+            The physical location of the logical port.  To successfully identify a
+            chassis, this column must be a <ref table="Chassis"/> record.  This is
+            populated by <code>ovn-controller</code>.
+          </dd>
+
+          <dt>vtep</dt>
+          <dd>
+            The physical location of the hardware_vtep gateway.  To successfully
+            identify a chassis, this column must be a <ref table="Chassis"/> record.
+            This is populated by <code>ovn-controller-vtep</code>.
+          </dd>
+
+          <dt>localnet</dt>
+          <dd>
+            Always empty.  A localnet port is realized on every chassis that has
+            connectivity to the corresponding physical network.
+          </dd>
+
+          <dt>l2gateway</dt>
+          <dd>
+            The physical location of this L2 gateway.  To successfully identify a
+            chassis, this column must be a <ref table="Chassis"/> record.
+            This is populated by an entity external to OVN, either manually or by
+            a CMS.
+          </dd>
+        </dl>
+
       </column>
 
       <column name="tunnel_key">
@@ -1362,6 +1391,14 @@ tcp.flags = RST;
             to model direct connectivity to an existing network.
           </dd>
 
+          <dt><code>l2gateway</code></dt>
+          <dd>
+            A connection to a physical network.  The chassis this
+            <ref table="Port_Binding"/> is bound to will serve as
+            an L2 gateway to the network named by
+            <ref column="options" table="Port_Binding"/>:<code>network_name</code>.
+          </dd>
+
           <dt><code>vtep</code></dt>
           <dd>
             A port to a logical switch on a VTEP gateway chassis.  In order to
@@ -1444,6 +1481,36 @@ tcp.flags = RST;
       </column>
     </group>
 
+    <group title="Gateway Options">
+      <p>
+        These options apply to logical ports with <ref column="type"/> of
+        <code>l2gateway</code>.
+      </p>
+
+      <column name="options" key="network_name">
+        Required.  <code>ovn-controller</code> uses the configuration entry
+        <code>ovn-bridge-mappings</code> to determine how to connect to this
+        network.  <code>ovn-bridge-mappings</code> is a list of network names
+        mapped to a local OVS bridge that provides access to that network.  An
+        example of configuring <code>ovn-bridge-mappings</code> would be:
+
+        <pre>$ ovs-vsctl set open . external-ids:ovn-bridge-mappings=physnet1:br-eth0,physnet2:br-eth1</pre>
+
+        <p>
+          When a logical switch has a <code>l2gateway</code> port attached,
+          the chassis that the <code>l2gateway</code> port is bound to
+          must have a bridge mapping configured to reach the network
+          identified by <code>network_name</code>.
+        </p>
+      </column>
+
+      <column name="tag">
+        If set, indicates that the gateway is connected to a specific
+        VLAN on the physical network. The VLAN ID is used to match
+        incoming traffic and is also added to outgoing traffic.
+      </column>
+    </group>
+
     <group title="VTEP Options">
       <p>
         These options apply to logical ports with <ref column="type"/> of
@@ -1501,7 +1568,8 @@ tcp.flags = RST;
 
         <p>
           This column is used for a different purpose when <ref column="type"/>
-          is <code>localnet</code> (see <code>Localnet Options</code>, above).
+          is <code>localnet</code> (see <code>Localnet Options</code>, above)
+          or <code>l2gateway</code> (see <code>Gateway Options</code>, above).
         </p>
       </column>
     </group>
diff --git a/tests/ovn.at b/tests/ovn.at
index a52def4..6d0bd47 100644
--- a/tests/ovn.at
+++ b/tests/ovn.at
@@ -1250,6 +1250,170 @@ for sim in hv1 hv2 hv3 vtep main; do
 done
 AT_CLEANUP
 
+# Similar test to "hardware GW"
+AT_SETUP([ovn -- 3 HVs, 1 VIFs/HV, 1 software GW, 1 LS])
+AT_SKIP_IF([test $HAVE_PYTHON = no])
+ovn_start
+
+# Configure the Northbound database
+ovn-nbctl ls-add lsw0
+
+ovn-nbctl lsp-add lsw0 lp1
+ovn-nbctl lsp-set-addresses lp1 f0:00:00:00:00:01
+
+ovn-nbctl lsp-add lsw0 lp2
+ovn-nbctl lsp-set-addresses lp2 f0:00:00:00:00:02
+
+ovn-nbctl lsp-add lsw0 lp-gw
+ovn-nbctl lsp-set-type lp-gw l2gateway
+ovn-nbctl lsp-set-options lp-gw network_name=physnet1
+ovn-nbctl lsp-set-addresses lp-gw unknown
+
+net_add n1               # Network to connect hv1, hv2, and gw
+net_add n2               # Network to connect gw and hv3
+
+# Create hypervisor hv1 connected to n1
+sim_add hv1
+as hv1
+ovs-vsctl add-br br-phys
+ovn_attach n1 br-phys 192.168.0.1
+ovs-vsctl add-port br-int vif1 -- set Interface vif1 external-ids:iface-id=lp1 options:tx_pcap=hv1/vif1-tx.pcap options:rxq_pcap=hv1/vif1-rx.pcap ofport-request=1
+
+# Create hypervisor hv2 connected to n1
+sim_add hv2
+as hv2
+ovs-vsctl add-br br-phys
+ovn_attach n1 br-phys 192.168.0.2
+ovs-vsctl add-port br-int vif2 -- set Interface vif2 external-ids:iface-id=lp2 options:tx_pcap=hv2/vif2-tx.pcap options:rxq_pcap=hv2/vif2-rx.pcap ofport-request=1
+
+# Create hypervisor hv_gw connected to n1 and n2
+# connect br-phys bridge to n1; connect hv-gw bridge to n2
+sim_add hv_gw
+as hv_gw
+ovs-vsctl add-br br-phys
+ovn_attach n1 br-phys 192.168.0.3
+ovs-vsctl add-br br-phys2
+net_attach n2 br-phys2
+ovs-vsctl set open . external_ids:ovn-bridge-mappings="physnet1:br-phys2"
+
+# Bind our gateway port to the hv_gw chassis
+ovn-sbctl lport-bind lp-gw hv_gw
+
+# Add hv3 on the other side of the GW
+sim_add hv3
+as hv3
+ovs-vsctl add-br br-phys
+net_attach n2 br-phys
+ovs-vsctl add-port br-phys vif3 -- set Interface vif3 options:tx_pcap=hv3/vif3-tx.pcap options:rxq_pcap=hv3/vif3-rx.pcap ofport-request=1
+
+
+# Pre-populate the hypervisors' ARP tables so that we don't lose any
+# packets for ARP resolution (native tunneling doesn't queue packets
+# for ARP resolution).
+ovn_populate_arp
+
+# Allow some time for ovn-northd and ovn-controller to catch up.
+# XXX This should be more systematic.
+sleep 1
+
+# test_packet INPORT DST SRC ETHTYPE OUTPORT...
+#
+# This shell function causes a packet to be received on INPORT.  The packet's
+# content has Ethernet destination DST and source SRC (each exactly 12 hex
+# digits) and Ethernet type ETHTYPE (4 hex digits).  The OUTPORTs (zero or
+# more) list the VIFs on which the packet should be received.  INPORT and the
+# OUTPORTs are specified as lport numbers, e.g. 1 for vif1.
+trim_zeros() {
+    sed 's/\(00\)\{1,\}$//'
+}
+for i in 1 2 3; do
+    : > $i.expected
+done
+test_packet() {
+    local inport=$1 packet=$2$3$4; shift; shift; shift; shift
+    #hv=hv`echo $inport | sed 's/^\(.\).*/\1/'`
+    hv=hv$inport
+    vif=vif$inport
+    as $hv ovs-appctl netdev-dummy/receive $vif $packet
+    for outport; do
+        echo $packet | trim_zeros >> $outport.expected
+    done
+}
+
+# Send packets between all pairs of source and destination ports:
+#
+# 1. Unicast packets are delivered to exactly one lport (except that packets
+#    destined to their input ports are dropped).
+#
+# 2. Broadcast and multicast are delivered to all lports except the input port.
+#
+# 3. The lswitch delivers packets with an unknown destination to lports with
+#    "unknown" among their MAC addresses (and port security disabled).
+for s in 1 2 3 ; do
+    bcast=
+    unknown=
+    for d in 1 2 3 ; do
+        if test $d != $s; then unicast=$d; else unicast=; fi
+        test_packet $s f0000000000$d f0000000000$s 00$s$d $unicast       #1
+
+        # The vtep (vif3) is the only one configured for "unknown"
+        if test $d != $s && test $d = 3; then
+            unknown="$unknown $d"
+        fi
+        bcast="$bcast $unicast"
+    done
+
+    test_packet $s ffffffffffff f0000000000$s 0${s}ff $bcast             #2
+    test_packet $s 010000000000 f0000000000$s 0${s}ff $bcast             #3
+    test_packet $s f0000000ffff f0000000000$s 0${s}66 $unknown           #4
+done
+
+# Allow some time for packet forwarding.
+# XXX This can be improved.
+sleep 3
+
+echo "------ ovn-nbctl show ------"
+ovn-nbctl show
+echo "------ ovn-sbctl show ------"
+ovn-sbctl show
+
+echo "------ hv1 ------"
+as hv1 ovs-vsctl show
+echo "------ hv1 br-int ------"
+as hv1 ovs-ofctl -O OpenFlow13 dump-flows br-int
+echo "------ hv1 br-phys ------"
+as hv1 ovs-ofctl -O OpenFlow13 dump-flows br-phys
+
+echo "------ hv2 ------"
+as hv2 ovs-vsctl show
+echo "------ hv2 br-int ------"
+as hv2 ovs-ofctl -O OpenFlow13 dump-flows br-int
+echo "------ hv2 br-phys ------"
+as hv2 ovs-ofctl -O OpenFlow13 dump-flows br-phys
+
+echo "------ hv_gw ------"
+as hv_gw ovs-vsctl show
+echo "------ hv_gw br-phys ------"
+as hv_gw ovs-ofctl -O OpenFlow13 dump-flows br-phys
+echo "------ hv_gw br-phys2 ------"
+as hv_gw ovs-ofctl -O OpenFlow13 dump-flows br-phys2
+
+echo "------ hv3 ------"
+as hv3 ovs-vsctl show
+echo "------ hv3 br-phys ------"
+as hv3 ovs-ofctl -O OpenFlow13 dump-flows br-phys
+
+# Now check the packets actually received against the ones expected.
+for i in 1 2 3; do
+    file=hv$i/vif$i-tx.pcap
+    echo $file
+    $PYTHON "$top_srcdir/utilities/ovs-pcap.in" $file | trim_zeros > $i.packets
+    sort $i.expected > expout
+    AT_CHECK([sort $i.packets], [0], [expout])
+    echo
+done
+AT_CLEANUP
+
 # 3 hypervisors, 3 logical switches with 3 logical ports each, 1 logical router
 AT_SETUP([ovn -- 3 HVs, 3 LS, 3 lports/LS, 1 LR])
 AT_SKIP_IF([test $HAVE_PYTHON = no])
