A reusable ParallelWorks workflow + Singularity/Apptainer container recipe for serving any interactive web application from an HPC resource — extracted and generalized from a production app-specific workflow.
You get:
| File | What it is |
|---|---|
workflow.yaml |
The PW workflow: pull container → run server → expose as web endpoint or VNC desktop |
app.def |
Multi-stage container recipe: build with source, ship only the production bundle |
app/ |
A tiny self-contained example app (plain Node, zero deps) proving the contract |
setup ─┬─ get_ports ─┬─ pull_software ─┬─ run_app (job_runner) ── expose_session
└─────────────┴─ pull_data ─────┘
- setup — create the workdir; kill anything a previous launch left running (pidfile).
- get_ports —
pw agent open-portauto-selects a free port (or use a fixed one). - pull_software — pull the read-only
app.siffrom a bucket on the head node (worker nodes may lack outbound HTTPS). Cached; re-pulled when the image name input changes or refresh is toggled. - pull_data (optional) — stage + unpack a data package up front, before any scheduler submission, so unpacking never burns scheduler walltime.
- run_app — generates
run_app.shand launches it viamarketplace/job_runner(login node or PBS/Slurm). The script forks the app server — and, for web sessions, thepw endpoints httptunnel — detached withsetsid. - expose_session — waits until the app answers HTTP, then either reports the endpoint URL (web) or creates a VNC desktop and opens Firefox on it (desktop).
app.def is multi-stage: the build stage has the source tree and toolchain and
produces dist/; the final stage copies only dist/. The published .sif contains
the runnable production bundle — no source code, no node_modules, no compilers. App
teams can build "in prod" and distribute only the artifact.
This works because nothing session-specific is baked at build time: the app binds
0.0.0.0:$PORT (env, chosen per launch) and serves at base path / (custom-subdomain
endpoints need no path prefix). One image serves every session.
Because the bundle is prebuilt, the cluster never builds anything: no --sandbox
expansion, no --fakeroot, no --writable-tmpfs overlay chains. Just
singularity run --env PORT=$PORT app.sif. Faster launches, fewer failure modes.
Background processes forked from an SSH step are reaped when the workflow completes.
Processes forked (setsid + nohup) inside the job_runner-launched script survive.
So the server and the web endpoint are forked in run_app.sh, and expose_session
only reports — letting the workflow reach Completed while the app keeps serving.
Every forked PID is appended to app-custom.pids. The next launch's setup job
kills those PIDs (and their process groups) before starting fresh. Launches are
idempotent; nothing accumulates.
- Web:
pw endpoints http --name X --subdomain X PORT→ root URL at a custom subdomain. No agent on the client, no path rewriting. - Desktop:
pw sessions create --type desktop→ detect your own newest Xvnc process for the realDISPLAY+-authcookie (a shared login node runs many users' X servers — grabbing the first one fails with "Authorization required"), wait for X to actually accept connections, then launch Firefox (optional--kioskfor fullscreen).
# 1. Build the container (root or --fakeroot needed for build only)
cd generic-interactive-app
apptainer build app.sif app.def # or singularity build
# 2. Smoke-test locally
apptainer run --env PORT=8080 app.sif &
curl http://localhost:8080/api/health # {"ok":true,...}
# 3. Publish it
pw buckets cp app.sif pw://<user>/<bucket>/app.sif
# 4. Import workflow.yaml as a PW workflow, pick your bucket in the
# Container Bucket selector, and Execute.The workflow can build the image for you on the cluster's head node — publish the recipe instead of the image and toggle Build Container on Resource:
tar -czf app-recipe.tar.gz app.def app/
pw buckets cp app-recipe.tar.gz pw://<user>/<bucket>/app-recipe.tar.gzThe build is cached (rebuilt only when the recipe package name changes or refresh is
toggled), and Push Built Image to Bucket uploads the resulting .sif back to the
bucket so future launches — or other users — pull the prebuilt image instead of
rebuilding. Requires --fakeroot build capability and outbound network on the head
node; prefer CI-/locally-built images for production.
Set Session Method → E2E test and the workflow becomes a self-contained smoke test:
it pulls (or builds) the container, launches the server through the exact same
job_runner path as a real session, asserts the app answers HTTP 200 at /, then
tears down every forked process and exits. No session, no endpoint, nothing left
running — workflow success is the end-to-end pass. Pairs well with
workflow-tester-tool for
automated regression runs.
- Replace
app/with your source tree and change the%postbuild command inapp.def(npm ci && npm run build,pip install . && pyinstaller, etc.). - Keep the runtime contract: final stage serves HTTP on
0.0.0.0:$PORTat/. That's the only thing the workflow assumes about your app. - If your client-side code calls a second backend service directly (separate port),
route it through the frontend server instead — the browser can only reach the
exposed port. (This was a real-world lesson: an SSR server also cannot call the
app's own public endpoint URL; keep in-node traffic on
localhost.) - Optional data? Point Data Bucket at a
tar.gzpackage; leave it empty to skip.
pw endpoints httprequires the platform's minimumpwCLI version on the cluster; older CLIs get evicted withdisplaced-below-minimum-version. Updatepwon the resource if endpoints drop.run_app.shruns under job_runner, which may have a differentPATHthan SSH steps; the script resolvespwwithcommand -v pwand the expose step warns if the endpoint log never appears.- Desktop mode assumes the app and the VNC desktop share a node (true for login-node runs; scheduled runs put the app on a compute node — use web mode there, or a browser on the desktop pointed at the compute node's host:port if routable).