diff --git a/config/caddy/Caddyfile b/config/caddy/Caddyfile
index 7846272..0753781 100644
--- a/config/caddy/Caddyfile
+++ b/config/caddy/Caddyfile
@@ -77,3 +77,34 @@ vegard.info {
header Content-Type text/html
respond `
vegard.infovegard.info — underveis!
` 200
}
+
+# === Custom domains for publiseringssamlinger ===
+# On-demand TLS: Caddy henter sertifikat kun for domener som maskinrommet
+# bekrefter via /internal/verify-domain. Forespørsler rutes til
+# maskinrommets /custom-domain/-ruter med Host-headeren bevart.
+# Ref: docs/concepts/publisering.md § "Custom domain-mekanisme"
+:443 {
+ tls {
+ on_demand {
+ ask http://host.docker.internal:3100/internal/verify-domain
+ }
+ }
+
+ # RSS/Atom feed
+ handle /feed.xml {
+ rewrite * /custom-domain/feed.xml
+ reverse_proxy host.docker.internal:3100
+ }
+
+ # Forside
+ handle / {
+ rewrite * /custom-domain/index
+ reverse_proxy host.docker.internal:3100
+ }
+
+ # Artikler (alt annet)
+ handle {
+ rewrite * /custom-domain{uri}
+ reverse_proxy host.docker.internal:3100
+ }
+}
diff --git a/maskinrommet/src/custom_domain.rs b/maskinrommet/src/custom_domain.rs
new file mode 100644
index 0000000..fb5f1ea
--- /dev/null
+++ b/maskinrommet/src/custom_domain.rs
@@ -0,0 +1,346 @@
+//! Custom domain-håndtering for publiseringssamlinger.
+//!
+//! Tre ansvarsområder:
+//! 1. Verify-domain callback for Caddy on-demand TLS (intern, uautentisert)
+//! 2. DNS-validering ved registrering av custom domain
+//! 3. Ruting: domene → samling → serve artikkel/forside/feed
+//!
+//! Ref: docs/concepts/publisering.md § "Custom domain-mekanisme"
+
+use axum::{
+ extract::{Query, State},
+ http::{HeaderMap, StatusCode},
+ response::Response,
+};
+use serde::Deserialize;
+use sqlx::PgPool;
+use std::net::ToSocketAddrs;
+use uuid::Uuid;
+
+use crate::publishing::{self, PublishingConfig};
+use crate::rss;
+use crate::AppState;
+
+// =============================================================================
+// Verify-domain (Caddy on-demand TLS callback)
+// =============================================================================
+
+#[derive(Deserialize)]
+pub struct VerifyDomainQuery {
+ domain: String,
+}
+
+/// GET /internal/verify-domain?domain=mittmagasin.no
+///
+/// Caddy kaller dette endepunktet før den henter TLS-sertifikat.
+/// Returnerer 200 hvis domenet tilhører en samling med publishing-trait,
+/// 404 ellers. Uautentisert — kun tilgjengelig internt.
+pub async fn verify_domain(
+ State(state): State,
+ Query(query): Query,
+) -> StatusCode {
+ let domain = query.domain.to_lowercase().trim().to_string();
+
+ if domain.is_empty() {
+ return StatusCode::BAD_REQUEST;
+ }
+
+ // Sjekk at domenet er registrert i en publishing-trait
+ match find_collection_by_domain(&state.db, &domain).await {
+ Ok(Some(_)) => {
+ tracing::info!(domain = %domain, "Domene verifisert for on-demand TLS");
+ StatusCode::OK
+ }
+ Ok(None) => {
+ tracing::debug!(domain = %domain, "Ukjent domene avvist");
+ StatusCode::NOT_FOUND
+ }
+ Err(e) => {
+ tracing::error!(domain = %domain, error = %e, "DB-feil ved domeneverifisering");
+ StatusCode::INTERNAL_SERVER_ERROR
+ }
+ }
+}
+
+// =============================================================================
+// DNS-validering
+// =============================================================================
+
+/// Serverens forventede IP (fra miljøvariabel eller default).
+fn expected_server_ip() -> String {
+ std::env::var("SERVER_IP").unwrap_or_else(|_| "157.180.81.26".to_string())
+}
+
+/// Validerer at domenet har DNS-oppføring som peker til serveren.
+///
+/// Sjekker A-record via system DNS resolver. Returnerer Ok(()) hvis
+/// minst én A-record peker til serverens IP, Err med forklaring ellers.
+pub fn validate_dns(domain: &str) -> Result<(), String> {
+ let expected_ip = expected_server_ip();
+
+ // Bruk system DNS resolver
+ let lookup = format!("{domain}:443")
+ .to_socket_addrs()
+ .map_err(|e| format!("DNS-oppslag feilet for {domain}: {e}"))?;
+
+ let resolved_ips: Vec = lookup.map(|addr| addr.ip().to_string()).collect();
+
+ if resolved_ips.is_empty() {
+ return Err(format!(
+ "Ingen DNS-oppføringer funnet for {domain}. \
+ Opprett en A-record som peker til {expected_ip}."
+ ));
+ }
+
+ if resolved_ips.iter().any(|ip| ip == &expected_ip) {
+ Ok(())
+ } else {
+ Err(format!(
+ "DNS for {domain} peker til {:?}, men forventet {expected_ip}. \
+ Opprett en A-record som peker til {expected_ip}.",
+ resolved_ips
+ ))
+ }
+}
+
+// =============================================================================
+// Domene → samling-oppslag
+// =============================================================================
+
+#[allow(dead_code)]
+struct DomainCollection {
+ id: Uuid,
+ title: Option,
+ publishing_config: PublishingConfig,
+ has_rss: bool,
+ slug: String,
+}
+
+/// Finn samling basert på custom_domain i publishing-trait.
+async fn find_collection_by_domain(
+ db: &PgPool,
+ domain: &str,
+) -> Result