Jump to heading Default backend & landing page
Scotty can act as the default backend for your load balancer. When a user visits the domain of an app that is currently stopped, the request no longer fails with a gateway error. Instead the load balancer forwards it to Scotty, which shows a friendly landing page with a Start button. After the user logs in (if needed) and the app has started, they are redirected back to the original URL.
This turns stopped review apps and on-demand environments into a self-service experience: the domain always "works", and visitors can spin the app back up themselves without ever opening the Scotty dashboard.
Jump to heading How it works
User visits myapp.example.com (app is stopped, no containers running)
│
▼
Traefik has no route for the domain
│
▼
Catch-all router forwards the request to Scotty (the default backend)
│
▼
Scotty inspects the Host header, finds the app that owns the domain,
and issues a 302 redirect to its own landing page:
│
▼
https://scotty.example.com/landing/myapp?return_url=https://myapp.example.com/...
│
▼
Landing page → [ Start App ] → (OAuth login if needed) → live startup output
│
▼
App is running → redirect back to https://myapp.example.com/...
The user only ever sees three states on the landing page:
- Stopped — "This app is not running. [Start App]"
- Starting — live docker-compose output streamed over the WebSocket
- Ready — a short countdown, then an automatic redirect to the original URL
Login happens invisibly between clicking Start and the startup output: the user
is bounced through the normal OAuth flow and returns straight to the landing page,
which auto-triggers the start (via an autostart=true parameter).
Jump to heading What Scotty does per app status
The catch-all only forwards requests for domains that Traefik has no route for — which normally means the app is stopped. Scotty double-checks the app status and responds accordingly:
| App status | Response |
|---|---|
Stopped |
302 redirect to the landing page (/landing/<app>?return_url=...) |
Running |
503 Service Unavailable with Retry-After: 5 — the app is up but routing has not caught up yet (also logged as a possible load balancer misconfiguration) |
| Any other (e.g. starting/creating) | 503 Service Unavailable — "the application is starting up" |
| Domain belongs to no known app | 404 Not Found — "No application is configured for this domain" |
| Request is for Scotty's own domain | The Scotty frontend is served as normal |
All landing-related responses are sent with Cache-Control: no-store so browsers
and proxies never cache the redirect or the error pages for a stopped app.
Jump to heading Configuration
Two things are required:
api.base_url— so Scotty knows its own public URL (to distinguish its own domain from app domains, and to build the redirect target).- A catch-all router on your load balancer that forwards otherwise-unmatched domains to Scotty.
Jump to heading
1. Set api.base_url
Set the public-facing URL under which Scotty itself is reachable:
api:
base_url: "https://scotty.example.com"
Or via environment variable:
SCOTTY__API__BASE_URL="https://scotty.example.com"
Scotty uses this value to:
- Recognise requests for its own domain and serve the frontend directly instead of trying to redirect them.
- Build the
https://scotty.example.com/landing/<app>?return_url=...redirect target for stopped-app domains.
api.base_urlis also used for post-login OAuth redirects, so the whole login-and-return-to-app round trip stays on one origin. If it is not set, Scotty falls back to the deprecatedapi.oauth.frontend_base_url(with a deprecation warning at startup). If neither is configured, Scotty logs a warning at startup, cannot tell its own domain apart from app domains, and serves every request as the frontend, so per-app domains will not redirect to the landing page. Always setapi.base_urlin production.
Jump to heading 2. Configure Traefik as the default backend
Add a catch-all router that points at Scotty with the lowest possible priority. Running apps get their own auto-generated routers whose priority (based on rule length) is always higher, so a real running app is never shadowed by the catch-all — only stopped apps (which have no router) fall through to Scotty.
Extend the labels of the scotty service in your compose.yml (see the
installation guide for the full file):
scotty:
image: ghcr.io/factorial-io/scotty:main
# ... volumes, environment, networks as in the installation guide ...
environment:
RUST_LOG: info
SCOTTY__APPS__ROOT_FOLDER: $PWD/apps
SCOTTY__APPS__DOMAIN_SUFFIX: <TLD>
SCOTTY__API__BASE_URL: "https://scotty.<TLD>"
labels:
- "traefik.enable=true"
# Scotty's own dashboard — normal, high-priority route
- "traefik.http.routers.scotty.rule=Host(`scotty.<TLD>`)"
- "traefik.http.routers.scotty.entrypoints=websecure"
- "traefik.http.routers.scotty.tls=true"
- "traefik.http.routers.scotty.tls.certresolver=myresolver"
- "traefik.http.routers.scotty.service=scotty"
# Catch-all default backend — any domain without its own (running app)
# route falls through to Scotty at the lowest possible priority.
- "traefik.http.routers.scotty-catchall.rule=HostRegexp(`^.+$`)"
- "traefik.http.routers.scotty-catchall.priority=1"
- "traefik.http.routers.scotty-catchall.entrypoints=websecure"
- "traefik.http.routers.scotty-catchall.tls=true"
- "traefik.http.routers.scotty-catchall.tls.certresolver=myresolver"
- "traefik.http.routers.scotty-catchall.service=scotty"
- "traefik.http.services.scotty.loadbalancer.server.port=21342"
Notes:
- Priority
1is the lowest possible value. Scotty's ownscottyrouter and every running app's auto-generated router have a higher default priority, so the catch-all only ever matches domains that have no other route. - The rule uses Traefik v3's
HostRegexp(^.+$), which matches any host. - If (as in the reference
compose.yml) thewebentrypoint globally redirects towebsecure, plain HTTP requests to a stopped app's domain are upgraded to HTTPS first and then hit the catch-all onwebsecure. The catch-all therefore only needs to be declared onwebsecure. - With on-demand certificates (
certresolver), Traefik will obtain a certificate for the stopped app's domain when it is first visited, so the redirect works over HTTPS.
Jump to heading Using the Traefik file provider
If Scotty does not run as a Traefik-labelled container (for example a
cargo-installed binary on the host), declare the same router and service through
the file provider instead:
# traefik/dynamic/scotty-catchall.yml
http:
routers:
scotty-catchall:
rule: "HostRegexp(`^.+$`)"
priority: 1
entrypoints:
- websecure
tls:
certresolver: myresolver
service: scotty
services:
scotty:
loadBalancer:
servers:
- url: "http://host.docker.internal:21342"
Jump to heading Security
- Open-redirect protection. The
return_urlis validated on the frontend before the final redirect: it must point to a domain that the app being started actually owns. Arbitrary URLs are rejected and the user is offered a link to the dashboard instead. - Deep-path preservation. The full path and query string of the original
request are preserved in
return_urlso the user lands back on the exact page they asked for. As a trade-off, if the original URL contained sensitive query parameters they will appear in the redirectLocationheader and may be logged by intermediate proxies. - Permissions. Starting the app from the landing page uses the normal
authorization rules — a user still needs
managepermission on the app's scope, or the start will be rejected.
Jump to heading Troubleshooting
- App domains are served the Scotty frontend instead of redirecting.
api.base_urlis probably not set (or is still the localhost default). Set it to Scotty's public URL. Look for the warning "api.base_url is not configured" in the logs. - After logging in, the user lands on the app list instead of returning to the
app's start page. In older releases this happened when
api.oauth.frontend_base_urlpointed to a different origin thanapi.base_url— the post-login redirect landed on the "wrong" origin and the stored return-to-app state was invisible there. Post-login redirects now useapi.base_urldirectly; remove the deprecatedapi.oauth.frontend_base_urlsetting. - Stopped app domains return a gateway error instead of the landing page.
The catch-all router is missing or not matching. Check the Traefik dashboard for
a
scotty-catchallrouter and confirm its priority is lower than the app routers. - A running app's domain intermittently hits the catch-all (503).
Routing has not fully propagated yet — the app just started and Traefik has not
picked up its labels. This is expected briefly after startup; the
Retry-Afterheader tells clients to retry. If it persists, verify the app's Traefik labels and the per-app proxy network (see Traefik configuration).