You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficserver.apache.org by ls...@apache.org on 2026/09/28 18:35:51 UTC

(trafficserver) branch master updated: virtualhost.yaml with remap rules (#13108)

This is an automated email from the ASF dual-hosted git repository.

serrislew pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new fb0733c57c virtualhost.yaml with remap rules (#13108)
fb0733c57c is described below

commit fb0733c57c86cda73d7de66958d23cf487d5cf78
Author: Serris Santos <se...@gmail.com>
AuthorDate: Mon Sep 28 11:35:44 2026 -0700

    virtualhost.yaml with remap rules (#13108)
    
    * init vhost remap
    
    * Update doc from ssl_multicert.config to .yaml
    
    * Use new _reload directive
    
    * Update docs and tests
    
    * copilot reviews on docs, wildcard matching, empty vhost entry load
    
    * use shared_ptr, don't set global ip_allow flag
    
    * clear all, explicitly set remap_yaml
    
    * Add more config_reload_rpc tests
    
    * use rpc response instead of diags
    
    * Fix virtualhost validation, reload safety, and reload diagnostics
    
    * Serialize virtualhost full reload and refuse unsafe reloads
    
    * invalidate empty conf.id and group plugin reloads
    
    * Prevent mistyped directive key from doing full reload
---
 configs/virtualhost.yaml.default                   |  27 +
 doc/admin-guide/files/index.en.rst                 |   4 +
 doc/admin-guide/files/records.yaml.en.rst          |   7 +
 doc/admin-guide/files/virtualhost.yaml.en.rst      | 236 ++++++++
 include/proxy/ReverseProxy.h                       |   5 +
 include/proxy/VirtualHost.h                        | 136 +++++
 include/proxy/http/HttpSM.h                        |   5 +-
 include/proxy/http/remap/PluginFactory.h           |   1 +
 include/proxy/http/remap/RemapYamlConfig.h         |   5 +
 include/proxy/http/remap/UrlRewrite.h              |  10 +-
 include/tscore/Filenames.h                         |   1 +
 src/proxy/CMakeLists.txt                           |   1 +
 src/proxy/ReverseProxy.cc                          |  13 +-
 src/proxy/VirtualHost.cc                           | 645 +++++++++++++++++++++
 src/proxy/http/HttpSM.cc                           |  40 ++
 src/proxy/http/remap/PluginFactory.cc              |  14 +-
 src/proxy/http/remap/RemapYamlConfig.cc            |  60 ++
 src/proxy/http/remap/UrlRewrite.cc                 |  19 +-
 src/records/RecordsConfig.cc                       |   2 +
 .../gold_tests/autest-site/config_reload.test.ext  |   7 +
 .../gold_tests/autest-site/trafficserver.test.ext  |   4 +
 tests/gold_tests/jsonrpc/config_reload_rpc.test.py | 473 ++++++++++++++-
 tests/gold_tests/remap/virtualhost_remap.test.py   | 255 ++++++++
 23 files changed, 1947 insertions(+), 23 deletions(-)

diff --git a/configs/virtualhost.yaml.default b/configs/virtualhost.yaml.default
new file mode 100644
index 0000000000..eef18964a3
--- /dev/null
+++ b/configs/virtualhost.yaml.default
@@ -0,0 +1,27 @@
+# virtualhost.yaml
+#
+# This configuration file defines a virtual host that provides domain-scoped configs and
+# remap rules, overriding global configs.
+#
+# Remap rule config flow:
+#   1. Resolve to a single virtualhost
+#       A. Look through exact match virtualhost domains. If found, use virtualhost config.
+#       B. Look through wildcard virtualhost domains. If found, use virtualhost config.
+#       C. If no virtualhost config found, skip to 3.
+#   2. Within virtualhost config, use virtualhost remap rules.
+#       A. Follow remap.yaml format rules. If found, use remap rule. (See remap.yaml for details)
+#   3. If no virtualhost or remap rule found, use global remap rules
+#
+#  Example:
+# virtualhost:
+#   - id: example
+#     domains:
+#       - example.com
+#       - "*.com"        # Only allow single left-most: "*.[domain]" format
+#
+#     remap:
+#       - type: map
+#         from:
+#           url: http://example.com
+#         to:
+#           url: http://origin.example.com/
diff --git a/doc/admin-guide/files/index.en.rst b/doc/admin-guide/files/index.en.rst
index 38b1db9b41..540f6a1c23 100644
--- a/doc/admin-guide/files/index.en.rst
+++ b/doc/admin-guide/files/index.en.rst
@@ -40,6 +40,7 @@ Configuration Files
    sni.yaml.en
    storage.yaml.en
    strategies.yaml.en
+   virtualhost.yaml.en
    jsonrpc.yaml.en
 
 :doc:`cache.config.en`
@@ -93,6 +94,9 @@ Configuration Files
 :doc:`strategies.yaml.en`
    Configures NextHop strategies used with `remap.config` and replaces parent.config.
 
+:doc:`virtualhost.yaml.en`
+   Defines configuration blocks that apply to a group of domains (virtualhosts).
+
 :doc:`jsonrpc.yaml.en`
     Defines some of the configurable arguments of the jsonrpc endpoint.
 
diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst
index caab819628..08eff524c2 100644
--- a/doc/admin-guide/files/records.yaml.en.rst
+++ b/doc/admin-guide/files/records.yaml.en.rst
@@ -6388,3 +6388,10 @@ AIO
    ============ ======================================================================
 
    Note: If you force the backend to use io_uring, you might experience failures with some (older, pre 5.4) kernel versions
+
+VirtualHost
+===========
+
+.. ts:cv:: CONFIG proxy.config.virtualhost.filename STRING virtualhost.yaml
+
+   Sets the name of the :file:`virtualhost.yaml` file.
diff --git a/doc/admin-guide/files/virtualhost.yaml.en.rst b/doc/admin-guide/files/virtualhost.yaml.en.rst
new file mode 100644
index 0000000000..cd1b21151c
--- /dev/null
+++ b/doc/admin-guide/files/virtualhost.yaml.en.rst
@@ -0,0 +1,236 @@
+
+.. Licensed to the Apache Software Foundation (ASF) under one
+   or more contributor license agreements.  See the NOTICE file
+   distributed with this work for additional information
+   regarding copyright ownership.  The ASF licenses this file
+   to you under the Apache License, Version 2.0 (the
+   "License"); you may not use this file except in compliance
+   with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing,
+   software distributed under the License is distributed on an
+   "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+   KIND, either express or implied.  See the License for the
+   specific language governing permissions and limitations
+   under the License.
+
+.. include:: ../../common.defs
+
+.. configfile:: virtualhost.yaml
+
+virtualhost.yaml
+****************
+
+The :file:`virtualhost.yaml` file defines configuration blocks that apply to a group of domains.
+Each virtual host entry defines a set of domains and the remap rules associated with those domains.
+Virtual host remap rules override global :file:`remap.yaml` rules but remain fully backward compatible
+with existing configurations. If absent, ATS behaves exactly as before.
+
+Currently, this file only supports :file:`remap.yaml` overrides. Future versions will expand virtual
+host support to additional configuration types (e.g. :file:`sni.yaml`, :file:`ssl_multicert.yaml`,
+:file:`parent.config`, etc)
+
+By default this is named :file:`virtualhost.yaml`. The filename can be changed by setting
+:ts:cv:`proxy.config.virtualhost.filename`.
+
+
+Configuration
+=============
+
+:file:`virtualhost.yaml` is YAML format with top level namespace **virtualhost** and a list of virtual host
+entries. Each virtual host entry must provide an **id** and at least one domain defined in **domains**.
+
+An example configuration looks like:
+
+.. code-block:: yaml
+
+   virtualhost:
+     - id: example
+       domains:
+         - example.com
+
+       remap:
+         - type: map
+           from:
+            url: http://example.com
+           to:
+            url: http://origin.example.com/
+
+
+===================== ==========================================================
+Field Name            Description
+===================== ==========================================================
+``id``                 Virtual host identifier to perform specific operations on
+``domains``            List of domains to resolve a request to
+``remap``              List of remap rules as defined in remap.yaml
+===================== ==========================================================
+
+``domains``
+   Domains can be defined as request domain name or subdomains using wildcard feature.
+   Wildcard support only allows a single left most ``*``. This does not support regex.
+   When matching to a virtual host entry, domains with exact match have precedence
+   over wildcard. If a domain matches to multiple wildcard domains, the most specific
+   (longest) suffix match is selected.
+
+   For example:
+      Supported:
+      - ``foo.example.com``
+      - ``*.example.com``
+      - ``*.com``
+
+      NOT Supported:
+      - ``foo[0-9]+.example.com`` (regex)
+      - ``bar.*.example.net`` (``*`` in the middle)
+      - ``*.bar.*.com`` (multiple ``*``)
+      - ``*.*.baz.com`` (multiple ``*``)
+      - ``baz*.example.net`` (partial wildcard)
+      - ``*baz.example.net`` (partial wildcard)
+      - ``b*z.example.net`` (partial wildcard)
+      - ``*`` (global)
+
+Evaluation Order
+----------------
+
+|TS| evaluates a request using deterministic precedence in the following order:
+
+1. Resolve to a single virtualhost
+   a. Check for an exact domain match. If any virtual host lists the request hostname explicitly, that virtual host is selected.
+   b. Check for a wildcard domain match. If any virtual host wildcard domains define a subdomain of the request hostname in the form ``*.[domain]``, that virtual host is selected.
+   c. If no matching virtual host exists, the request proceeds using global configuration (i.e :file:`remap.config`). Skip to step 3.
+2. Within selected virtual host config, use virtual host remap rules.
+   a. Follow existing :file:`remap.yaml` rules and matching orders. If a matching remap rule is found, that remap rule is selected.
+3. If neither virtual host nor remap rules match, ATS falls back to global :file:`remap.yaml` resolution.
+
+Only one virtual host entry may match a given request. Exact domain matches take precedence over wildcard matches. For wildcard matches,
+ATS selects the most specific (longest) matching suffix (e.g. ``*.example.com`` before ``*.com``).
+
+
+Granular Reload
+===============
+
+|TS| now supports granular configuration reloads for individual virtual hosts defined in :file:`virtualhost.yaml`.
+In addition to reloading the entire |TS| configuration with :option:`traffic_ctl config reload`, users can
+selectively reload a single virtual host entry without affecting other virtual host entries.
+
+By only updating the necessary changes, this reduces configuration deployment time and improves visibility on the changes made.
+
+To reload for a specific virtual host, use new reload directive:
+
+::
+
+   $ traffic_ctl config reload -D virtualhost.id=<id>
+
+Where **<id>** is the virtual host ID defined in :file:`virtualhost.yaml`. Only the **<id>** virtual host
+configuration will be reloaded. This does not affect other virtual hosts or global configuration files.
+
+Example:
+
+::
+
+   $ traffic_ctl config reload -D virtualhost.id=foo
+    ✔ Reload scheduled [rpc-123456789]
+
+    Monitor : traffic_ctl config reload -t rpc-123456789 -m
+    Details : traffic_ctl config reload -t rpc-123456789 -s -l
+
+  $ traffic_ctl config reload -t rpc-123456789 -s -l
+    ✗ Token 'rpc-123456789' already in use
+    ✔ Reload [success] — rpc-123456789
+    Started : 2026 May 19 19:20:20.691
+    Finished: 2026 May 19 19:20:20.692
+    Duration: 1ms
+
+    ✔ 1 success  ◌ 0 in-progress  ✗ 0 failed  (1 total)
+
+    Tasks:
+    ✔ virtualhost ··································    1ms
+        [Note]  Reloaded virtualhost entry: foo
+
+The **<id>** must name an entry. An empty value is rejected rather than treated as a request to
+reload the whole file, so a reload scoped to one entry never rebuilds the entire table from disk.
+
+A reload, whether of a single entry or of the whole file, fails and leaves the running
+configuration in place if :file:`virtualhost.yaml` is missing, empty, or invalid. Only the initial
+load at startup treats a missing or empty file as "no virtual hosts configured"; on a reload,
+replacing a live routing table with an empty one is reported as a failure instead.
+
+
+Examples
+========
+
+.. code-block:: yaml
+
+   # virtualhost.yaml
+   virtualhost:
+     - id: example
+       domains:
+         - example.com
+
+       remap:
+         - type: map
+           from:
+            url: http://example.com
+           to:
+            url: http://origin.example.com/
+
+   # remap.yaml
+   remap:
+     - type: map
+       from:
+         url: http://www.x.com
+       to:
+         url: http://other.example.com/
+
+These rules translate in the following translation.
+
+================================================ ========================================================
+Client Request                                   Translated Request
+================================================ ========================================================
+``http://example.com/index.html``                ``http://origin.example.com/index.html``
+``http://www.x.com/index.html``                  ``http://other.example.com/index.html``
+================================================ ========================================================
+
+.. code-block:: yaml
+
+   # virtualhost.yaml
+   virtualhost:
+     - id: example
+       domains:
+         - "*.example.com"
+
+       remap:
+         - type: regex_map
+           from:
+            url: http://sub([0-9]+).example.com/
+           to:
+            url: http://origin$1.example.com/
+
+
+     - id: foo
+       domains:
+         - foo.example.com
+
+       remap:
+         - type: map
+           from:
+            url: http://foo.example.com/
+           to:
+            url: http://foo.origin.com/
+
+This rules translates in the following translation.
+
+================================================ ========================================================
+Client Request                                   Translated Request
+================================================ ========================================================
+``http://sub0.example.com/index.html``           ``http://origin0.example.com/index.html``
+``http://foo.example.com/index.html``            ``http://foo.origin.com/index.html``
+``http://bar.example.com/index.html``             No remap rule found in virtual host entry `example`
+================================================ ========================================================
+
+
+See Also
+========
+
+:file:`remap.yaml`
diff --git a/include/proxy/ReverseProxy.h b/include/proxy/ReverseProxy.h
index 76c4db6aaa..1f08f33596 100644
--- a/include/proxy/ReverseProxy.h
+++ b/include/proxy/ReverseProxy.h
@@ -32,6 +32,8 @@
 
 #pragma once
 
+#include <memory>
+
 #include "records/RecProcess.h"
 
 #include "tscore/ink_defs.h"
@@ -50,6 +52,9 @@ struct host_hdr_info;
 
 extern AtomicSharedPtr<UrlRewrite> rewrite_table;
 
+// Wrap a freshly built table in a shared_ptr with the standard UrlRewrite deleter.
+std::shared_ptr<UrlRewrite> make_managed_url_rewrite(std::unique_ptr<UrlRewrite> table);
+
 // API Functions
 int init_reverse_proxy();
 
diff --git a/include/proxy/VirtualHost.h b/include/proxy/VirtualHost.h
new file mode 100644
index 0000000000..e6aa72d5f8
--- /dev/null
+++ b/include/proxy/VirtualHost.h
@@ -0,0 +1,136 @@
+/** @file
+  Virtual Host configuration
+  @section license License
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+      http://www.apache.org/licenses/LICENSE-2.0
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#pragma once
+
+#include <atomic>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+#include <vector>
+
+#include "iocore/eventsystem/ConfigProcessor.h"
+#include "mgmt/config/ConfigContext.h"
+#include "proxy/http/remap/UrlRewrite.h"
+#include "tscore/Ptr.h"
+
+class VirtualHostPluginReload;
+
+class VirtualHostConfig : public ConfigInfo
+{
+public:
+  VirtualHostConfig() = default;
+  VirtualHostConfig(const VirtualHostConfig &other)
+    : _entries(other._entries),
+      _exact_domains_to_id(other._exact_domains_to_id),
+      _wildcard_domains_to_id(other._wildcard_domains_to_id)
+  {
+  }
+  VirtualHostConfig &
+  operator=(const VirtualHostConfig &other)
+  {
+    if (this != &other) {
+      _entries                = other._entries;
+      _exact_domains_to_id    = other._exact_domains_to_id;
+      _wildcard_domains_to_id = other._wildcard_domains_to_id;
+    }
+    return *this;
+  }
+  ~VirtualHostConfig() = default;
+
+  struct Entry : public RefCountObjInHeap {
+    std::string                 id;
+    std::vector<std::string>    exact_domains;
+    std::vector<std::string>    wildcard_domains;
+    std::shared_ptr<UrlRewrite> remap_table;
+
+    std::string get_id() const;
+  };
+
+  /** Load every entry from the configuration file into this (empty) config.
+
+      @param initial_load Set only for the load performed by @c VirtualHost::startup(). An absent
+      or empty file is a supported "no virtualhosts configured" state there, but on a reload it is
+      an error: reporting success would publish an empty config over a live routing table, silently
+      dropping every per-domain remap table.
+   */
+  bool        load(ConfigContext ctx = {}, bool initial_load = false, VirtualHostPluginReload *plugin_reload = nullptr);
+  bool        set_entry(std::string_view id, Ptr<Entry> &entry, ConfigContext ctx = {});
+  static bool load_entry(std::string_view id, Ptr<Entry> &entry, ConfigContext ctx = {},
+                         VirtualHostPluginReload *plugin_reload = nullptr);
+  Ptr<Entry>  find_by_id(std::string_view id) const;
+  Ptr<Entry>  find_by_domain(std::string_view domain) const;
+
+  /// Add the remap plugins instantiated by every entry's remap table to @a used.
+  void collect_used_plugins(std::unordered_map<PluginDso *, int> &used) const;
+
+  size_t
+  entry_count() const
+  {
+    return _entries.size();
+  }
+
+private:
+  using entry_map = std::unordered_map<std::string, Ptr<Entry>>;
+  using name_map  = std::unordered_map<std::string, std::string>;
+
+  entry_map _entries;
+  name_map  _exact_domains_to_id;
+  name_map  _wildcard_domains_to_id;
+};
+
+/** Sends one remap plugin reload notification pair for a whole virtualhost rebuild.
+
+    The pre/post callbacks go to every loaded remap plugin, not just the ones a table uses, so
+    notifying per table would repeat them for each entry and report plugins used only by earlier
+    tables as unused. The pre notification is sent lazily, before the first remap table is built, so
+    a rebuild with no remap tables sends nothing. If @c finish() is never reached, the destructor
+    reports the reload as failed.
+ */
+class VirtualHostPluginReload
+{
+public:
+  VirtualHostPluginReload()                                           = default;
+  VirtualHostPluginReload(const VirtualHostPluginReload &)            = delete;
+  VirtualHostPluginReload &operator=(const VirtualHostPluginReload &) = delete;
+  ~VirtualHostPluginReload();
+
+  /// Call before building a remap table.
+  void begin();
+  /// Report success, with @a config as the full set of tables that will be live.
+  void finish(VirtualHostConfig const &config);
+
+private:
+  bool _started = false;
+};
+
+class VirtualHost
+{
+public:
+  using scoped_config = ConfigProcessor::scoped_config<VirtualHost, VirtualHostConfig>;
+
+  static void               startup();
+  static int                reconfigure(ConfigContext ctx = {}, bool initial_load = false);
+  static int                reconfigure(std::string_view id, ConfigContext ctx = {});
+  static VirtualHostConfig *acquire();
+  static void               release(VirtualHostConfig *config);
+
+private:
+  static std::atomic<int> _configid;
+};
diff --git a/include/proxy/http/HttpSM.h b/include/proxy/http/HttpSM.h
index ea4214ecce..fb825e0a67 100644
--- a/include/proxy/http/HttpSM.h
+++ b/include/proxy/http/HttpSM.h
@@ -43,6 +43,7 @@
 #include "proxy/http/HttpTunnel.h"
 #include "api/InkAPIInternal.h"
 #include "proxy/ProxyTransaction.h"
+#include "proxy/VirtualHost.h"
 
 // inknet
 #include "proxy/http/PreWarmManager.h"
@@ -331,7 +332,8 @@ public:
 
   // This unfortunately can't go into the t_state, because of circular dependencies. We could perhaps refactor
   // this, with a lot of work, but this is easier for now.
-  std::shared_ptr<UrlRewrite> m_remap;
+  std::shared_ptr<UrlRewrite>   m_remap;
+  Ptr<VirtualHostConfig::Entry> m_virtualhost_entry;
 
   History<HISTORY_DEFAULT_SIZE> history;
   NetVConnection *
@@ -389,6 +391,7 @@ private:
 
   // Y! ebalsa: remap handlers
   int  state_remap_request(int event, void *data);
+  void set_virtualhost_entry(std::string_view domain);
   void do_remap_request(bool);
 
   // Cache Handlers
diff --git a/include/proxy/http/remap/PluginFactory.h b/include/proxy/http/remap/PluginFactory.h
index fd08c00000..26f69e8c44 100644
--- a/include/proxy/http/remap/PluginFactory.h
+++ b/include/proxy/http/remap/PluginFactory.h
@@ -103,6 +103,7 @@ public:
   void deactivate();
   void indicatePreReload();
   void indicatePostReload(bool reloadSuccessful);
+  void collectUsedPlugins(std::unordered_map<PluginDso *, int> &pluginUsed);
 
   static void cleanup(); // For startup, clean out all temporary directory we may have left from before
 
diff --git a/include/proxy/http/remap/RemapYamlConfig.h b/include/proxy/http/remap/RemapYamlConfig.h
index a93ee9d8b1..fc385d55e7 100644
--- a/include/proxy/http/remap/RemapYamlConfig.h
+++ b/include/proxy/http/remap/RemapYamlConfig.h
@@ -72,3 +72,8 @@ swoc::Errata parse_yaml_remap_rule(const YAML::Node &node, BUILD_TABLE_INFO *bti
 bool remap_parse_yaml_bti(const char *path, BUILD_TABLE_INFO *bti, ConfigContext ctx = {});
 
 bool remap_parse_yaml(const char *path, UrlRewrite *rewrite, ConfigContext ctx = {});
+// Parse remap YAML node from inline YAML node (for virtualhost)
+bool remap_parse_yaml_bti(YAML::Node const *remap_node, BUILD_TABLE_INFO *bti, ConfigContext ctx = {});
+
+// Unlike the file overload, this does not send the remap plugin reload notifications; the caller owns them.
+bool remap_parse_yaml(YAML::Node const *remap_node, UrlRewrite *rewrite, ConfigContext ctx = {});
diff --git a/include/proxy/http/remap/UrlRewrite.h b/include/proxy/http/remap/UrlRewrite.h
index 58fa755a82..eb6c27df7d 100644
--- a/include/proxy/http/remap/UrlRewrite.h
+++ b/include/proxy/http/remap/UrlRewrite.h
@@ -80,13 +80,21 @@ public:
    */
   bool load(ConfigContext ctx = {});
 
+  /** Build the tables from @a path, or from @a remap_node when it is set.
+   *
+   * @param path Configuration file to parse. Ignored when @a remap_node is set, so inline callers pass it empty.
+   * @param remap_node Inline remap.yaml style rule sequence, or @c nullptr to parse @a path.
+   * @param ctx ConfigContext for reload status tracking.
+   */
+  bool load_table(const std::string &path, YAML::Node const *remap_node, ConfigContext ctx = {});
+
   /** Build the internal url write tables.
    *
    * @param path Path to configuration file.
    * @param ctx  ConfigContext for reload status tracking.
    * @return 0 on success, non-zero error code on failure.
    */
-  int BuildTable(const char *path, ConfigContext ctx = {});
+  int BuildTable(const char *path, YAML::Node const *remap_node = nullptr, ConfigContext ctx = {});
 
   mapping_type Remap_redirect(HTTPHdr *request_header, URL *redirect_url);
   bool         ReverseMap(HTTPHdr *response_header);
diff --git a/include/tscore/Filenames.h b/include/tscore/Filenames.h
index b36e282aeb..d828eda921 100644
--- a/include/tscore/Filenames.h
+++ b/include/tscore/Filenames.h
@@ -44,6 +44,7 @@ namespace filename
   constexpr const char *SPLITDNS      = "splitdns.config";
   constexpr const char *SNI           = "sni.yaml";
   constexpr const char *JSONRPC       = "jsonrpc.yaml";
+  constexpr const char *VIRTUALHOST   = "virtualhost.yaml";
 
   ///////////////////////////////////////////////////////////////////
   // Various other file names
diff --git a/src/proxy/CMakeLists.txt b/src/proxy/CMakeLists.txt
index f4c74c8c8d..101bb0e382 100644
--- a/src/proxy/CMakeLists.txt
+++ b/src/proxy/CMakeLists.txt
@@ -37,6 +37,7 @@ add_library(
   Transform.cc
   FetchSM.cc
   PluginHttpConnect.cc
+  VirtualHost.cc
 )
 add_library(ts::proxy ALIAS proxy)
 
diff --git a/src/proxy/ReverseProxy.cc b/src/proxy/ReverseProxy.cc
index da47dc08e5..c3657bb7a0 100644
--- a/src/proxy/ReverseProxy.cc
+++ b/src/proxy/ReverseProxy.cc
@@ -44,6 +44,7 @@
 #include "proxy/http/remap/UrlRewrite.h"
 #include "proxy/http/remap/UrlMapping.h"
 #include "proxy/http/remap/UrlMappingPathIndex.h"
+#include "proxy/VirtualHost.h"
 
 namespace
 {
@@ -81,6 +82,12 @@ struct UrlRewriteDeleter {
 AtomicSharedPtr<UrlRewrite>       rewrite_table;
 thread_local PluginThreadContext *pluginThreadContext = nullptr;
 
+std::shared_ptr<UrlRewrite>
+make_managed_url_rewrite(std::unique_ptr<UrlRewrite> table)
+{
+  return std::shared_ptr<UrlRewrite>(table.release(), UrlRewriteDeleter{});
+}
+
 void
 shutdown_url_rewrite()
 {
@@ -144,7 +151,7 @@ init_reverse_proxy()
     init_table_volume_host_records(*initial_table);
   }
 
-  rewrite_table.store(std::shared_ptr<UrlRewrite>(initial_table.release(), UrlRewriteDeleter{}), std::memory_order_release);
+  rewrite_table.store(make_managed_url_rewrite(std::move(initial_table)), std::memory_order_release);
   ink_assert(0 == config_reg.attach("remap", "proxy.config.url_remap.filename"));
   ink_assert(0 == config_reg.attach("remap", "proxy.config.proxy_name"));
   ink_assert(0 == config_reg.attach("remap", "proxy.config.http.referer_default_redirect"));
@@ -153,6 +160,8 @@ init_reverse_proxy()
   ink_assert(0 == config_reg.attach("remap_yaml", "proxy.config.http.referer_default_redirect"));
   RecRegisterConfigUpdateCb("proxy.config.reverse_proxy.enabled", url_rewrite_CB, (void *)REVERSE_CHANGED);
 
+  VirtualHost::startup();
+
   return 0;
 }
 
@@ -208,7 +217,7 @@ reloadUrlRewrite(ConfigContext ctx)
   if (status) {
     swoc::bwprint(msg_buffer, "{} finished loading", is_yaml ? ts::filename::REMAP_YAML : ts::filename::REMAP);
 
-    rewrite_table.exchange(std::shared_ptr<UrlRewrite>(newTable.release(), UrlRewriteDeleter{}), std::memory_order_acq_rel);
+    rewrite_table.exchange(make_managed_url_rewrite(std::move(newTable)), std::memory_order_acq_rel);
 
     Dbg(dbg_ctl_url_rewrite, "%s", msg_buffer.c_str());
     CfgLoadComplete(ctx, "%s finished loading", is_yaml ? ts::filename::REMAP_YAML : ts::filename::REMAP);
diff --git a/src/proxy/VirtualHost.cc b/src/proxy/VirtualHost.cc
new file mode 100644
index 0000000000..f02ae6348f
--- /dev/null
+++ b/src/proxy/VirtualHost.cc
@@ -0,0 +1,645 @@
+/** @file
+
+  Virtual Host configuration implementation
+
+  @section license License
+
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+ */
+
+#include <algorithm>
+#include <cctype>
+#include <cerrno>
+#include <atomic>
+#include <memory>
+#include <mutex>
+#include <set>
+#include <string>
+#include <string_view>
+#include <strings.h>
+#include <sys/stat.h>
+#include <yaml-cpp/yaml.h>
+
+#include "proxy/VirtualHost.h"
+#include "proxy/ReverseProxy.h"
+#include "mgmt/config/ConfigContextDiags.h"
+#include "mgmt/config/ConfigRegistry.h"
+#include "records/RecCore.h"
+#include "tscore/Filenames.h"
+#include "tsutil/Convert.h"
+
+namespace
+{
+DbgCtl dbg_ctl_virtualhost("virtualhost");
+
+/** Serializes both reload paths against each other.
+
+    Each reload must hold this from the moment it reads the file until it publishes. Otherwise two
+    reloads that parse concurrently can publish in the opposite order to their reads, and the older
+    read wins: a full reload resurrects entries a newer one had dropped, and a single-entry reload
+    parsed before a full reload that removed its id re-adds that entry on top of it. The single-entry
+    reload additionally does a read-copy-modify-publish against the live config, which the same
+    critical section keeps atomic. It also keeps the remap plugin reload notifications of one rebuild
+    from interleaving with another's. Reload paths are scheduled on ET_TASK with unrelated mutexes
+    (a fresh one per @c ConfigRegistry::schedule_reload and one per trigger record), so nothing else
+    serializes them. Neither path is on the request path, so serializing the parse costs nothing.
+ */
+std::mutex vhost_reconfigure_mutex;
+} // namespace
+
+std::atomic<int> VirtualHost::_configid{0};
+
+std::string
+VirtualHostConfig::Entry::get_id() const
+{
+  return id;
+}
+
+namespace
+{
+const std::set<std::string> valid_vhost_keys = {"id", "domains", "remap"};
+
+/** Check that @a name is composed of non-empty hostname labels.
+
+    Without this, a documented-as-unsupported form such as `foo[0-9]+.example.com` is accepted as
+    an exact domain that no Host header can ever equal: the entry loads clean and never fires.
+ */
+bool
+is_hostname(std::string_view name)
+{
+  // A bracketed IPv6 literal is matched verbatim against the Host header.
+  if (name.size() > 2 && name.front() == '[' && name.back() == ']') {
+    return true;
+  }
+
+  size_t label_len = 0;
+
+  for (char c : name) {
+    if (c == '.') {
+      if (label_len == 0) {
+        return false;
+      }
+      label_len = 0;
+      continue;
+    }
+    if (!isalnum(static_cast<unsigned char>(c)) && c != '-' && c != '_') {
+      return false;
+    }
+    ++label_len;
+  }
+  return label_len > 0;
+}
+
+/** Decode a single `virtualhost` sequence element.
+
+    Diagnostics go through @a ctx so the operator who asked for the reload sees them in the reload
+    task log, not just in diags.log.
+ */
+bool
+decode_virtualhost_entry(YAML::Node const &node, VirtualHostConfig::Entry &item, ConfigContext ctx)
+{
+  if (!node["id"]) {
+    CfgLoadLog(ctx, DL_Error, "Virtualhost entry at line %d must provide `id`", node.Mark().line + 1);
+    return false;
+  }
+  item.id = node["id"].as<std::string>();
+  if (item.id.empty()) {
+    // A single-entry reload cannot name an empty id, so such an entry could never be reloaded on its own.
+    CfgLoadLog(ctx, DL_Error, "Virtualhost entry at line %d must provide a non-empty `id`", node.Mark().line + 1);
+    return false;
+  }
+
+  for (const auto &elem : node) {
+    auto key = elem.first.as<std::string>();
+    if (!valid_vhost_keys.contains(key)) {
+      CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' has unsupported key '%s' (line %d)", item.id.c_str(), key.c_str(),
+                 elem.first.Mark().line + 1);
+      return false;
+    }
+  }
+
+  auto domains = node["domains"];
+  if (!domains || !domains.IsSequence() || domains.size() == 0) {
+    CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' must provide at least one domain in a `domains` sequence (line %d)",
+               item.id.c_str(), node.Mark().line + 1);
+    return false;
+  }
+  item.exact_domains.clear();
+  item.wildcard_domains.clear();
+
+  for (const auto &it : domains) {
+    auto domain_entry = it.as<std::string>();
+    if (domain_entry.empty()) {
+      CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' has an empty entry in `domains` (line %d)", item.id.c_str(), it.Mark().line + 1);
+      return false;
+    }
+    char domain[TS_MAX_HOST_NAME_LEN + 1];
+    ts::transform_lower(domain_entry, domain);
+
+    // Check if domain is wildcard, prefixed with *
+    if (domain[0] == '*') {
+      if (domain[1] != '.' || domain[2] == '\0' || domain[2] == '.' || strchr(domain + 2, '*') != nullptr) {
+        CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' wildcard '%s' must match '*.[domain]' format (line %d)", item.id.c_str(),
+                   domain, it.Mark().line + 1);
+        return false;
+      }
+      if (!is_hostname(domain + 2)) {
+        CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' wildcard '%s' suffix is not a valid hostname; regex is not supported (line %d)",
+                   item.id.c_str(), domain, it.Mark().line + 1);
+        return false;
+      }
+      item.wildcard_domains.emplace_back(domain + 2);
+    } else {
+      if (strchr(domain, '*') != nullptr) {
+        CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' domain '%s' may only use a wildcard in the leading '*.[domain]' form (line %d)",
+                   item.id.c_str(), domain, it.Mark().line + 1);
+        return false;
+      }
+      if (!is_hostname(domain)) {
+        CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' domain '%s' is not a valid hostname; regex is not supported (line %d)",
+                   item.id.c_str(), domain, it.Mark().line + 1);
+        return false;
+      }
+      item.exact_domains.emplace_back(domain);
+    }
+  }
+
+  if (item.exact_domains.empty() && item.wildcard_domains.empty()) {
+    CfgLoadLog(ctx, DL_Error, "Virtualhost '%s' must have at least one domain defined (line %d)", item.id.c_str(),
+               node.Mark().line + 1);
+    return false;
+  }
+
+  return true;
+}
+
+bool
+build_virtualhost_entry(YAML::Node const &node, Ptr<VirtualHostConfig::Entry> &entry, ConfigContext ctx,
+                        VirtualHostPluginReload *plugin_reload)
+{
+  entry.clear();
+  Ptr<VirtualHostConfig::Entry> vhost = make_ptr(new VirtualHostConfig::Entry);
+  auto                         &conf  = *vhost;
+  try {
+    if (!decode_virtualhost_entry(node, conf, ctx)) {
+      return false;
+    }
+  } catch (YAML::Exception const &ex) {
+    CfgLoadLog(ctx, DL_Error, "Failed to parse virtualhost entry at line %d: %s", node.Mark().line + 1, ex.what());
+    return false;
+  }
+
+  // Build UrlRewrite table for remap rules
+  auto remap_node = node["remap"];
+  if (remap_node) {
+    if (plugin_reload) {
+      plugin_reload->begin();
+    }
+    auto table = std::make_unique<UrlRewrite>();
+    table->set_remap_yaml(true);
+    if (!table->load_table({}, &remap_node, ctx)) {
+      CfgLoadLog(ctx, DL_Error, "Failed to load remap rules for virtualhost '%s' at line %d", conf.id.c_str(),
+                 remap_node.Mark().line + 1);
+      return false;
+    }
+    conf.remap_table = make_managed_url_rewrite(std::move(table));
+  }
+  entry = std::move(vhost);
+  return true;
+}
+} // namespace
+
+namespace
+{
+/// Number of entries in the currently published config, so a refused reload can say what it would
+/// otherwise have dropped.
+size_t
+live_entry_count()
+{
+  VirtualHost::scoped_config config;
+
+  return config ? config->entry_count() : 0;
+}
+} // namespace
+
+bool
+VirtualHostConfig::load(ConfigContext ctx, bool initial_load, VirtualHostPluginReload *plugin_reload)
+{
+  _entries.clear();
+  _exact_domains_to_id.clear();
+  _wildcard_domains_to_id.clear();
+  std::string config_path = RecConfigReadConfigPath("proxy.config.virtualhost.filename", ts::filename::VIRTUALHOST);
+
+  struct stat sbuf;
+  if (stat(config_path.c_str(), &sbuf) == -1 && errno == ENOENT) {
+    if (!initial_load) {
+      CfgLoadLog(ctx, DL_Error, "Cannot reload virtualhost config: '%s' doesn't exist; keeping the %zu live entry(s)",
+                 config_path.c_str(), live_entry_count());
+      return false;
+    }
+    CfgLoadLog(ctx, DL_Warning, "Virtualhost configuration '%s' doesn't exist, no virtualhost entries loaded", config_path.c_str());
+    return true;
+  }
+
+  try {
+    YAML::Node config = YAML::LoadFile(config_path);
+    if (config.IsNull()) {
+      if (!initial_load) {
+        CfgLoadLog(ctx, DL_Error, "Cannot reload virtualhost config: '%s' is empty; keeping the %zu live entry(s)",
+                   config_path.c_str(), live_entry_count());
+        return false;
+      }
+      Dbg(dbg_ctl_virtualhost, "Empty virtualhost config: %s", config_path.c_str());
+      return true;
+    }
+
+    config = config["virtualhost"];
+    if (config.IsNull() || !config.IsSequence()) {
+      CfgLoadLog(ctx, DL_Error, "%s: expected toplevel 'virtualhost' key to be a sequence", config_path.c_str());
+      return false;
+    }
+
+    for (auto const &node : config) {
+      Ptr<Entry> entry;
+      if (!build_virtualhost_entry(node, entry, ctx, plugin_reload)) {
+        return false;
+      }
+
+      std::string vhost_id{entry->id};
+      if (_entries.contains(vhost_id)) {
+        CfgLoadLog(ctx, DL_Error, "%s: duplicate virtualhost id '%s' (line %d)", config_path.c_str(), vhost_id.c_str(),
+                   node.Mark().line + 1);
+        return false;
+      }
+
+      for (auto const &domain : entry->exact_domains) {
+        if (_exact_domains_to_id.contains(domain)) {
+          CfgLoadLog(ctx, DL_Error, "%s: domain '%s' in virtualhost '%s' is already claimed by virtualhost '%s'",
+                     config_path.c_str(), domain.c_str(), vhost_id.c_str(), _exact_domains_to_id.at(domain).c_str());
+          return false;
+        }
+        _exact_domains_to_id.emplace(domain, vhost_id);
+      }
+
+      for (auto const &domain_suffix : entry->wildcard_domains) {
+        if (_wildcard_domains_to_id.contains(domain_suffix)) {
+          CfgLoadLog(ctx, DL_Error, "%s: wildcard domain '*.%s' in virtualhost '%s' is already claimed by virtualhost '%s'",
+                     config_path.c_str(), domain_suffix.c_str(), vhost_id.c_str(),
+                     _wildcard_domains_to_id.at(domain_suffix).c_str());
+          return false;
+        }
+        _wildcard_domains_to_id.emplace(domain_suffix, vhost_id);
+      }
+
+      _entries.emplace(vhost_id, std::move(entry));
+    }
+
+  } catch (std::exception &ex) {
+    CfgLoadLog(ctx, DL_Error, "Failed to load %s: %s", config_path.c_str(), ex.what());
+    return false;
+  }
+  return true;
+}
+
+bool
+VirtualHostConfig::load_entry(std::string_view id, Ptr<Entry> &entry, ConfigContext ctx, VirtualHostPluginReload *plugin_reload)
+{
+  entry.clear();
+  std::string config_path = RecConfigReadConfigPath("proxy.config.virtualhost.filename", ts::filename::VIRTUALHOST);
+
+  struct stat sbuf;
+  if (stat(config_path.c_str(), &sbuf) == -1 && errno == ENOENT) {
+    CfgLoadLog(ctx, DL_Error, "Cannot reload virtualhost entry '%.*s': configuration '%s' doesn't exist",
+               static_cast<int>(id.size()), id.data(), config_path.c_str());
+    return false;
+  }
+
+  try {
+    YAML::Node config = YAML::LoadFile(config_path);
+    if (config.IsNull()) {
+      CfgLoadLog(ctx, DL_Error, "Cannot reload virtualhost entry '%.*s': '%s' is empty", static_cast<int>(id.size()), id.data(),
+                 config_path.c_str());
+      return false;
+    }
+
+    config = config["virtualhost"];
+    if (config.IsNull() || !config.IsSequence()) {
+      CfgLoadLog(ctx, DL_Error, "%s: expected toplevel 'virtualhost' key to be a sequence", config_path.c_str());
+      return false;
+    }
+
+    for (auto const &node : config) {
+      auto config_id = node["id"];
+      if (!config_id || config_id.as<std::string>() != id) {
+        continue;
+      }
+
+      Ptr<Entry> vhost_entry;
+      if (!build_virtualhost_entry(node, vhost_entry, ctx, plugin_reload)) {
+        return false;
+      }
+      entry = std::move(vhost_entry);
+      return true;
+    }
+
+  } catch (std::exception &ex) {
+    CfgLoadLog(ctx, DL_Error, "Failed to load virtualhost entry '%.*s' in %s: %s", static_cast<int>(id.size()), id.data(),
+               config_path.c_str(), ex.what());
+    return false;
+  }
+  CfgLoadLog(ctx, DL_Error, "%s: virtualhost with id '%.*s' not found", config_path.c_str(), static_cast<int>(id.size()),
+             id.data());
+  return false;
+}
+
+bool
+VirtualHostConfig::set_entry(std::string_view id, Ptr<Entry> &entry, ConfigContext ctx)
+{
+  std::string vhost_id{id};
+  // If virtualhost entry already exists, remove current entry
+  if (auto it = _entries.find(vhost_id); it != _entries.end()) {
+    Ptr<Entry> curr_entry = std::move(it->second);
+    for (auto const &domain : curr_entry->exact_domains) {
+      _exact_domains_to_id.erase(domain);
+    }
+    for (auto const &domain : curr_entry->wildcard_domains) {
+      _wildcard_domains_to_id.erase(domain);
+    }
+    _entries.erase(vhost_id);
+  }
+
+  // Add new entry into virtualhost config
+  if (entry) {
+    for (auto const &domain : entry->exact_domains) {
+      if (_exact_domains_to_id.contains(domain)) {
+        CfgLoadLog(ctx, DL_Error, "Domain '%s' in virtualhost '%s' is already claimed by virtualhost '%s'", domain.c_str(),
+                   vhost_id.c_str(), _exact_domains_to_id.at(domain).c_str());
+        return false;
+      }
+      _exact_domains_to_id.emplace(domain, vhost_id);
+    }
+
+    for (auto const &domain_suffix : entry->wildcard_domains) {
+      if (_wildcard_domains_to_id.contains(domain_suffix)) {
+        CfgLoadLog(ctx, DL_Error, "Wildcard domain '*.%s' in virtualhost '%s' is already claimed by virtualhost '%s'",
+                   domain_suffix.c_str(), vhost_id.c_str(), _wildcard_domains_to_id.at(domain_suffix).c_str());
+        return false;
+      }
+      _wildcard_domains_to_id.emplace(domain_suffix, vhost_id);
+    }
+
+    _entries.emplace(vhost_id, std::move(entry));
+  }
+  return true;
+}
+
+void
+VirtualHostConfig::collect_used_plugins(std::unordered_map<PluginDso *, int> &used) const
+{
+  for (auto const &[id, entry] : _entries) {
+    if (entry->remap_table) {
+      entry->remap_table->pluginFactory.collectUsedPlugins(used);
+    }
+  }
+}
+
+VirtualHostPluginReload::~VirtualHostPluginReload()
+{
+  if (_started) {
+    PluginDso::loadedPlugins()->indicatePostReload(false, {}, "virtualhost");
+  }
+}
+
+void
+VirtualHostPluginReload::begin()
+{
+  if (!_started) {
+    PluginDso::loadedPlugins()->indicatePreReload("virtualhost");
+    _started = true;
+  }
+}
+
+void
+VirtualHostPluginReload::finish(VirtualHostConfig const &config)
+{
+  if (!_started) {
+    return;
+  }
+
+  std::unordered_map<PluginDso *, int> used;
+  config.collect_used_plugins(used);
+
+  PluginDso::loadedPlugins()->indicatePostReload(true, used, "virtualhost");
+  _started = false;
+}
+
+Ptr<VirtualHostConfig::Entry>
+VirtualHostConfig::find_by_id(std::string_view id) const
+{
+  if (_entries.empty()) {
+    return Ptr<VirtualHostConfig::Entry>();
+  }
+
+  auto entry = _entries.find(std::string{id});
+  if (entry != _entries.end()) {
+    return entry->second;
+  }
+  return Ptr<VirtualHostConfig::Entry>();
+}
+
+Ptr<VirtualHostConfig::Entry>
+VirtualHostConfig::find_by_domain(std::string_view domain) const
+{
+  if (_entries.empty() || domain.empty()) {
+    return Ptr<VirtualHostConfig::Entry>();
+  }
+
+  char lower_domain[TS_MAX_HOST_NAME_LEN + 1];
+  ts::transform_lower(std::string{domain}, lower_domain);
+
+  // Check for exact match domains first
+  auto id = _exact_domains_to_id.find(lower_domain);
+  if (id != _exact_domains_to_id.end()) {
+    auto entry = _entries.find(id->second);
+    if (entry != _entries.end()) {
+      return entry->second;
+    }
+  }
+
+  // Check wildcard suffixes
+  const char *subdomain = index(lower_domain, '.');
+  while (subdomain) {
+    subdomain++;
+    if (auto suffix_id = _wildcard_domains_to_id.find(subdomain); suffix_id != _wildcard_domains_to_id.end()) {
+      auto entry = _entries.find(suffix_id->second);
+      if (entry != _entries.end()) {
+        return entry->second;
+      }
+    }
+    subdomain = index(subdomain, '.');
+  }
+
+  return Ptr<VirtualHostConfig::Entry>();
+}
+
+namespace
+{
+/** Reload handler for the `virtualhost` config.
+
+    Registered as FileAndRpc so that `admin_config_reload` can carry `_reload` directives (currently
+    just `id`, for a single-entry reload). Pushed config *content* is deliberately not supported:
+    both reload paths re-read the on-disk file, so silently dropping a supplied body would report
+    success for a change that never took effect.
+ */
+void
+virtualhost_reload(ConfigContext ctx)
+{
+  ctx.in_progress();
+
+  if (ctx.supplied_yaml()) {
+    ctx.fail("virtualhost does not accept config content over rpc; only '_reload' directives are supported. "
+             "Update " +
+             std::string{ts::filename::VIRTUALHOST} + " and reload without a body.");
+    return;
+  }
+
+  // Single-entry reload requested via -D virtualhost.id=<id>
+  if (auto directives = ctx.reload_directives(); directives) {
+    // An unrecognized key (e.g. a mistyped 'ID') would otherwise fall through to a full reload.
+    for (const auto &kv : directives) {
+      std::string key = kv.first.IsScalar() ? kv.first.as<std::string>() : std::string{"<non-scalar>"};
+      if (key != "id") {
+        ctx.fail("virtualhost '_reload' directive '" + key + "' is not supported; only 'id' is recognized");
+        return;
+      }
+    }
+    if (const auto id_dir = directives["id"]; id_dir) {
+      if (!id_dir.IsScalar()) {
+        ctx.fail("virtualhost '_reload' directive 'id' must be a scalar");
+        return;
+      }
+      std::string id = id_dir.as<std::string>();
+      if (id.empty()) {
+        // An empty id would otherwise fall through to a full reload, turning a request scoped to
+        // one entry into a rebuild of the whole table from whatever is currently on disk.
+        ctx.fail("virtualhost '_reload' directive 'id' must name an entry; omit 'id' entirely to reload the whole file");
+        return;
+      }
+      if (VirtualHost::reconfigure(id, ctx)) {
+        ctx.complete("Reloaded virtualhost entry: " + id);
+      } else {
+        ctx.fail("Failed to reload virtualhost entry: " + id);
+      }
+      return;
+    }
+  }
+
+  if (VirtualHost::reconfigure(ctx)) {
+    ctx.complete("Finished loading virtualhost config");
+  } else {
+    ctx.fail("Failed to load virtualhost config");
+  }
+}
+} // namespace
+
+void
+VirtualHost::startup()
+{
+  if (!reconfigure({}, true)) {
+    Fatal("failed to load %s", ts::filename::VIRTUALHOST);
+  }
+
+  config::ConfigRegistry::Get_Instance().register_config(
+    "virtualhost",                          // registry key
+    ts::filename::VIRTUALHOST,              // default filename
+    "proxy.config.virtualhost.filename",    // record holding the filename
+    virtualhost_reload,                     // reload handler
+    config::ConfigSource::FileAndRpc,       // rpc may supply '_reload' directives; content is rejected
+    {"proxy.config.virtualhost.filename"}); // trigger records
+}
+
+int
+VirtualHost::reconfigure(ConfigContext ctx, bool initial_load)
+{
+  CfgLoadLog(ctx, DL_Note, "%s loading ...", ts::filename::VIRTUALHOST);
+  auto config = std::make_unique<VirtualHostConfig>();
+
+  // The parse is inside the lock as well as the publish; see vhost_reconfigure_mutex.
+  std::scoped_lock        lock(vhost_reconfigure_mutex);
+  VirtualHostPluginReload plugin_reload;
+
+  if (!config->load(ctx, initial_load, &plugin_reload)) {
+    CfgLoadLog(ctx, DL_Error, "%s failed to load", ts::filename::VIRTUALHOST);
+    return 0;
+  }
+  plugin_reload.finish(*config);
+  _configid = configProcessor.set(_configid, config.release());
+
+  CfgLoadLog(ctx, DL_Note, "%s finished loading", ts::filename::VIRTUALHOST);
+  return 1;
+}
+
+int
+VirtualHost::reconfigure(std::string_view id, ConfigContext ctx)
+{
+  Dbg(dbg_ctl_virtualhost, "Reconfiguring virtualhost entry: %.*s", static_cast<int>(id.size()), id.data());
+  if (id.empty()) {
+    // Reinterpreting this as a full reload would let a narrowly scoped request rebuild the whole
+    // table from disk. Callers that want that must ask for it directly.
+    CfgLoadLog(ctx, DL_Error, "Cannot reload a virtualhost entry without an id");
+    return 0;
+  }
+
+  // The parse is inside the lock as well as the publish; see vhost_reconfigure_mutex.
+  std::scoped_lock        lock(vhost_reconfigure_mutex);
+  VirtualHostPluginReload plugin_reload;
+
+  Ptr<VirtualHostConfig::Entry> entry;
+  if (!VirtualHostConfig::load_entry(id, entry, ctx, &plugin_reload)) {
+    return 0;
+  }
+
+  VirtualHost::scoped_config         vhost_config;
+  std::unique_ptr<VirtualHostConfig> config;
+
+  if (vhost_config) {
+    config = std::make_unique<VirtualHostConfig>(*vhost_config);
+  } else {
+    config = std::make_unique<VirtualHostConfig>();
+  }
+
+  if (!config->set_entry(id, entry, ctx)) {
+    return 0;
+  }
+  plugin_reload.finish(*config);
+  _configid = configProcessor.set(_configid, config.release());
+  return 1;
+}
+
+VirtualHostConfig *
+VirtualHost::acquire()
+{
+  return static_cast<VirtualHostConfig *>(configProcessor.get(_configid));
+}
+
+void
+VirtualHost::release(VirtualHostConfig *config)
+{
+  if (config && _configid > 0) {
+    configProcessor.release(_configid, config);
+  }
+}
diff --git a/src/proxy/http/HttpSM.cc b/src/proxy/http/HttpSM.cc
index db92c3b569..740048b802 100644
--- a/src/proxy/http/HttpSM.cc
+++ b/src/proxy/http/HttpSM.cc
@@ -4717,13 +4717,53 @@ HttpSM::check_sni_host()
   }
 }
 
+void
+HttpSM::set_virtualhost_entry(std::string_view domain)
+{
+  VirtualHost::scoped_config vhost_config;
+  // If already set, don't need to look at configs
+  if (m_virtualhost_entry || domain.empty() || !vhost_config) {
+    return;
+  }
+
+  auto vhost_entry = vhost_config->find_by_domain(domain);
+  if (vhost_entry) {
+    SMDbg(dbg_ctl_url_rewrite, "Found virtualhost: %s", vhost_entry->get_id().c_str());
+    m_virtualhost_entry = std::move(vhost_entry);
+  }
+}
+
 void
 HttpSM::do_remap_request(bool run_inline)
 {
   SMDbg(dbg_ctl_http_seq, "Remapping request");
   SMDbg(dbg_ctl_url_rewrite, "Starting a possible remapping for request");
+
+  if (!m_virtualhost_entry) {
+    auto host_name{t_state.hdr_info.client_request.host_get()};
+    set_virtualhost_entry(host_name);
+  }
+
+  // Check virtualhost remap rules before looking at remap.config. Copying the shared_ptr pins the
+  // table for the life of this transaction, so a reload that drops the entry cannot pull the table
+  // out from under us mid-transaction.
+  bool virtualhost_remap = false;
+  if (m_virtualhost_entry && m_virtualhost_entry->remap_table) {
+    m_remap           = m_virtualhost_entry->remap_table;
+    virtualhost_remap = true;
+    SMDbg(dbg_ctl_url_rewrite, "Using virtualhost remap table: %s", m_virtualhost_entry->get_id().c_str());
+  }
+
   bool ret = remapProcessor.setup_for_remap(&t_state, m_remap.get());
 
+  // If no remap matches in virtualhost, revert to default remap configs
+  if (!ret && virtualhost_remap) {
+    SMDbg(dbg_ctl_url_rewrite, "No virtualhost remap rules found: using global remap table");
+    // May be null once shutdown has cleared the table; setup_for_remap() handles that.
+    m_remap = rewrite_table.load(std::memory_order_acquire);
+    ret     = remapProcessor.setup_for_remap(&t_state, m_remap.get());
+  }
+
   check_sni_host();
 
   if (!ret) {
diff --git a/src/proxy/http/remap/PluginFactory.cc b/src/proxy/http/remap/PluginFactory.cc
index f68eebc697..254fdc4894 100644
--- a/src/proxy/http/remap/PluginFactory.cc
+++ b/src/proxy/http/remap/PluginFactory.cc
@@ -344,11 +344,19 @@ PluginFactory::indicatePreReload()
 void
 PluginFactory::indicatePostReload(bool reloadSuccessful)
 {
-  /* Find out which plugins (DSO) are actually instantiated by this factory */
   std::unordered_map<PluginDso *, int> pluginUsed;
+  collectUsedPlugins(pluginUsed);
+
+  PluginDso::loadedPlugins()->indicatePostReload(reloadSuccessful, pluginUsed, getUuid());
+}
+
+/**
+ * @brief Add the plugins (DSO) actually instantiated by this factory to @a pluginUsed
+ */
+void
+PluginFactory::collectUsedPlugins(std::unordered_map<PluginDso *, int> &pluginUsed)
+{
   for (auto &inst : _instList) {
     pluginUsed[&(inst._plugin)]++;
   }
-
-  PluginDso::loadedPlugins()->indicatePostReload(reloadSuccessful, pluginUsed, getUuid());
 }
diff --git a/src/proxy/http/remap/RemapYamlConfig.cc b/src/proxy/http/remap/RemapYamlConfig.cc
index be9fa3459e..ffe5b41809 100644
--- a/src/proxy/http/remap/RemapYamlConfig.cc
+++ b/src/proxy/http/remap/RemapYamlConfig.cc
@@ -1026,6 +1026,51 @@ remap_parse_yaml_bti(const char *path, BUILD_TABLE_INFO *bti, ConfigContext ctx)
   return false;
 }
 
+bool
+remap_parse_yaml_bti(YAML::Node const *remap_node, BUILD_TABLE_INFO *bti, ConfigContext ctx)
+{
+  try {
+    if (!remap_node || remap_node->IsNull() || !remap_node->IsSequence()) {
+      Dbg(dbg_ctl_remap_yaml, "Remap node must be a sequence");
+      return false;
+    }
+
+    Dbg(dbg_ctl_url_rewrite, "[BuildTable] UrlRewrite::BuildTable()");
+
+    ACLBehaviorPolicy behavior_policy = ACLBehaviorPolicy::ACL_BEHAVIOR_LEGACY;
+    if (!UrlRewrite::get_acl_behavior_policy(behavior_policy)) {
+      CfgLoadLog(ctx, DL_Warning, "Failed to get ACL matching policy.");
+      return false;
+    }
+    bti->behavior_policy = behavior_policy;
+
+    for (const auto &rule : *remap_node) {
+      bti->reset();
+
+      auto errata = parse_yaml_remap_rule(rule, bti);
+      if (!errata.is_ok()) {
+        CfgLoadLog(ctx, DL_Error, "Failed to parse remap rule");
+        return false;
+      }
+    }
+
+    // Deliberately do NOT call IpAllow::enableAcceptCheck() here. accept_check_p is a single
+    // process-wide flag owned by the global remap table; the accept-time fast-deny decision is made
+    // before any host is known, so a per-domain rule set cannot meaningfully influence it. Writing it
+    // from here would let the last inline config parsed silently override a `deactivate_filter:
+    // ip_allow` in the global remap config.
+
+    Dbg(dbg_ctl_remap_yaml, "Successfully parsed inline remap YAML rules");
+    return true;
+
+  } catch (YAML::Exception &ex) {
+    CfgLoadLog(ctx, DL_Error, "YAML parsing error in inline remap rules: %s", ex.what());
+  } catch (std::exception &ex) {
+    CfgLoadLog(ctx, DL_Error, "Exception parsing inline remap YAML rules: %s", ex.what());
+  }
+  return false;
+}
+
 bool
 remap_parse_yaml(const char *path, UrlRewrite *rewrite, ConfigContext ctx)
 {
@@ -1046,3 +1091,18 @@ remap_parse_yaml(const char *path, UrlRewrite *rewrite, ConfigContext ctx)
 
   return status;
 }
+
+bool
+remap_parse_yaml(YAML::Node const *remap_node, UrlRewrite *rewrite, ConfigContext ctx)
+{
+  BUILD_TABLE_INFO bti;
+
+  // No plugin reload notification here: the caller builds several tables per reload and must send
+  // one pre/post pair for all of them, see VirtualHostPluginReload.
+  bti.rewrite = rewrite;
+  bool status = remap_parse_yaml_bti(remap_node, &bti, ctx);
+
+  bti.clear_acl_rules_list();
+
+  return status;
+}
diff --git a/src/proxy/http/remap/UrlRewrite.cc b/src/proxy/http/remap/UrlRewrite.cc
index a70dbf7b54..6b8387218a 100644
--- a/src/proxy/http/remap/UrlRewrite.cc
+++ b/src/proxy/http/remap/UrlRewrite.cc
@@ -97,7 +97,12 @@ UrlRewrite::load(ConfigContext ctx)
       return false;
     }
   }
+  return load_table(std::string(config_file_path.get()), nullptr, ctx);
+}
 
+bool
+UrlRewrite::load_table(const std::string &path, YAML::Node const *remap_node, ConfigContext ctx)
+{
   this->ts_name = nullptr;
   if (auto rec_str{RecGetRecordStringAlloc("proxy.config.proxy_name")}; rec_str) {
     this->ts_name = ats_stringdup(rec_str);
@@ -145,7 +150,7 @@ UrlRewrite::load(ConfigContext ctx)
   Dbg(dbg_ctl_url_rewrite_regex, "strategyFactory file: %s", sf.c_str());
   strategyFactory = new NextHopStrategyFactory(sf.c_str());
 
-  if (TS_SUCCESS == this->BuildTable(config_file_path, ctx)) {
+  if (TS_SUCCESS == this->BuildTable(path.c_str(), remap_node, ctx)) {
     int n_rules = this->rule_count(); // Minimum # of rules to be considered a valid configuration.
     int required_rules;
     required_rules = RecGetRecordInt("proxy.config.url_remap.min_rules_required").value_or(0);
@@ -819,7 +824,7 @@ UrlRewrite::InsertForwardMapping(mapping_type maptype, url_mapping *mapping, con
 
 */
 int
-UrlRewrite::BuildTable(const char *path, ConfigContext ctx)
+UrlRewrite::BuildTable(const char *path, YAML::Node const *remap_node, ConfigContext ctx)
 {
   ink_assert(forward_mappings.empty());
   ink_assert(reverse_mappings.empty());
@@ -832,6 +837,10 @@ UrlRewrite::BuildTable(const char *path, ConfigContext ctx)
   ink_assert(num_rules_redirect_temporary == 0);
   ink_assert(num_rules_forward_with_recv_port == 0);
 
+  // A supplied node is only reachable through the yaml parser; otherwise the node is silently
+  // dropped and @a path is parsed as a remap.config file name.
+  ink_release_assert(!remap_node || is_remap_yaml());
+
   forward_mappings.hash_lookup.reset(new URLTable);
   reverse_mappings.hash_lookup.reset(new URLTable);
   permanent_redirects.hash_lookup.reset(new URLTable);
@@ -840,7 +849,11 @@ UrlRewrite::BuildTable(const char *path, ConfigContext ctx)
 
   bool parse_success;
   if (is_remap_yaml()) {
-    parse_success = remap_parse_yaml(path, this, ctx);
+    if (remap_node) {
+      parse_success = remap_parse_yaml(remap_node, this, ctx);
+    } else {
+      parse_success = remap_parse_yaml(path, this, ctx);
+    }
   } else {
     parse_success = remap_parse_config(path, this, ctx);
   }
diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc
index f4eb6fcd1e..3e7e4b6f3c 100644
--- a/src/records/RecordsConfig.cc
+++ b/src/records/RecordsConfig.cc
@@ -1149,6 +1149,8 @@ static constexpr RecordElement RecordsConfig[] =
   ,
   {RECT_CONFIG, "proxy.config.url_remap.acl_behavior_policy", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_INT, "[0-1]", RECA_NULL}
   ,
+  {RECT_CONFIG, "proxy.config.virtualhost.filename", RECD_STRING, ts::filename::VIRTUALHOST, RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL}
+  ,
 
   //##############################################################################
   //#
diff --git a/tests/gold_tests/autest-site/config_reload.test.ext b/tests/gold_tests/autest-site/config_reload.test.ext
index 8a9f352223..351c82009a 100644
--- a/tests/gold_tests/autest-site/config_reload.test.ext
+++ b/tests/gold_tests/autest-site/config_reload.test.ext
@@ -146,6 +146,7 @@ def AddConfigReload(
     expect_absent_tasks=None,
     delay_start=None,
     description=None,
+    directives=None,
 ):
     """Trigger a config reload, block until completion, and validate the result.
 
@@ -174,6 +175,8 @@ def AddConfigReload(
             Useful when the previous step writes a config file and CI may
             run fast enough that the filesystem timestamp hasn't changed.
         description: Custom TestRun description.
+        directives: Dict of reload directives passed with --directive, e.g.
+            {"virtualhost.id": "foo"}. Scopes the reload to the named handlers.
 
     Returns:
         The reload TestRun object (first test run).
@@ -204,6 +207,10 @@ def AddConfigReload(
         cmd += " --force"
     if data is not None:
         cmd += f" --data {_shell_quote(data)}"
+    # --directive= keeps each pair attached to its own option, so a following
+    # option is never swallowed as a directive value.
+    for key, value in (directives or {}).items():
+        cmd += f" --directive={_shell_quote(f'{key}={value}')}"
 
     tr.Processes.Default.Command = cmd
 
diff --git a/tests/gold_tests/autest-site/trafficserver.test.ext b/tests/gold_tests/autest-site/trafficserver.test.ext
index fa31324a14..7047baa6ee 100755
--- a/tests/gold_tests/autest-site/trafficserver.test.ext
+++ b/tests/gold_tests/autest-site/trafficserver.test.ext
@@ -320,6 +320,10 @@ def MakeATSProcess(
     tmpname = os.path.join(config_dir, fname)
     p.Disk.File(tmpname, id=make_id(fname), typename="ats:config")
 
+    fname = "virtualhost.yaml"
+    tmpname = os.path.join(config_dir, fname)
+    p.Disk.File(tmpname, id=make_id(fname), typename="ats:config")
+
     # The big motivation in exposing this file is that we need to tell the traffic_ctl
     # where to find the socket to interact with the TS. traffic_ctl cannot rely only
     # in the build layout for unit test.
diff --git a/tests/gold_tests/jsonrpc/config_reload_rpc.test.py b/tests/gold_tests/jsonrpc/config_reload_rpc.test.py
index 55d3810345..c9aa27392f 100644
--- a/tests/gold_tests/jsonrpc/config_reload_rpc.test.py
+++ b/tests/gold_tests/jsonrpc/config_reload_rpc.test.py
@@ -42,6 +42,14 @@ ts.Disk.records_config.update({
     'proxy.config.diags.debug.tags': 'rpc|config',
 })
 
+# Used by Test 12: a single-entry reload of this id must find it on disk.
+ts.Disk.virtualhost_yaml.AddLines([
+    'virtualhost:',
+    '  - id: myhost.example.com',
+    '    domains:',
+    '      - myhost.example.com',
+])
+
 # ============================================================================
 # Test 1: File-based reload (no configs parameter)
 # ============================================================================
@@ -424,30 +432,36 @@ tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_dir
 tr.StillRunningAfter = ts
 
 # ============================================================================
-# Test 12: Reload directive for unregistered config (virtualhost)
-# virtualhost is not registered yet — should get 6010.
-# This is the intended use case once the virtualhost handler is registered.
+# Test 12: Reload directive routes to registered handler (virtualhost)
+# virtualhost is registered as FileAndRpc — the _reload directive should be
+# accepted by the framework and forwarded to VirtualHost's handler, which
+# schedules an inline reload and returns a task token.
 # ============================================================================
-tr = Test.AddTestRun("Reload directive for unregistered config (virtualhost)")
+tr = Test.AddTestRun("Reload directive routed to registered virtualhost handler")
 tr.DelayStart = 2
 tr.AddJsonRPCClientRequest(ts, Request.admin_config_reload(configs={"virtualhost": {"_reload": {"id": "myhost.example.com"}}}))
 
 
-def validate_directive_unregistered(resp: Response):
-    '''virtualhost is not registered — rejected with 6010'''
+def validate_directive_routed(resp: Response):
+    '''virtualhost is registered as FileAndRpc — the directive should be accepted and scheduled'''
     result = resp.result
     errors = result.get('errors', [])
 
-    if not errors:
-        return (False, f"Expected error for unregistered config, got: {result}")
+    if errors:
+        error_str = str(errors)
+        if '6010' in error_str or 'not registered' in error_str:
+            return (False, f"virtualhost should now be registered, got: {errors}")
+        return (False, f"Unexpected synchronous error: {errors}")
 
-    error_str = str(errors)
-    if '6010' in error_str or 'not registered' in error_str:
-        return (True, f"Directive for unregistered config rejected: {errors}")
-    return (False, f"Expected error 6010, got: {errors}")
+    tasks = result.get('tasks', [])
+    message = result.get('message', [])
+    if tasks or message:
+        return (True, f"Directive accepted and reload scheduled: tasks={tasks}, message={message}")
+
+    return (False, f"Expected scheduled reload, got: {result}")
 
 
-tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_directive_unregistered)
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_directive_routed)
 tr.StillRunningAfter = ts
 
 # ============================================================================
@@ -489,3 +503,436 @@ def validate_directive_mixed(resp: Response):
 
 tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_directive_mixed)
 tr.StillRunningAfter = ts
+
+# ============================================================================
+# Test 14: virtualhost config content over RPC is rejected
+# virtualhost is FileAndRpc, but only '_reload' directives are supported — a
+# supplied body must be refused instead of silently ignored. The handler reports
+# the rejection through the reload task log, so the rejection is verified by
+# querying the task status rather than by scanning diags.log.
+# ============================================================================
+vhost_reject_token = "vhost-content-reject"
+
+tr = Test.AddTestRun("virtualhost rejects pushed config content")
+tr.DelayStart = 2
+tr.AddJsonRPCClientRequest(
+    ts,
+    Request.admin_config_reload(
+        token=vhost_reject_token, configs={"virtualhost": [{
+            "id": "pushed.example.com",
+            "domains": ["pushed.example.com"]
+        }]}))
+
+
+def validate_content_rejected(resp: Response):
+    '''Content is accepted by the framework and rejected by the handler'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected synchronous error: {errors}")
+
+    return (True, f"Content accepted by framework, handler expected to reject: {result}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_content_rejected)
+tr.StillRunningAfter = ts
+
+# The handler's rejection lands in the reload task log — query it by token.
+tr = Test.AddTestRun("virtualhost content rejection is reported in the reload task log")
+tr.DelayStart = 2
+tr.AddJsonRPCClientRequest(ts, Request.get_reload_config_status(token=vhost_reject_token))
+
+
+def validate_content_rejection_logged(resp: Response):
+    '''The virtualhost subtask should be FAIL and carry the rejection message'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected error querying status: {errors}")
+
+    expected = "virtualhost does not accept config content over rpc"
+
+    def find_rejection(task_list):
+        for t in task_list:
+            for entry in t.get('logs', []):
+                if expected in entry.get('text', ''):
+                    return t
+            found = find_rejection(t.get('sub_tasks', []))
+            if found:
+                return found
+        return None
+
+    tasks = result.get('tasks', [])
+    task = find_rejection(tasks)
+
+    if task is None:
+        return (False, f"Pushed virtualhost content should be rejected, no such log in: {tasks}")
+
+    status = task.get('status', '')
+    if status != 'fail':
+        return (False, f"Expected the rejecting task to be 'fail', got '{status}': {task}")
+
+    return (True, f"Pushed virtualhost content rejected and logged: {task.get('description', '')}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_content_rejection_logged)
+tr.StillRunningAfter = ts
+
+# ============================================================================
+# Test 15: single-entry reload of an id that is not in virtualhost.yaml
+# This logs a core ERROR, so it runs against its own ATS instance whose diags
+# expectations are replaced. The handler reports the failure through the reload
+# task log, which is where an operator sees it, so that is asserted too.
+# ============================================================================
+ts_unknown = Test.MakeATSProcess('ts-unknown-id')
+ts_unknown.Disk.records_config.update({
+    'proxy.config.diags.debug.enabled': 1,
+    'proxy.config.diags.debug.tags': 'rpc|config',
+})
+ts_unknown.Disk.virtualhost_yaml.AddLines(
+    [
+        'virtualhost:',
+        '  - id: present.example.com',
+        '    domains:',
+        '      - present.example.com',
+    ])
+ts_unknown.Disk.diags_log.Content = Testers.ContainsExpression(
+    "virtualhost with id 'absent.example.com' not found", "Reloading an unknown id should report it as not found")
+ts_unknown.Disk.diags_log.Content += Testers.ExcludesExpression("FATAL:", "Unknown id should not be fatal")
+
+vhost_unknown_id_token = "vhost-unknown-id"
+
+tr = Test.AddTestRun("Single-entry reload of an unknown virtualhost id")
+tr.Processes.Default.StartBefore(ts_unknown)
+tr.AddJsonRPCClientRequest(
+    ts_unknown,
+    Request.admin_config_reload(token=vhost_unknown_id_token, configs={"virtualhost": {
+        "_reload": {
+            "id": "absent.example.com"
+        }
+    }}))
+
+
+def validate_unknown_id(resp: Response):
+    '''Accepted by the framework; the handler fails because the id is not on disk'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected synchronous error: {errors}")
+
+    return (True, f"Directive accepted, handler expected to fail: {result}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_unknown_id)
+tr.StillRunningAfter = ts_unknown
+
+
+def find_failed_task_with(task_list, expected):
+    '''Depth-first search for a failed task whose log carries `expected`'''
+    for t in task_list:
+        for entry in t.get('logs', []):
+            if expected in entry.get('text', ''):
+                return t
+        found = find_failed_task_with(t.get('sub_tasks', []), expected)
+        if found:
+            return found
+    return None
+
+
+# The not-found diagnostic must reach the operator who asked for the reload, not
+# just diags.log — query the task log by token.
+tr = Test.AddTestRun("Unknown virtualhost id is reported in the reload task log")
+tr.DelayStart = 2
+tr.AddJsonRPCClientRequest(ts_unknown, Request.get_reload_config_status(token=vhost_unknown_id_token))
+
+
+def validate_unknown_id_logged(resp: Response):
+    '''The virtualhost subtask should be FAIL and name the missing id'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected error querying status: {errors}")
+
+    expected = "virtualhost with id 'absent.example.com' not found"
+    tasks = result.get('tasks', [])
+    task = find_failed_task_with(tasks, expected)
+
+    if task is None:
+        return (False, f"Expected '{expected}' in the reload task log, got: {tasks}")
+
+    status = task.get('status', '')
+    if status != 'fail':
+        return (False, f"Expected the reloading task to be 'fail', got '{status}': {task}")
+
+    return (True, f"Unknown id reported over rpc: {task.get('description', '')}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_unknown_id_logged)
+tr.StillRunningAfter = ts_unknown
+
+# ============================================================================
+# Test 16: single-entry reload with no virtualhost.yaml on disk
+# A missing file is handled like a missing config, not a parse failure — it must
+# not surface a yaml-cpp 'bad file' ERROR in diags.log.
+#
+# The startup path and the reload path report a missing file differently on
+# purpose: startup warns and carries on with no entries, a single-entry reload
+# fails. Asserting the reload-specific wording in the task log is what makes
+# this test depend on the RPC — the startup warning is emitted before any
+# request is sent, so asserting only that would pass with the handler removed.
+# ============================================================================
+ts_missing = Test.MakeATSProcess('ts-missing-file')
+ts_missing.Disk.records_config.update({
+    'proxy.config.diags.debug.enabled': 1,
+    'proxy.config.diags.debug.tags': 'rpc|config',
+})
+# No virtualhost.yaml is written for this instance.
+ts_missing.Disk.diags_log.Content = Testers.ContainsExpression(
+    "Virtualhost configuration .* doesn't exist, no virtualhost entries loaded",
+    "Startup with no virtualhost.yaml should warn and carry on")
+ts_missing.Disk.diags_log.Content += Testers.ExcludesExpression(
+    "bad file", "Missing virtualhost.yaml should not surface a yaml-cpp load failure")
+ts_missing.Disk.diags_log.Content += Testers.ExcludesExpression("FATAL:", "A missing virtualhost.yaml should not be fatal")
+
+vhost_missing_file_token = "vhost-missing-file"
+
+tr = Test.AddTestRun("Single-entry reload with no virtualhost.yaml")
+tr.Processes.Default.StartBefore(ts_missing)
+tr.AddJsonRPCClientRequest(
+    ts_missing,
+    Request.admin_config_reload(token=vhost_missing_file_token, configs={"virtualhost": {
+        "_reload": {
+            "id": "myhost.example.com"
+        }
+    }}))
+
+
+def validate_missing_file(resp: Response):
+    '''Accepted by the framework; the handler fails because the file is absent'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected synchronous error: {errors}")
+
+    return (True, f"Directive accepted, handler expected to fail: {result}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_missing_file)
+tr.StillRunningAfter = ts_missing
+
+tr = Test.AddTestRun("Missing virtualhost.yaml is reported in the reload task log")
+tr.DelayStart = 2
+tr.AddJsonRPCClientRequest(ts_missing, Request.get_reload_config_status(token=vhost_missing_file_token))
+
+
+def validate_missing_file_logged(resp: Response):
+    '''The failure must be distinguishable from the benign startup warning'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected error querying status: {errors}")
+
+    expected = "Cannot reload virtualhost entry 'myhost.example.com'"
+    tasks = result.get('tasks', [])
+    task = find_failed_task_with(tasks, expected)
+
+    if task is None:
+        return (False, f"Expected '{expected}' in the reload task log, got: {tasks}")
+
+    status = task.get('status', '')
+    if status != 'fail':
+        return (False, f"Expected the reloading task to be 'fail', got '{status}': {task}")
+
+    return (True, f"Missing file reported over rpc: {task.get('description', '')}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_missing_file_logged)
+tr.StillRunningAfter = ts_missing
+
+# ============================================================================
+# Test 17: an empty '_reload' id is refused, not widened to a full reload
+# The scoped request must not silently rebuild the whole table from disk, which
+# would pick up every unrelated edit currently in the file.
+# ============================================================================
+vhost_empty_id_token = "vhost-empty-id"
+
+tr = Test.AddTestRun("Single-entry reload with an empty virtualhost id")
+tr.AddJsonRPCClientRequest(
+    ts, Request.admin_config_reload(token=vhost_empty_id_token, configs={"virtualhost": {
+        "_reload": {
+            "id": ""
+        }
+    }}))
+
+
+def validate_empty_id(resp: Response):
+    '''Accepted by the framework; the handler refuses the directive'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected synchronous error: {errors}")
+
+    return (True, f"Directive accepted, handler expected to refuse: {result}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_empty_id)
+tr.StillRunningAfter = ts
+
+tr = Test.AddTestRun("Empty virtualhost id is refused in the reload task log")
+tr.DelayStart = 2
+tr.AddJsonRPCClientRequest(ts, Request.get_reload_config_status(token=vhost_empty_id_token))
+
+
+def validate_empty_id_refused(resp: Response):
+    '''The subtask must FAIL and point at the way to ask for a full reload'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected error querying status: {errors}")
+
+    expected = "must name an entry"
+    tasks = result.get('tasks', [])
+    task = find_failed_task_with(tasks, expected)
+
+    if task is None:
+        return (False, f"Expected '{expected}' in the reload task log, got: {tasks}")
+
+    status = task.get('status', '')
+    if status != 'fail':
+        return (False, f"Expected the reloading task to be 'fail', got '{status}': {task}")
+
+    return (True, f"Empty id refused: {task.get('description', '')}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_empty_id_refused)
+tr.StillRunningAfter = ts
+
+# ============================================================================
+# Test 18: a full reload with no virtualhost.yaml on disk fails
+# Startup tolerates an absent file; a reload must not, because publishing an
+# empty config would drop every live per-domain remap table and still report
+# success. Reuses the instance from Test 16, which has no virtualhost.yaml.
+# ============================================================================
+ts_missing.Disk.diags_log.Content += Testers.ContainsExpression(
+    "Cannot reload virtualhost config", "A full reload with no virtualhost.yaml must not report success")
+
+vhost_missing_full_token = "vhost-missing-file-full"
+
+tr = Test.AddTestRun("Full virtualhost reload with no virtualhost.yaml")
+tr.AddJsonRPCClientRequest(
+    ts_missing, Request.admin_config_reload(token=vhost_missing_full_token, configs={"virtualhost": {
+        "_reload": {}
+    }}))
+
+
+def validate_missing_file_full(resp: Response):
+    '''Accepted by the framework; the handler fails because the file is absent'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected synchronous error: {errors}")
+
+    return (True, f"Request accepted, handler expected to fail: {result}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_missing_file_full)
+tr.StillRunningAfter = ts_missing
+
+tr = Test.AddTestRun("Full reload with no virtualhost.yaml is reported as a failure")
+tr.DelayStart = 2
+tr.AddJsonRPCClientRequest(ts_missing, Request.get_reload_config_status(token=vhost_missing_full_token))
+
+
+def validate_missing_file_full_logged(resp: Response):
+    '''The whole-file reload must fail rather than publish an empty table'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected error querying status: {errors}")
+
+    expected = "Cannot reload virtualhost config"
+    tasks = result.get('tasks', [])
+    task = find_failed_task_with(tasks, expected)
+
+    if task is None:
+        return (False, f"Expected '{expected}' in the reload task log, got: {tasks}")
+
+    status = task.get('status', '')
+    if status != 'fail':
+        return (False, f"Expected the reloading task to be 'fail', got '{status}': {task}")
+
+    return (True, f"Missing file refused on a full reload: {task.get('description', '')}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_missing_file_full_logged)
+tr.StillRunningAfter = ts_missing
+
+# ============================================================================
+# Test 19: an unrecognized '_reload' key is refused, not widened to a full reload
+# A mistyped key like 'ID' must not rebuild the whole table from disk.
+# ============================================================================
+vhost_unknown_key_token = "vhost-unknown-key"
+
+tr = Test.AddTestRun("Reload directive with an unrecognized virtualhost key")
+tr.AddJsonRPCClientRequest(
+    ts,
+    Request.admin_config_reload(token=vhost_unknown_key_token, configs={"virtualhost": {
+        "_reload": {
+            "ID": "myhost.example.com"
+        }
+    }}))
+
+
+def validate_unknown_directive(resp: Response):
+    '''Accepted by the framework; the handler refuses the directive'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected synchronous error: {errors}")
+
+    return (True, f"Directive accepted, handler expected to refuse: {result}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_unknown_directive)
+tr.StillRunningAfter = ts
+
+tr = Test.AddTestRun("Unrecognized virtualhost key is refused in the reload task log")
+tr.DelayStart = 2
+tr.AddJsonRPCClientRequest(ts, Request.get_reload_config_status(token=vhost_unknown_key_token))
+
+
+def validate_unknown_directive_refused(resp: Response):
+    '''The subtask must FAIL and name the offending key'''
+    result = resp.result
+    errors = result.get('errors', [])
+
+    if errors:
+        return (False, f"Unexpected error querying status: {errors}")
+
+    expected = "directive 'ID' is not supported"
+    tasks = result.get('tasks', [])
+    task = find_failed_task_with(tasks, expected)
+
+    if task is None:
+        return (False, f"Expected '{expected}' in the reload task log, got: {tasks}")
+
+    status = task.get('status', '')
+    if status != 'fail':
+        return (False, f"Expected the reloading task to be 'fail', got '{status}': {task}")
+
+    return (True, f"Unknown key refused: {task.get('description', '')}")
+
+
+tr.Processes.Default.Streams.stdout = Testers.CustomJSONRPCResponse(validate_unknown_directive_refused)
+tr.StillRunningAfter = ts
diff --git a/tests/gold_tests/remap/virtualhost_remap.test.py b/tests/gold_tests/remap/virtualhost_remap.test.py
new file mode 100644
index 0000000000..79871fd36b
--- /dev/null
+++ b/tests/gold_tests/remap/virtualhost_remap.test.py
@@ -0,0 +1,255 @@
+'''
+Verify that per-domain remap tables in virtualhost.yaml are applied to requests.
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+Test.Summary = '''
+Verify virtualhost.yaml domain resolution and per-domain remap rules on the request path.
+'''
+
+import os
+
+Test.ContinueOnFail = True
+Test.testName = 'virtualhost_remap'
+
+ts = Test.MakeATSProcess("ts")
+
+# The origin is keyed on the request path only (the default lookup key), so each
+# remap rule can be given a private target path. Which rule won is then decided
+# by which body comes back, not merely by getting a 200.
+server = Test.MakeOriginServer("server")
+
+
+def add_origin_response(path: str, body: str) -> None:
+    """Register an origin response for `path` carrying a rule-specific body."""
+    request_header = {
+        "headers": f"GET {path} HTTP/1.1\r\nHost: origin.example.com\r\n\r\n",
+        "timestamp": "1469733493.993",
+        "body": ""
+    }
+    response_header = {"headers": "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", "timestamp": "1469733493.993", "body": body}
+    server.addResponse("sessionfile.log", request_header, response_header)
+
+
+# One target path per remap rule that could plausibly fire. The bodies are chosen
+# so that no expected body is a substring of another.
+add_origin_response("/vhost-exact-domain/", "hit:vhost-exact-domain")
+add_origin_response("/vhost-deep-wildcard/", "hit:vhost-deep-wildcard")
+add_origin_response("/vhost-wide-wildcard/", "hit:vhost-wide-wildcard")
+add_origin_response("/vhost-wildcard-precedence/", "hit:vhost-wildcard-precedence")
+add_origin_response("/vhost-exact-precedence/", "hit:vhost-exact-precedence")
+add_origin_response("/vhost-fallback-rule/", "hit:vhost-fallback-rule")
+add_origin_response("/global-fallback/other/", "hit:global-fallback")
+add_origin_response("/global-plain/", "hit:global-plain")
+# Only reachable if the rejected reload below is wrongly published.
+add_origin_response("/vhost-conflict/", "hit:vhost-conflict")
+
+ts.Disk.records_config.update({
+    'proxy.config.diags.debug.enabled': 1,
+    'proxy.config.diags.debug.tags': 'virtualhost|url_rewrite',
+})
+
+# The refused reload at the end of this test logs the conflict as an ERROR, which
+# replaces the default diags expectations.
+ts.Disk.diags_log.Content = Testers.ContainsExpression(
+    "is already claimed by virtualhost 'exact-only'", "The conflicting reload should name the virtualhost holding the domain")
+ts.Disk.diags_log.Content += Testers.ExcludesExpression("FATAL:", "A refused reload must not be fatal")
+
+origin = f'127.0.0.1:{server.Variables.Port}'
+
+# Global table, in remap.config (legacy) format. The virtualhost tables below are
+# always YAML, so this also covers the mixed case.
+ts.Disk.remap_config.AddLines(
+    [
+        f'map http://fallback.example.net/ http://{origin}/global-fallback/',
+        f'map http://none.example.net/ http://{origin}/global-plain/',
+    ])
+
+vhost_config_lines = [
+    'virtualhost:',
+    # Plain exact-domain match. No wildcard in this config matches .example.org.
+    '  - id: exact-only',
+    '    domains:',
+    '      - exact.example.org',
+    '    remap:',
+    '      - type: map',
+    '        from:',
+    '          url: http://exact.example.org/',
+    '        to:',
+    f'          url: http://{origin}/vhost-exact-domain/',
+    # x.deep.example.com matches both this wildcard and the wider one below.
+    # The longest (most specific) suffix must win.
+    '  - id: deep-wildcard',
+    '    domains:',
+    '      - "*.deep.example.com"',
+    '    remap:',
+    '      - type: map',
+    '        from:',
+    '          url: http://x.deep.example.com/',
+    '        to:',
+    f'          url: http://{origin}/vhost-deep-wildcard/',
+    '  - id: wide-wildcard',
+    '    domains:',
+    '      - "*.example.com"',
+    '    remap:',
+    '      - type: map',
+    '        from:',
+    '          url: http://x.deep.example.com/',
+    '        to:',
+    f'          url: http://{origin}/vhost-wide-wildcard/',
+    '      - type: map',
+    '        from:',
+    '          url: http://precedence.example.com/',
+    '        to:',
+    f'          url: http://{origin}/vhost-wildcard-precedence/',
+    # precedence.example.com is claimed exactly here and by the wildcard
+    # above. The exact domain must win.
+    '  - id: exact-precedence',
+    '    domains:',
+    '      - precedence.example.com',
+    '    remap:',
+    '      - type: map',
+    '        from:',
+    '          url: http://precedence.example.com/',
+    '        to:',
+    f'          url: http://{origin}/vhost-exact-precedence/',
+    # This virtualhost resolves for fallback.example.net but its only rule
+    # covers a different path, so requests elsewhere must fall back to the
+    # global table.
+    '  - id: path-miss',
+    '    domains:',
+    '      - fallback.example.net',
+    '    remap:',
+    '      - type: map',
+    '        from:',
+    '          url: http://fallback.example.net/only-here/',
+    '        to:',
+    f'          url: http://{origin}/vhost-fallback-rule/',
+]
+
+ts.Disk.virtualhost_yaml.AddLines(vhost_config_lines)
+
+
+def add_request(name: str, host: str, path: str, expected: str, not_expected: str = "") -> 'TestRun':
+    """Send one request through the proxy and assert which remap rule served it.
+
+    :param name: Test run name.
+    :param host: Host header, which selects the virtualhost.
+    :param path: Request path.
+    :param expected: Body of the rule that must have won.
+    :param not_expected: Body of the rule that must have lost, if any.
+    """
+    tr = Test.AddTestRun(name)
+    tr.MakeCurlCommand(f'-s -H"Host: {host}" http://127.0.0.1:{ts.Variables.port}{path} --verbose', ts=ts)
+    tr.Processes.Default.ReturnCode = 0
+    tr.Processes.Default.Streams.stdout = Testers.ContainsExpression(expected, f"{host}{path} should be served by {expected}")
+    if not_expected:
+        tr.Processes.Default.Streams.stdout += Testers.ExcludesExpression(
+            not_expected, f"{host}{path} must not be served by {not_expected}")
+    tr.StillRunningAfter = ts
+    tr.StillRunningAfter += server
+    return tr
+
+
+# The first run starts the processes.
+tr = add_request(
+    "Exact virtualhost domain uses its own remap table", "exact.example.org", "/", "hit:vhost-exact-domain", "hit:global-plain")
+tr.Processes.Default.StartBefore(server)
+tr.Processes.Default.StartBefore(ts)
+
+add_request("Longest wildcard suffix wins", "x.deep.example.com", "/", "hit:vhost-deep-wildcard", "hit:vhost-wide-wildcard")
+
+add_request(
+    "Exact domain takes precedence over a wildcard", "precedence.example.com", "/", "hit:vhost-exact-precedence",
+    "hit:vhost-wildcard-precedence")
+
+add_request(
+    "A virtualhost whose rules do not match falls back to the global table", "fallback.example.net", "/other/",
+    "hit:global-fallback", "hit:vhost-fallback-rule")
+
+add_request("A host with no virtualhost entry uses the global table", "none.example.net", "/", "hit:global-plain")
+
+# ============================================================================
+# A reload that is refused must leave the previous routing table serving.
+#
+# deep-wildcard is rewritten to also claim exact.example.org, which exact-only
+# already holds. That conflict is detected by set_entry(), inside the critical
+# section and after the copy of the live config has already dropped the old
+# deep-wildcard entry — so this exercises the one failure path that runs after
+# the read-copy-modify begins. Nothing may be published, and both the entry
+# that was reloaded and the entry it collided with must still serve their
+# original rules.
+# ============================================================================
+vhost_config_path = os.path.join(ts.Variables.CONFIGDIR, 'virtualhost.yaml')
+
+conflicting_config_lines = [
+    'virtualhost:',
+    '  - id: exact-only',
+    '    domains:',
+    '      - exact.example.org',
+    '    remap:',
+    '      - type: map',
+    '        from:',
+    '          url: http://exact.example.org/',
+    '        to:',
+    f'          url: http://{origin}/vhost-exact-domain/',
+    '  - id: deep-wildcard',
+    '    domains:',
+    '      - "*.deep.example.com"',
+    # Already claimed by exact-only above.
+    '      - exact.example.org',
+    '    remap:',
+    '      - type: map',
+    '        from:',
+    '          url: http://x.deep.example.com/',
+    '        to:',
+    f'          url: http://{origin}/vhost-conflict/',
+    '      - type: map',
+    '        from:',
+    '          url: http://exact.example.org/',
+    '        to:',
+    f'          url: http://{origin}/vhost-conflict/',
+]
+
+
+def write_conflicting_config() -> None:
+    """Replace virtualhost.yaml with a version whose deep-wildcard entry steals a claimed domain."""
+    with open(vhost_config_path, 'w') as f:
+        f.write("\n".join(conflicting_config_lines) + "\n")
+
+
+tr = Test.AddTestRun("Rewrite virtualhost.yaml so deep-wildcard claims a domain exact-only holds")
+tr.Processes.Default.Env = ts.Env
+tr.Processes.Default.Command = 'echo "rewrite virtualhost.yaml with a domain conflict"'
+tr.Processes.Default.Setup.Lambda(lambda: write_conflicting_config())
+tr.StillRunningAfter = ts
+
+Test.AddConfigReload(
+    ts,
+    expect="fail",
+    directives={"virtualhost.id": "deep-wildcard"},
+    expect_tasks={"virtualhost": "fail"},
+    delay_start=2,
+    description="Single-entry reload with a claimed domain is refused")
+
+add_request(
+    "The refused reload leaves the reloaded entry serving its old rules", "x.deep.example.com", "/", "hit:vhost-deep-wildcard",
+    "hit:vhost-conflict")
+
+add_request(
+    "The refused reload leaves the entry it collided with serving", "exact.example.org", "/", "hit:vhost-exact-domain",
+    "hit:vhost-conflict")