diff --git a/.env.example b/.env.example
index cc435d0..7b222f4 100644
--- a/.env.example
+++ b/.env.example
@@ -36,5 +36,12 @@ RATE_LIMIT_VIEW=120/minute
# rate limits will apply globally rather than per-client.
TRUST_PROXY_HEADERS=false
+# Public base URL — scheme + host, no trailing slash (e.g. https://paste.example.com).
+# Builds the absolute URLs in social-preview meta tags (og:image, og:url) so
+# link-unfurl bots (iMessage, Slack, …) can fetch the preview image. Leave empty
+# to derive it from the request — set it when a TLS-terminating proxy would
+# otherwise make the app advertise http:// URLs. A bad value fails fast at startup.
+# BASE_URL=
+
# Server port (default: 8000)
PORT=8000
diff --git a/Dockerfile b/Dockerfile
index dceb7a5..d1e0c30 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -58,8 +58,11 @@ ENV PORT=8000
EXPOSE ${PORT}
-HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
- CMD ["sh", "-c", "wget -qO- http://localhost:${PORT}/healthz || exit 1"]
+# No HEALTHCHECK instruction on purpose: `podman build` produces OCI-format
+# images, which silently drop it ("HEALTHCHECK is not supported for OCI image
+# format"). The liveness probe lives with the orchestrator instead — the
+# `healthcheck:` block in docker-compose.yml and the Health* keys in the Podman
+# Quadlet (see README) — so it behaves the same regardless of build engine.
# JSON-array CMD (silences Dockerfile JSONArgsRecommended) + `exec` so the
# shell is replaced by uvicorn. Without exec, uvicorn would be a child of
diff --git a/README.md b/README.md
index 0186ed6..21783e1 100644
--- a/README.md
+++ b/README.md
@@ -139,6 +139,12 @@ Environment=SQLITE_PATH=/data/ghostbit.db
Environment=MAX_PASTE_SIZE=524288
Environment=PORT=8000
+HealthCmd=wget -qO- http://127.0.0.1:8000/healthz || exit 1
+HealthInterval=30s
+HealthTimeout=5s
+HealthStartPeriod=15s
+HealthRetries=3
+
[Service]
Restart=always
@@ -170,6 +176,7 @@ For Redis, add a `ghostbit-redis.container` alongside and use `After=ghostbit-re
| `RATE_LIMIT_CREATE` | `30/minute` | Rate limit for paste creation |
| `RATE_LIMIT_VIEW` | `120/minute` | Rate limit for paste viewing |
| `TRUST_PROXY_HEADERS` | `false` | Use rightmost `X-Forwarded-For` for rate limiting (enable only behind a trusted proxy) |
+| `BASE_URL` | — | Public base URL (e.g. `https://paste.example.com`) for the absolute links in social-preview meta tags. Derived from the request when unset. |
| `WEBHOOK_SECRET` | — | HMAC-SHA256 secret for signing webhook payloads |
---
diff --git a/app/config.py b/app/config.py
index e3d6f73..7a0a0d1 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,3 +1,4 @@
+from pydantic import field_validator
from pydantic_settings import BaseSettings
@@ -26,9 +27,26 @@ class Settings(BaseSettings):
# can spoof it and bypass rate limits.
trust_proxy_headers: bool = False
+ # Public-facing base URL (scheme + host [+ :port]), e.g. "https://paste.example.com".
+ # Builds the absolute URLs in social-preview meta tags (og:image, og:url).
+ # Empty → derived from the incoming request, which is correct for direct
+ # exposure and for proxies that forward scheme + Host. Set it explicitly when
+ # a TLS-terminating proxy would otherwise leave the app advertising http://.
+ base_url: str = ""
+
# Ignore extra env vars (e.g. a stale ENCRYPTION_KEY from pre-E2E setups)
# instead of failing at startup.
model_config = {"env_file": ".env", "extra": "ignore"}
+ @field_validator("base_url")
+ @classmethod
+ def _normalize_base_url(cls, v: str) -> str:
+ # Fail fast on a malformed value rather than silently emitting broken
+ # URLs. Trailing slash stripped so callers can join cleanly.
+ v = v.strip().rstrip("/")
+ if v and not v.startswith(("http://", "https://")):
+ raise ValueError("BASE_URL must start with http:// or https://")
+ return v
+
settings = Settings()
diff --git a/app/main.py b/app/main.py
index 68f36d3..06d7963 100644
--- a/app/main.py
+++ b/app/main.py
@@ -244,14 +244,14 @@ async def security_txt():
# Browser icon probes. Without these routes, every browser hits
# GET /favicon.ico / /apple-touch-icon.png / /apple-touch-icon-precomposed.png
# on page load and those paths fall through to the `/{paste_id}` catch-all,
-# which fails the ID regex and returns 422. The redirect to our existing
-# logo.png is small, cacheable (browsers remember 301s aggressively), and
-# stops the access log from filling up with 422 noise.
+# which fails the ID regex and returns 422. The redirect to the square site
+# icon is small, cacheable (browsers remember 301s aggressively), and stops
+# the access log from filling up with 422 noise.
@app.get("/favicon.ico", include_in_schema=False)
@app.get("/apple-touch-icon.png", include_in_schema=False)
@app.get("/apple-touch-icon-precomposed.png", include_in_schema=False)
async def _browser_icon_redirect():
- return RedirectResponse("/static/logo.png", status_code=301)
+ return RedirectResponse("/static/icon.png", status_code=301)
_ROBOTS_TXT = "User-agent: *\nDisallow: /api/\nDisallow: /docs\nDisallow: /redoc\n"
@@ -288,6 +288,22 @@ def _asset_hash() -> str:
templates.env.globals["v"] = _asset_hash()
+
+def _abs_url(request: Request, path: str) -> str:
+ """Absolute URL for a site path, used by social-preview meta tags.
+
+ Prefers settings.base_url when configured — the escape hatch for
+ TLS-terminating proxies, where request.base_url would otherwise carry an
+ internal http:// scheme/host that link-preview bots cannot reach. Falls
+ back to the request's own base URL for direct exposure and scheme-aware
+ proxies.
+ """
+ base = settings.base_url or str(request.base_url).rstrip("/")
+ return f"{base}/{path.lstrip('/')}"
+
+
+templates.env.globals["abs_url"] = _abs_url
+
_ERROR_TITLES = {
404: "Not found",
403: "Forbidden",
diff --git a/docker-compose.yml b/docker-compose.yml
index 81078e3..d4c2f88 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,6 +12,14 @@ services:
- PORT=${PORT:-8000}
volumes:
- ghostbit_data:/data
+ # 127.0.0.1, not localhost: busybox wget resolves localhost to [::1], but
+ # uvicorn binds IPv4 only (--host 0.0.0.0), so an IPv6 probe is refused.
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${PORT:-8000}/healthz || exit 1"]
+ interval: 30s
+ timeout: 5s
+ start_period: 15s
+ retries: 3
restart: unless-stopped
redis:
diff --git a/docs/configuration.md b/docs/configuration.md
index bf92865..c911d8e 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -17,6 +17,7 @@ All configuration is done via environment variables (or a `.env` file at the pro
| `RATE_LIMIT_CREATE` | `30/minute` | Rate limit for paste creation per IP (`POST /api/v1/pastes`) |
| `RATE_LIMIT_VIEW` | `120/minute` | Rate limit for paste reads per IP (`GET /api/v1/pastes/{id}`) |
| `TRUST_PROXY_HEADERS` | `false` | Read client IP from `X-Forwarded-For` for rate-limiting. See the [Reverse proxy](#reverse-proxy) note below before enabling. |
+| `BASE_URL` | — | Public base URL (e.g. `https://paste.example.com`). Builds the absolute URLs in social-preview meta tags (`og:image`, `og:url`). Derived from the request when unset — see [Reverse proxy](#reverse-proxy). |
!!! info "No server-side encryption key"
All encryption is performed client-side (AES-256-GCM in the browser or CLI). The server never sees plaintext — no `ENCRYPTION_KEY` is needed.
@@ -97,6 +98,21 @@ would show only the reverse proxy's internal IP, which is not useful for
incident triage. If you run the server outside of Docker, pass those flags
yourself (`uvicorn app.main:app --proxy-headers --forwarded-allow-ips="*"`).
+### Absolute URLs for link previews
+
+Ghostbit puts absolute URLs in its social-preview `` tags (`og:image`,
+`og:url`) so link-unfurl bots — iMessage, Slack, Discord… — can fetch the
+preview banner. They are derived from the incoming request by default, which
+is correct for direct exposure and for proxies that forward the scheme and
+`Host` header. If a TLS-terminating proxy would otherwise make the app emit
+`http://` URLs, pin the public origin explicitly:
+
+```env
+BASE_URL=https://paste.example.com
+```
+
+A malformed value (missing `http://` / `https://` scheme) fails fast at startup.
+
!!! warning "Multi-hop setups (CDN → LB → app)"
If more than one trusted proxy sits between the client and Ghostbit, the
rightmost entry will be the nearest proxy (not the client), and rate
diff --git a/static/icon.png b/static/icon.png
new file mode 100644
index 0000000..726ba0c
Binary files /dev/null and b/static/icon.png differ
diff --git a/static/og-banner.png b/static/og-banner.png
new file mode 100644
index 0000000..612c6fb
Binary files /dev/null and b/static/og-banner.png differ
diff --git a/templates/base.html b/templates/base.html
index a009780..6d07d1f 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -8,12 +8,19 @@
-
-
-
+
+
+
+
+
+
+
+
+
{% endblock %}
-
+
+