Shyft SolutionsDev guidePortal registration
GitHub

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.

This guide is the internal (proxied) flow

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.

New app from scratch?

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.)

FieldWhat it doesNotes
Display nameTile label, breadcrumb, and admin pages.Free-form. Rename any time.
URL slugAppears 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.
DescriptionSub-line on the tile.≤280 chars. Optional.
CategoryGroups tiles on the landing page.Free-form. Matching strings are merged.
Icon URLTile icon.Optional. Any public square image.
Base URLWhere 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 pathPolled every 30s; result drives the tile dot.Defaults to /health. See below.
Allowed groupsMembers of any group here can launch the app.Set after "Discover roles" pre-fills the role list.
Role mappingsAuthentik 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.

What 'reachable from the portal' means
EC2 host
your SSH shell
Portal container
rootless podman, custom net
Your app
anywhere

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:8000

No 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.

Why this is the cleanest option for co-located apps

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:8000

host.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:8000
Don't use the EC2 public IP

AWS 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 DNS

Three 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.io

Two 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-added

That 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:

ModelHow the app learns its prefixBase URL you registerTypical frameworks
A · header-drivenReads X-Forwarded-Prefix: /apps/<slug> (the proxy sends it on every request) and routes at root.No prefixhttp://<app>:<port>Flask, FastAPI (any app you control the request handling of)
B · static prefixPrefix is baked in at build/boot time and the app expects it in the path.Include the prefixhttp://<app>:<port>/apps/<slug>Next.js (basePath), Grafana (serve_from_sub_path)
The one rule

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.