A plugin is a static build. Host dist/ anywhere the panel's users can reach it,
get two headers right, and register it.
npm run buildYou get:
dist/
5stack-plugin.json # copied from public/
assets/
remoteEntry.js # the Federation entry, stable name, changing content
*.js # hashed chunks
Two rules, and both cause confusing failures when missed.
The browser imports your remote entry cross-origin from the panel, so it needs
Access-Control-Allow-Origin. Your manifest does not, Detect fetches that
server-side through the 5Stack API.
location /assets/ {
add_header Access-Control-Allow-Origin "*" always;
}* is correct here: this is public JavaScript and the request carries no
credentials. Your backend API is the opposite case, it
carries the session cookie and must reflect a specific origin with
Access-Control-Allow-Credentials.
remoteEntry.js keeps the same filename across every build but its contents
change, and it references hashed chunk names. A cached copy points at chunks that
no longer exist, so users get a plugin that 404s halfway through loading.
# Stable filename, changing content, must never be cached.
location = /assets/remoteEntry.js {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Access-Control-Allow-Origin "*" always;
}
# Hashed chunks are immutable, cache them hard. Vite hashes are base64url
# (mixed case, - and _), not hex.
location ~ ^/assets/.+-[A-Za-z0-9_-]{8}\.js$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header Access-Control-Allow-Origin "*" always;
}Behind a CDN, confirm it is honoring no-store on that one path. This is the
single most common cause of "I deployed but the panel is running my old code".
The panel does append a cache-busting query parameter when it loads a remote entry, which helps, but do not rely on it in place of correct headers.
Any static host works: nginx in a container, an object-storage bucket behind a CDN, or a static-site host.
If you have a backend, serving from a subdomain of the panel
(myplugin.panel.example.com) is required, not just convenient. The 5Stack
session cookie is SameSite=Lax and never reaches an unrelated domain, so
identity is unavailable there. See Backend & Auth.
A minimal container:
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.confIf you are deploying alongside 5Stack itself, ship a kustomize package and an
install block in your manifest. An operator then installs your plugin straight
from your repository, in one command:
./plugin.sh https://github.com/5stackgg/5stack-example-pluginIt reads their site's own config — cluster, domain, whether TLS terminates at the cluster or in front of it, Postgres credentials — instead of asking them to repeat any of it. One-Command Install is the format, and what to lay out where.
If you have a backend, keep it a ClusterIP Service so the ingress is its only
route in, and split paths on a single host: /api to the backend, / to the
static frontend. See also Custom Kubernetes.
In the panel, go to Settings → Application → Plugins.
- Make sure the Plugins master switch is enabled.
- Add, paste your base URL (e.g.
https://myplugin.example.com). - Press Detect. The panel fetches
5stack-plugin.jsonand fills in the name, slug, icon, remote entry, scope, module, required role, and, if you ship one, the player-profile tab label. - Toggle Enabled and save.
The page appears in the sidebar immediately for every connected client, the registry is subscription-backed, so no one needs to reload.
Admins can override any detected field, and can register a plugin entirely by
hand if it ships no manifest. One page can be marked default, which makes it
take over the panel's landing route. Setting a Player Profile Tab label,
whether detected or typed by hand, also mounts the plugin as a tab on
/players/:steamid; clearing it removes the tab. See
Routing.
Redeploy your dist/. Clients pick up the new build on their next load of
/apps/<slug>, assuming your cache headers are right.
Two changes need more care:
- Changing your Federation scope requires users to hard-reload the panel. Scopes are registered once per page load and never re-registered.
- Changing your remote entry URL on an already-loaded scope has the same problem, the old URL stays registered for the life of the page.
Neither is a reason to avoid deploying; just do not expect a silent hot-swap.
| Symptom | Likely cause |
|---|---|
| Sidebar entry missing | Master switch off, plugin disabled, or requiredRole above the viewer |
| Page loads, remote never mounts | CORS missing on remoteEntry.js; check the browser console |
| "Module not found" after entry loads | scope or module in the manifest disagrees with vite.config.ts |
| Stale code after deploy | remoteEntry.js is being cached |
| Chunk 404s | A cached remoteEntry.js referencing chunks from a previous build |
| Reactivity silently broken | Version mismatch on a shared singleton |
| Panel nav or sidebar visually breaks | Unscoped utilities; see Styling |