diff --git a/builder/build.sh b/builder/build.sh
index 06570f5..b3696e1 100755
--- a/builder/build.sh
+++ b/builder/build.sh
@@ -149,11 +149,20 @@ if curl --silent --fail "${CADDY_API}/config/" >/dev/null 2>&1; then
else
ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes"
- # Route JSON uses Caddy's forward_auth pattern:
- # 1. HIY server checks the session cookie and app-level permission at /auth/verify
- # 2. On 2xx → Caddy proxies to the app container
- # 3. On anything else (e.g. 302 redirect to /login) → Caddy passes through to the client
- ROUTE_JSON=$(python3 -c "
+ # Route JSON: public apps use plain reverse_proxy; private apps use forward_auth.
+ if [ "${IS_PUBLIC:-0}" = "1" ]; then
+ ROUTE_JSON=$(python3 -c "
+import json, sys
+upstream = sys.argv[1]
+app_host = sys.argv[2]
+route = {
+ 'match': [{'host': [app_host]}],
+ 'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': upstream}]}]
+}
+print(json.dumps(route))
+" "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}")
+ else
+ ROUTE_JSON=$(python3 -c "
import json, sys
upstream = sys.argv[1]
app_host = sys.argv[2]
@@ -187,6 +196,7 @@ route = {
}
print(json.dumps(route))
" "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}")
+ fi
# Upsert the route for this app.
ROUTES=$(curl --silent --fail "${ROUTES_URL}" 2>/dev/null || echo "[]")
# Remove existing route for the same host, rebuild list, keep dashboard as catch-all.
diff --git a/server/Cargo.toml b/server/Cargo.toml
index cc8d19f..4e76770 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -28,3 +28,4 @@ aes-gcm = "0.10"
anyhow = "1"
futures = "0.3"
base64 = "0.22"
+reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
diff --git a/server/src/builder.rs b/server/src/builder.rs
index 32a95a6..b194054 100644
--- a/server/src/builder.rs
+++ b/server/src/builder.rs
@@ -156,6 +156,7 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
.env("BUILD_DIR", &build_dir)
.env("MEMORY_LIMIT", &app.memory_limit)
.env("CPU_LIMIT", &app.cpu_limit)
+ .env("IS_PUBLIC", if app.is_public != 0 { "1" } else { "0" })
.env("DOMAIN_SUFFIX", &domain_suffix)
.env("CADDY_API_URL", &caddy_api_url)
.stdout(std::process::Stdio::piped())
diff --git a/server/src/db.rs b/server/src/db.rs
index 0366a6a..fb34d02 100644
--- a/server/src/db.rs
+++ b/server/src/db.rs
@@ -108,6 +108,8 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
.execute(pool).await;
let _ = sqlx::query("ALTER TABLE apps ADD COLUMN git_token TEXT")
.execute(pool).await;
+ let _ = sqlx::query("ALTER TABLE apps ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0")
+ .execute(pool).await;
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS databases (
diff --git a/server/src/models.rs b/server/src/models.rs
index c6de907..994f5a4 100644
--- a/server/src/models.rs
+++ b/server/src/models.rs
@@ -15,6 +15,7 @@ pub struct App {
/// Encrypted git token for cloning private repos. Never serialised to API responses.
#[serde(skip_serializing)]
pub git_token: Option,
+ pub is_public: i64,
}
#[derive(Debug, Deserialize)]
@@ -36,6 +37,7 @@ pub struct UpdateApp {
pub memory_limit: Option,
pub cpu_limit: Option,
pub git_token: Option,
+ pub is_public: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
diff --git a/server/src/routes/apps.rs b/server/src/routes/apps.rs
index f354498..cce0e68 100644
--- a/server/src/routes/apps.rs
+++ b/server/src/routes/apps.rs
@@ -12,6 +12,91 @@ use crate::{
AppState,
};
+/// Build the Caddy route JSON for an app.
+/// Public apps get a plain reverse_proxy; private apps get forward_auth via HIY.
+fn caddy_route(app_host: &str, upstream: &str, is_public: bool) -> serde_json::Value {
+ if is_public {
+ serde_json::json!({
+ "match": [{"host": [app_host]}],
+ "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": upstream}]}]
+ })
+ } else {
+ serde_json::json!({
+ "match": [{"host": [app_host]}],
+ "handle": [{
+ "handler": "subroute",
+ "routes": [{
+ "handle": [{
+ "handler": "reverse_proxy",
+ "rewrite": {"method": "GET", "uri": "/auth/verify"},
+ "headers": {"request": {"set": {
+ "X-Forwarded-Method": ["{http.request.method}"],
+ "X-Forwarded-Uri": ["{http.request.uri}"],
+ "X-Forwarded-Host": ["{http.request.host}"],
+ "X-Forwarded-Proto": ["{http.request.scheme}"]
+ }}},
+ "upstreams": [{"dial": "server:3000"}],
+ "handle_response": [{
+ "match": {"status_code": [2]},
+ "routes": [{"handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": upstream}]}]}]
+ }]
+ }]
+ }]
+ }]
+ })
+ }
+}
+
+/// Push a visibility change to Caddy without requiring a full redeploy.
+/// Best-effort: logs a warning on failure but does not surface an error to the caller.
+async fn push_visibility_to_caddy(app_id: &str, port: i64, is_public: bool) {
+ if let Err(e) = try_push_visibility_to_caddy(app_id, port, is_public).await {
+ tracing::warn!("caddy visibility update for {}: {}", app_id, e);
+ }
+}
+
+async fn try_push_visibility_to_caddy(app_id: &str, port: i64, is_public: bool) -> anyhow::Result<()> {
+ let caddy_api = std::env::var("CADDY_API_URL").unwrap_or_else(|_| "http://caddy:2019".into());
+ let domain = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
+ let app_host = format!("{}.{}", app_id, domain);
+ let upstream = format!("hiy-{}:{}", app_id, port);
+ let client = reqwest::Client::new();
+
+ // Discover the Caddy server name (Caddyfile adapter names it "srv0").
+ let servers: serde_json::Value = client
+ .get(format!("{}/config/apps/http/servers/", caddy_api))
+ .send().await?
+ .json().await?;
+ let server_name = servers.as_object()
+ .and_then(|m| m.keys().next().cloned())
+ .ok_or_else(|| anyhow::anyhow!("no servers in Caddy config"))?;
+
+ let routes_url = format!("{}/config/apps/http/servers/{}/routes", caddy_api, server_name);
+
+ let routes: Vec = client.get(&routes_url).send().await?.json().await?;
+
+ let dashboard = serde_json::json!({
+ "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "server:3000"}]}]
+ });
+
+ let mut updated: Vec = routes.into_iter()
+ .filter(|r| {
+ let is_this_app = r.pointer("/match/0/host")
+ .and_then(|h| h.as_array())
+ .map(|hosts| hosts.iter().any(|h| h.as_str() == Some(app_host.as_str())))
+ .unwrap_or(false);
+ let is_catchall = r.get("match").is_none();
+ !is_this_app && !is_catchall
+ })
+ .collect();
+
+ updated.insert(0, caddy_route(&app_host, &upstream, is_public));
+ updated.push(dashboard);
+
+ client.patch(&routes_url).json(&updated).send().await?;
+ Ok(())
+}
+
pub async fn list(State(s): State) -> Result>, StatusCode> {
let apps = sqlx::query_as::<_, App>("SELECT * FROM apps ORDER BY created_at DESC")
.fetch_all(&s.db)
@@ -112,6 +197,17 @@ pub async fn update(
.execute(&s.db).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
+ if let Some(v) = payload.is_public {
+ let flag: i64 = if v { 1 } else { 0 };
+ sqlx::query("UPDATE apps SET is_public = ?, updated_at = ? WHERE id = ?")
+ .bind(flag).bind(&now).bind(&id)
+ .execute(&s.db).await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ // Immediately reconfigure the Caddy route so the change takes effect
+ // without a full redeploy.
+ let app = fetch_app(&s, &id).await?;
+ push_visibility_to_caddy(&id, app.port, v).await;
+ }
if let Some(v) = payload.git_token {
if v.is_empty() {
sqlx::query("UPDATE apps SET git_token = NULL, updated_at = ? WHERE id = ?")
diff --git a/server/src/routes/ui.rs b/server/src/routes/ui.rs
index cf0f844..10e25a1 100644
--- a/server/src/routes/ui.rs
+++ b/server/src/routes/ui.rs
@@ -297,6 +297,14 @@ pub async fn app_detail(
}
};
+ let is_public = app.is_public != 0;
+ let visibility_badge = if is_public {
+ r#"public"#
+ } else {
+ r#"private"#
+ };
+ let visibility_toggle_label = if is_public { "Make private" } else { "Make public" };
+
let (git_token_status, git_token_clear_btn) = if app.git_token.is_some() {
(
r#"Token configured"#.to_string(),
@@ -321,8 +329,11 @@ pub async fn app_detail(
.replace("{{env_rows}}", &env_rows)
.replace("{{c_badge}}", &container_badge(&container_state))
.replace("{{db_card}}", &db_card)
- .replace("{{git_token_status}}", &git_token_status)
- .replace("{{git_token_clear_btn}}", &git_token_clear_btn);
+ .replace("{{git_token_status}}", &git_token_status)
+ .replace("{{git_token_clear_btn}}", &git_token_clear_btn)
+ .replace("{{visibility_badge}}", visibility_badge)
+ .replace("{{visibility_toggle_label}}", visibility_toggle_label)
+ .replace("{{is_public_js}}", if is_public { "true" } else { "false" });
Html(page(&app.name, &body)).into_response()
}
diff --git a/server/templates/app_detail.html b/server/templates/app_detail.html
index af06614..c4e2380 100644
--- a/server/templates/app_detail.html
+++ b/server/templates/app_detail.html
@@ -14,6 +14,8 @@
· branch {{branch}}
· port {{port}}
· {{name}}.{{host}}
+ · {{visibility_badge}}
+
@@ -165,6 +167,16 @@ async function deprovisionDb() {
if (r.ok) window.location.reload();
else alert('Error: ' + await r.text());
}
+const IS_PUBLIC = {{is_public_js}};
+async function toggleVisibility() {
+ const r = await fetch('/api/apps/' + APP_ID, {
+ method: 'PATCH',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({is_public: !IS_PUBLIC}),
+ });
+ if (r.ok) window.location.reload();
+ else alert('Error updating visibility: ' + await r.text());
+}
async function saveGitToken() {
const tok = document.getElementById('git-token-input').value;
if (!tok) { alert('Enter a token first'); return; }