Skip to main content
← All guides

Share an app from a container or CI

Checked against the qURL™ CLI 3.4.1 source and documentation on October 9, 2026. Link and session behaviour is tested separately.

Content updated October 18, 2026. The verification date above records the original review; later behaviour checks are dated in the guide.

How do I share an app running in a container or CI job?

Run qurl publish with --foreground as a background job inside the container or CI step, wait for the CRID, then run qurl share. The share lasts as long as that process runs, and the link works once and expires within 24 hours.

What you need

  • An app listening on 127.0.0.1 inside the container or job
  • Linux on amd64 or arm64, with a shell, curl, tar, sha256sum (or shasum), and the system CA certificates
  • No account, API key, or sign-in

Steps

  1. Install the qURL CLI in the job

    This is the installer the qURL agent skill uses: it downloads the script to a temporary file rather than piping it into a shell, verifies the CLI against the release checksums, and never runs sudo. qurl refuses to keep its state under a directory other users can write to, so the first line creates and tightens those folders. Setting VERSION gives every run the same CLI and skips the script’s GitHub API lookup.

    (umask 022 && mkdir -p "$HOME/.local/bin" "$HOME/.local/state") &&
      chmod go-w "$HOME/.local" "$HOME/.local/bin" "$HOME/.local/state" &&
      installer=$(mktemp) &&
      curl -fsSL --retry 3 --proto '=https' -o "$installer" \
        https://raw.githubusercontent.com/layervai/qurl-integrations/main/scripts/install.sh &&
      INSTALL_DIR="$HOME/.local/bin" VERSION=3.4.1 sh "$installer" &&
      rm -f "$installer"
    export PATH="$HOME/.local/bin:$PATH"
    qurl --version
  2. Publish in the foreground as a background job

    Containers and some CI runners have no user service manager, so the default background service can’t start. --foreground serves from this process instead. This waits up to 90 seconds for the CRID and prints the publish log if it fails. Run all the steps in one shell or CI step: $run, $pub, and $CRID don’t carry over to a new step.

    run=$(mktemp -d)
    nohup qurl publish http://127.0.0.1:3000 --foreground --quiet \
      > "$run/crid" 2> "$run/publish.log" < /dev/null &
    pub=$!
    for i in $(seq 1 90); do
      [ "$(wc -c < "$run/crid")" -ge 60 ] && break
      kill -0 "$pub" 2>/dev/null || break
      sleep 1
    done
    CRID=$(head -n 1 "$run/crid")
    [ -n "$CRID" ] || { echo "publish failed:"; cat "$run/publish.log"; kill "$pub" 2>/dev/null; false; }
  3. Create a link

    Capture the link in a variable so it never reaches the build log. On GitHub Actions the second line masks it in everything the job prints afterwards; on any other CI that line prints nothing. Then send the link to the reviewer through a private channel.

    link=$(qurl share "$CRID" --quiet)
    [ -z "${GITHUB_ACTIONS:-}" ] || echo "::add-mask::$link"
  4. Keep the job alive while people review, then clean up

    The share stops when the publish process exits. Keep the job running for the review window (GitHub-hosted jobs end after at most 6 hours), then stop the publisher. On a runner that is thrown away after the job, also delete the resource: its identity goes with the runner, so the CRID can’t be used again anyway. On a runner that keeps its home directory, leave the last line out to keep the same CRID for the next run.

    sleep 3600   # the review window; at most 6 hours on GitHub-hosted runners
    kill "$pub"
    wait "$pub" || true
    qurl delete "$CRID" --yes

What the recipient sees

The reviewer opens the link in a browser and sees the build running inside your container. When the job ends, the app stops answering.

Troubleshooting

If you seeDo this
publish says qURL can’t start local sharing while this process is configured to use an HTTP proxyUnset the lowercase http_proxy variable for the qurl publish command and run it again.
qurl publish says the local app is not accepting TCP connectionsStart the app first and check that curl works on the same address. qurl publish connects to localhost as 127.0.0.1, so if your app listens only on IPv6 (on macOS or Linux, lsof -iTCP -sTCP:LISTEN -nP shows [::1]), publish 'http://[::1]:PORT' instead. Keep the quotes: zsh treats the brackets as a pattern.
The recipient sees “Access link invalid”The link was already used or has expired. Run qurl share again and send the new link.
The recipient sees “Resource not found”The resource was deleted after the link was made. Publish the app again, then run qurl share and send the new link.
publish rejects http://127.0.0.1 with “only HTTPS URLs are allowed”, or asks you to log inYou have a CLI older than 3.0. On macOS run brew update, then brew upgrade qurl; elsewhere install the latest release again. After updating, qurl --version should print 3.4.1 or later.
publish prints no Access: line, or the Access: line says public and you did not ask for publicWith no Access: line, the CLI is older than 3.4.1: on macOS run brew update, then brew upgrade qurl; elsewhere install the latest release again. A resource that an older CLI published is public unless you published it with --private, and anyone who has the CRID of a public resource can open it. To make a public resource private, run qurl delete <CRID> and publish again with qurl 3.4.1 or later. The new resource is private and has a new CRID.
publish says this Linux session “can’t reach a systemd user manager”You are in a container or sandbox without a user service manager. Run qurl publish with --foreground; sharing lasts as long as that process runs.
publish says “Quota Exceeded” with “protected resource limit reached”, or “Your qURL account has reached a plan limit”You can see this without an account: a device with no account can keep up to 3 active resources, with links that last up to 24 hours. Run qurl list --status active, then qurl delete <CRID> for any you no longer need (it asks you to confirm; --yes skips that). qurl stop keeps the resource, so it does not free a slot.

Frequently Asked Questions

Do I need to store a key in CI?

No. The first publish on a fresh runner creates a device identity, so nothing is stored in CI. On a runner that is thrown away after the job, that identity goes with it, and each run gets a new CRID. A self-hosted runner that keeps its home directory reuses its identity, and its CRID too when the job does not run qurl delete. Don’t copy the state directory into a cache or artifact: it holds the device credential.

Where should the link go?

Only to the people reviewing the build. Treat it like a password: don’t print it in build logs. Capture it with link=$(qurl share "$CRID" --quiet) and, on GitHub Actions, mask it with ::add-mask:: before anything else runs. Run that line only on GitHub Actions, as step 3 does: on any other CI it prints the link.

Can qurl run in a different container from the app?

Only if it shares the app’s network. qurl serves an app from the machine it runs on only at a loopback address (localhost or 127.0.0.1), so run it in the app’s container, in a sidecar on the same network namespace (network_mode: "service:app" in Docker Compose, or the same Kubernetes pod), or on the host with the app’s port published to 127.0.0.1.

Who else can reach my app while the link is open?

Opening the link admits the network address it was opened from for the session, which lasts 1 hour by default. Other devices that share that public IP address, such as people on the same office or café network or VPN, can reach the app until the session ends. By default a link admits whoever opens it; access rules set through the API, such as IP allow and deny lists, can restrict that. Keep your app’s own sign-in for anything sensitive.

Publish your app now

Install the CLI, then run one command. No sign-up, no API key.

Install the CLI ↗

Contact sales

Contact our team for enterprise deployments.

Enterprise starts with a scoped, paid pilot on one application, typically 60 to 90 days, credited in full toward year one if you convert within 60 days. Then an annual package sized to what you protect. Not per seat.