diff --git a/.gitmodules b/.gitmodules
index 20e0b98686..a3c5d9a4dd 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,6 @@
 [submodule "vendor/emojis"]
 	path = vendor/emojis
 	url = https://github.com/buildkite/emojis.git
+[submodule "vendor/migration"]
+	path = vendor/migration
+	url = https://github.com/buildkite/migration
diff --git a/Gemfile b/Gemfile
index 9f6ac4c776..d8605732c6 100644
--- a/Gemfile
+++ b/Gemfile
@@ -79,3 +79,5 @@ end
 group :test do
   gem "buildkite-test_collector"
 end
+
+gem "parslet"
diff --git a/Gemfile.lock b/Gemfile.lock
index c5a439b522..ed88c47af4 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -81,6 +81,7 @@ GEM
     nokogiri (1.15.2)
       mini_portile2 (~> 2.8.2)
       racc (~> 1.4)
+    parslet (2.0.0)
     pry (0.14.1)
       coderay (~> 1.1)
       method_source (~> 1.0)
@@ -175,6 +176,7 @@ DEPENDENCIES
   graphql-client
   lograge
   matrix
+  parslet
   pry
   puma
   railties (~> 6.0)
diff --git a/app/controllers/migration_controller.rb b/app/controllers/migration_controller.rb
new file mode 100644
index 0000000000..9c966b5c22
--- /dev/null
+++ b/app/controllers/migration_controller.rb
@@ -0,0 +1,20 @@
+class MigrationController < ApplicationController
+  skip_forgery_protection
+
+  def show
+    @nav = default_nav
+
+    render template: "migrate", layout: "homepage"
+  end
+
+  def migrate
+    # Some request path rewriting for the compat server to slot in.
+    request.env["REQUEST_PATH"] = "/"
+    request.env["REQUEST_URI"] = "/"
+    request.env["PATH_INFO"] = "/"
+
+    res = BK::Compat::Server.new.call(request.env)
+
+    render body: res[2].string, status: res[0], content_type: res[1]["content-type"]
+  end
+end
diff --git a/app/models/nav.rb b/app/models/nav.rb
index 7c74adda07..ddcba649b5 100644
--- a/app/models/nav.rb
+++ b/app/models/nav.rb
@@ -15,12 +15,7 @@ def current_item_root(request)
 
   # Returns the current nav item
   def current_item(request)
-    return nil if request.path == "/docs"
-
-    item = route_map[request.path.sub("/docs/", "")]
-    raise ActionController::RoutingError.new("Missing navigation for #{request.path}") unless item
-
-    item
+    route_map[request.path.sub("/docs/", "")]
   end
 
   # Returns a hash of routes, indexed by path
diff --git a/app/views/migrate.html.erb b/app/views/migrate.html.erb
new file mode 100644
index 0000000000..ab2369446c
--- /dev/null
+++ b/app/views/migrate.html.erb
@@ -0,0 +1,175 @@
+<style>
+  body {
+    font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Helvetica, sans-serif;
+    padding: 0;
+    margin: 0;
+  }
+  .flex {
+    display: flex;
+  }
+  .flex-column {
+    -webkit-box-orient: vertical;
+    -webkit-box-direction: normal;
+    -ms-flex-direction: column;
+    flex-direction: column;
+  }
+  .flex-auto {
+    -webkit-box-flex: 1;
+    -ms-flex: 1 1 auto;
+    flex: 1 1 auto;
+    min-width: 0;
+    min-height: 0;
+  }
+  .items-center {
+    align-items: center;
+  }
+  .items-stretch {
+    -webkit-box-align: stretch;
+    -ms-flex-align: stretch;
+    align-items: stretch;
+  }
+  .justify-center {
+    justify-content: center;
+  }
+  .center {
+    text-align: center;
+  }
+  input[type=submit] {
+    border-radius: 4px;
+    background-color: #14cc80;
+    border: 1px solid transparent;
+    color: white;
+    font-size: 1em;
+    padding: .75em 1em;
+    line-height: 1.2;
+    font-weight: bold;
+    font-family: inherit;
+    cursor: pointer;
+  }
+  input[type=submit]:hover {
+    box-shadow: inset 0 0 0 20rem rgba(0, 0, 0, .0625);
+  }
+  input[type=submit]:disabled {
+    opacity: 0.5;
+  }
+  textarea {
+    display: block;
+    width: 100%;
+    padding: 15px;
+    font-size: 12px;
+    font-family: "SFMono-Regular", Monaco, Menlo, Consolas, "Liberation Mono", Courier, monospace;
+    line-height: 1.42857143;
+    color: #555555;
+    background-color: #fff;
+    background-image: none;
+    border: 1px solid #ccc;
+    border-radius: .3em;
+    resize: none;
+    -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+            box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+
+    /* disable iOS' extra inner shadows */
+    -webkit-appearance: none;
+        -moz-appearance: none;
+            appearance: none
+  }
+  textarea:focus {
+    border-color: #888;
+    outline: 0;
+  }
+  textarea[readonly] {
+    background: #f9fafb;
+  }
+  .error {
+    background: #ff000044 !important;
+  }
+</style>
+
+
+<div class="flex items-stretch justify-center" style="min-height: 100%">
+  <form onSubmit="return transform(event)" class="flex items-stretch center flex-column" style="width: 100%; padding: 5%;">
+    <div class="flex flex-auto" style="margin-bottom: 20px;">
+      <textarea rows="20" style="width: 50%; margin-right: 20px" placeholder="👋 Paste or drag a config file here!" id="config" class="flex-auto"></textarea>
+      <textarea rows="20" style="width: 50%" readonly="true" id="results" placeholder="👈 Look over there" class="flex-auto"></textarea>
+    </div>
+    <div>
+      <input type="submit" value="Buildkite-ify!" id="button" />
+    </div>
+  </form>
+</div>
+
+<script>
+  function transform() {
+    var button = document.getElementById("button");
+
+    var configTextarea = document.getElementById("config");
+    var formData = new FormData();
+    formData.append("file", new Blob([configTextarea.value], { type: "text/plain" }));
+
+    button.disabled = true;
+
+    var resultsTextarea = document.getElementById("results");
+
+    fetch("<%= migrate_url %>", {
+      method: "POST",
+      credentials: 'same-origin',
+      body: formData
+    }).then(function(response) {
+      button.disabled = false;
+      if (!response.ok) {
+        resultsTextarea.classList.add('error')
+      } else {
+        resultsTextarea.classList.remove('error')
+      }
+      return response;
+    }).then(function(response) {
+      response.text().then(function(text) {
+        resultsTextarea.value = text;
+      });
+    }).catch((function(error) {
+      alert(error.message);
+      resultsTextarea.value = '';
+    }));
+
+    return false;
+  }
+
+  function handleFormSubmit() {
+    event.preventDefault();
+
+    transform();
+  }
+
+  function handleFileSelect(evt) {
+    evt.stopPropagation();
+    evt.preventDefault();
+
+    var file = evt.dataTransfer.files[0];
+
+    if (file) {
+      if (file.type == "application/x-yaml") {
+        var reader = new FileReader();
+
+        reader.onload = function(e) {
+          document.getElementById("config").value = e.target.result;
+          transform();
+        };
+
+        reader.readAsText(file);
+      } else {
+        alert("Only YAML files are supported - you uploaded a: " + file.type);
+      }
+    }
+  }
+
+  function handleDragOver(evt) {
+    evt.stopPropagation();
+    evt.preventDefault();
+    evt.dataTransfer.dropEffect = 'copy'; // Explicitly show this is a copy.
+  }
+
+  // Setup the dnd listeners.
+  var dropZone = document.body;
+  dropZone.addEventListener('dragover', handleDragOver, false);
+  dropZone.addEventListener('drop', handleFileSelect, false);
+</script>
diff --git a/config/initializers/migration.rb b/config/initializers/migration.rb
new file mode 100644
index 0000000000..0756a728e5
--- /dev/null
+++ b/config/initializers/migration.rb
@@ -0,0 +1,2 @@
+require Rails.root.join('vendor/migration/app/lib/bk/compat').to_s
+require Rails.root.join('vendor/migration/app/lib/bk/compat/server').to_s
diff --git a/config/routes.rb b/config/routes.rb
index 95b36f42df..020374fbcd 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -143,6 +143,10 @@
   # Homepage
   get "/docs" => "pages#index", as: :home_page
 
+  # Hosted migration/transform tool
+  get "/docs/migrate" => "migration#show"
+  post "/docs/migrate" => "migration#migrate", as: :migrate
+
   # All other standard docs pages
   get "/docs/*path" => "pages#show", as: :docs_page
 
diff --git a/vendor/migration b/vendor/migration
new file mode 160000
index 0000000000..c9c42116eb
--- /dev/null
+++ b/vendor/migration
@@ -0,0 +1 @@
+Subproject commit c9c42116eb9253b2a6d8d4e3674805c07b095205