diff --git a/lib/towerops_web/controllers/sitemap_controller.ex b/lib/towerops_web/controllers/sitemap_controller.ex
new file mode 100644
index 00000000..95226d1f
--- /dev/null
+++ b/lib/towerops_web/controllers/sitemap_controller.ex
@@ -0,0 +1,28 @@
+defmodule ToweropsWeb.SitemapController do
+ @moduledoc false
+
+ use ToweropsWeb, :controller
+
+ @public_urls ["/", "/privacy", "/terms", "/docs/api", "/docs/graphql", "/help"]
+
+ def index(conn, _params) do
+ base_url = ToweropsWeb.Endpoint.url()
+ xml = build_sitemap(base_url)
+
+ conn
+ |> put_resp_content_type("text/xml")
+ |> send_resp(200, xml)
+ end
+
+ defp build_sitemap(base_url) do
+ urls =
+ Enum.map_join(@public_urls, "\n", fn path -> " #{base_url}#{path}" end)
+
+ String.trim("""
+
+
+ #{urls}
+
+ """)
+ end
+end
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index 65135ea2..63f6213e 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -80,6 +80,11 @@ defmodule ToweropsWeb.Router do
get "/health", HealthController, :index, log: false
end
+ # Public crawlable endpoints (no pipeline, no CSRF/session required)
+ scope "/", ToweropsWeb do
+ get "/sitemap.xml", SitemapController, :index
+ end
+
scope "/", ToweropsWeb do
pipe_through :browser
diff --git a/priv/static/robots.txt b/priv/static/robots.txt
index 26e06b5f..45bd446c 100644
--- a/priv/static/robots.txt
+++ b/priv/static/robots.txt
@@ -1,5 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
-#
-# To ban all spiders from the entire site uncomment the next two lines:
-# User-agent: *
-# Disallow: /
+User-agent: *
+Allow: /
+
+Sitemap: https://towerops.net/sitemap.xml
diff --git a/test/towerops_web/controllers/sitemap_controller_test.exs b/test/towerops_web/controllers/sitemap_controller_test.exs
new file mode 100644
index 00000000..1472cd05
--- /dev/null
+++ b/test/towerops_web/controllers/sitemap_controller_test.exs
@@ -0,0 +1,18 @@
+defmodule ToweropsWeb.SitemapControllerTest do
+ use ToweropsWeb.ConnCase, async: true
+
+ describe "GET /sitemap.xml" do
+ test "returns valid XML with public pages", %{conn: conn} do
+ conn = get(conn, "/sitemap.xml")
+
+ assert response(conn, 200)
+ assert get_resp_header(conn, "content-type") == ["text/xml; charset=utf-8"]
+ body = response(conn, 200)
+ assert body =~ ~r/<\?xml/
+ assert body =~ ""
+ assert body =~ "/privacy"
+ assert body =~ "/terms"
+ end
+ end
+end