Once your app verifies the portal JWT (see Adding portal auth to your app), the last step is telling the portal where to find it. This guide walks through the registration form field-by-field and — the bit that catches people out — what URL to put in Base URL depending on where your app actually runs.
The portal is a reverse proxy: every request to /apps/<slug>/… turns into a
server-side fetch(baseUrl + path) from inside the portal process. So "what's a valid
Base URL?" is really "what URL does the portal's runtime see your app at?" — and that depends on
your topology, not on what works from your shell.
The registration form's first field is Type. This guide covers Internal app (Shyft auth) — apps the portal proxies and mints a JWT for. If you're registering an off-the-shelf app that signs in against Authentik directly, or just a bookmark to third-party SaaS, see External apps & links instead.
Registration is the last step. For the repo structure and stack that come before it, see Creating a new app.
The registration form
/admin/apps/new collects everything in one screen. None of the fields except slug + Base URL
need to be permanent — you can edit them later from /admin/apps/<id>. (Leave Type on its
default, Internal app, for everything in this guide.)
| Field | What it does | Notes |
|---|---|---|
| Display name | Tile label, breadcrumb, and admin pages. | Free-form. Rename any time. |
| URL slug | Appears in /apps/<slug>/ and as aud in the JWT. | Lowercase, hyphens. Effectively permanent — changing it invalidates every cached link and requires redeploying the app with a new expected_aud. |
| Description | Sub-line on the tile. | ≤280 chars. Optional. |
| Category | Groups tiles on the landing page. | Free-form. Matching strings are merged. |
| Icon URL | Tile icon. | Optional. Any public square image. |
| Base URL | Where the portal proxies requests to. See below. | Doesn't have to be reachable when you save — registration succeeds and the tile shows not-deployed until the first 2xx. |
| Health-check path | Polled every 30s; result drives the tile dot. | Defaults to /health. See below. |
| Allowed groups | Members of any group here can launch the app. | Set after "Discover roles" pre-fills the role list. |
| Role mappings | Authentik group → app role (priority-ordered). | See Roles & discovery in the integration guide. |
Choosing a Base URL
The portal's fetch(baseUrl + …) runs inside the portal's process. That process is a rootless
podman container on a private podman network — so its localhost is not the EC2 host, and an
URL that works from your SSH shell may or may not work from inside the container.
Pick the scenario that matches where your app actually lives.
Co-located on the same EC2, on the portal podman network
If your app runs as a podman container with Network=portal.network (the same custom net the
portal and its DB share — see deploy/quadlets/portal.network), the portal can reach it by
container name.
http://<container-name>:<port>
# example:
http://training-api:8000No host port needs to be published. The DB does it this way — portal-db is reachable as
portal-db:5432 from inside the portal, with no port exposed to the host. Both your app's
container name and the port your app listens on are stable as long as the quadlet is.
No firewall, no AWS routing, no host networking quirks. The container name is a stable DNS record on the podman bridge. If you move the app to another EC2 later, you change the Base URL and the rest of the registration stays the same.
Co-located on the same EC2, but not on the portal network
Either the app is a host process (systemd unit, raw binary, npm start in a tmux), or it's a
container on a different podman/docker network. Two sub-cases here, and which one applies
depends on how the app binds.
App binds to 127.0.0.1:<port> on the host — only the host loopback accepts connections.
Use podman's special host alias:
http://host.containers.internal:<port>
# example:
http://host.containers.internal:8000host.containers.internal is a dedicated forwarding shortcut inserted by rootless podman's
network shim (slirp4netns/pasta). It NATs from inside the container's netns to the host's
127.0.0.1. It's the only path that reaches a loopback-bound host service — the host's own
public/private IPs won't, because they're not on lo from the container's perspective.
App binds to 0.0.0.0:<port> (or the host's private IP) — any interface accepts connections.
Either of these works:
http://host.containers.internal:<port>
http://<ec2-private-ip>:<port>
# example:
http://10.0.1.42:8000AWS does 1:1 NAT for public IPs at the VPC edge — the instance's interface only has the private
IP. A connection from the instance to its own public IP has to leave the box, hit the IGW, and
hairpin back — security groups and asymmetric routing drop it. This is an AWS thing, not a
portal thing; curl <public-ip>:<port> from the EC2 shell fails too.
Different EC2, same VPC
Use the other instance's private IP or its internal DNS name. The portal's outbound traffic
goes through the host network — the same routing the host shell uses — so any address that's
reachable from curl on the EC2 host is also reachable from the portal container.
http://<other-private-ip>:<port>
http://ip-10-0-1-42.us-east-2.compute.internal:<port>
http://training-api.internal.shyftsolutions.io:<port> # if you run private DNSThree things to confirm before you save:
- Security group on the target instance allows inbound from the portal's instance (or its
SG) on
<port>. - The app binds to an interface other than
127.0.0.1— same rule as in the co-located case. - No NACL / subnet routing is in the way (rare, but worth knowing exists).
Different EC2 / off-instance behind a public hostname
If the app already has a public hostname (its own domain, a load balancer, an ALB target), just use it.
https://training-api.shyftsolutions.ioTwo things to be aware of when the portal speaks to a public TLS endpoint:
- The portal validates the cert chain. Self-signed / mismatched-CN certs fail — there's no "skip TLS verification" knob.
- Outbound from the portal's instance must be allowed. In default VPC setups it is; locked-down egress rules need an exception for the upstream hostname.
The portal forwards the user's identity in headers (X-Forwarded-User,
Authorization: Bearer <jwt>) regardless of where the app sits — there's no different "remote
mode."
Subpaths, the /apps/<slug> prefix, and trailing slashes
The proxy consumes /apps/<slug> from the inbound path and forwards only the subpath after
it, appended to whatever path your Base URL already has:
# Base URL: http://training-api:8000
# Request: GET /apps/training/dashboard
# Upstream: GET http://training-api:8000/dashboard ← slug stripped
# Base URL: http://training-api:8000/apps/training
# Request: GET /apps/training/dashboard
# Upstream: GET http://training-api:8000/apps/training/dashboard ← prefix re-addedThat stripping is why an app that emits root-relative URLs (asset links, Location:
redirects, an OIDC redirect_uri) breaks behind the portal: the URL points at /foo, the browser
asks the portal for /foo, and it 404s. The app has to produce URLs under /apps/<slug>/…. There
are two supported ways to make that happen — your choice of Base URL follows from which one your
framework can do. The full per-framework recipes live in
Adding portal auth → Run behind the prefix; here's what each
means for Base URL:
| Model | How the app learns its prefix | Base URL you register | Typical frameworks |
|---|---|---|---|
| A · header-driven | Reads X-Forwarded-Prefix: /apps/<slug> (the proxy sends it on every
request) and routes at root. | No prefix — http://<app>:<port> | Flask, FastAPI (any app you control the request handling of) |
| B · static prefix | Prefix is baked in at build/boot time and the app expects it in the path. | Include the prefix — http://<app>:<port>/apps/<slug> | Next.js (basePath), Grafana (serve_from_sub_path) |
The path your framework sees must equal the public path the browser sees (/apps/<slug>/…).
Either let the portal tell the app its prefix (Model A — header, no prefix in Base URL) or
bake the prefix into the app and the Base URL (Model B) — never one without the other. The
classic failure is Model B with the prefix in only one place.
Where this bites the registration form: the health probe and role discovery both fetch
baseUrl + <path> (see Health and Discovering roles). Under Model B
that means /health and /.well-known/app-roles resolve to
…/apps/<slug>/health and …/apps/<slug>/.well-known/app-roles — so those endpoints must be served
under the prefix too (they will be, if the prefix is framework-wide). This is the most common
reason "Discover roles" only starts working after the prefix is added to Base URL.
Trailing slashes: the proxy forwards the slash the browser sent, verbatim — so an app whose root
redirects /x → /x/ (Flask/Werkzeug) resolves cleanly instead of looping. Don't put a trailing
slash on the Base URL itself, though; it's normalised off and only the request path's slash is
honoured.
Health-check path
The portal polls baseUrl + healthPath every ~30 seconds. A 2xx within 60s means
live; persistent timeouts or 5xx mean unreachable. The endpoint is hit
unauthenticated — don't gate it behind the JWT middleware.
For the response contract and the state machine the poller uses, see Health & liveliness in the integration guide.
If your app has no /health and you can't add one, point Health-check path at any cheap 200-OK
GET — / works for most things. The poller doesn't read the body, it just looks at the status.
Discovering roles
After Base URL is set, the Discover roles button hits baseUrl + /.well-known/app-roles and
pre-fills the role list from the response. If discovery fails (Base URL wrong, app not deployed,
endpoint missing), the form falls back to a textarea where you can type role names by hand and
hit Re-discover later.
Wiring up /.well-known/app-roles is covered in
Roles & discovery.
Verifying reachability
After you save the registration, the tile dot is the first signal. Grey "not-deployed" usually means the URL is wrong; red "unreachable" usually means the app is down or the URL works but returns 5xx. Quick diagnostic recipes, in the order you'd try them:
# 1. The app itself is up.
curl -v <baseUrl>/health
# 2. The app is reachable from inside the portal container.
# This is the path the proxy actually uses.
podman exec portal wget -q -O - --timeout=3 <baseUrl>/health
podman exec portal wget -q -O - --timeout=3 <baseUrl>/.well-known/app-roles
# 3. The proxy's last error, if it tried and failed.
journalctl --user -u portal -n 50 | grep -i "Upstream app unreachable"If (1) works but (2) doesn't, the Base URL is in the wrong namespace — re-read the
Base URL scenarios above. If (2) works but the tile is still red, check that
healthPath returns 2xx (a 401/403 from an auth-gated /health looks the same as down).
FAQ
My app is stuck in an endless redirect loop / "too many redirects".
Almost always a prefix mismatch (Model B with the prefix in only one place). The app thinks it
lives at /apps/<slug> and keeps redirecting toward it, but the Base URL doesn't carry the prefix
(or vice-versa), so each redirect bounces back through the proxy. Pick one model from
Subpaths and the prefix and apply it on both sides. For Flask/FastAPI the
clean fix is Model A: drop the prefix out of the app's routing entirely, let it read
X-Forwarded-Prefix, and register the Base URL without the prefix. The proxy itself no longer
strips the trailing slash, so a Werkzeug /x → /x/ root redirect resolves on the first hop.
My assets / links 404 but the page HTML loads.
The app is emitting root-relative URLs (/static/…, /_next/…) that escape the /apps/<slug>/
namespace. Same fix as above — the app needs to know its prefix. See
Run behind the prefix for the per-framework config.
My Base URL works from the EC2 shell but the proxy returns 502.
The portal's fetch runs inside its container, not on the host. Most common cause: you used
http://localhost:<port> or http://127.0.0.1:<port>, which resolves to the portal container
itself. See the scenarios — pick the one that matches your topology.
Why doesn't http://<ec2-public-ip>:<port> work, even though I can curl it from somewhere else?
You can't reach an EC2 instance's own public IP from within that same instance — AWS does the
public-IP NAT at the VPC edge, so the connection has to leave the box and hairpin back, and
security groups drop it. Use the private IP, the container name, or host.containers.internal
depending on where the app sits.
Can I use HTTPS for the Base URL?
Yes, but the certificate has to validate against the system trust store inside the portal container. There's no "ignore TLS errors" toggle. For internal apps on a private network, plain HTTP is the usual choice — TLS terminates at the portal's public ingress, not on the internal hop.
Does the Base URL need to be reachable when I save the registration?
No. Registration succeeds either way; the tile just shows not-deployed until the
poller sees the first 2xx. This is on purpose — it lets you reserve a slug and wire up the
role mapping before the app exists.
My app is behind a corporate VPN / not reachable from AWS at all.
The portal can't proxy what it can't reach. Either move the app somewhere the portal's instance can talk to (same VPC, peered VPC, public endpoint with TLS), or expose a tunnel from the app's network into the portal's. There's no "portal-side agent that dials out" — the portal is always the client.