Ephemeral HTTP command relay. Push a shell command from any HTTP client; have
it run on a remote machine that has nothing installed but curl and bash.
Useful whenever you need to bounce a command to a shell without opening an SSH port, installing an agent, or deploying anything:
- LLM coding sessions driving an arbitrary remote host - any tool, self-hosted or cloud (MCP hosts, curl-literate chat models, local runners, etc.)
- CI runners poking at post-deploy state
- Admin shells behind NAT / firewalls
- Headless devices reachable only outbound
Data on the relay is minimised, not absent: each session gets a small directory holding the pending command/result and a timestamped push history, purged as soon as the idle TTL lapses (default 3h) or on explicit delete. Session keys are 128-bit random.
CLIENT RELAY REMOTE SHELL
(LLM / CI / terminal) (single vhost) (curl + bash)
| | |
|-- POST /api/session -------->| |
|<-- key + URLs ---------------| |
| | |
| (user pastes the remote-quickstart on the remote) |
| | |
| |<-- curl /r/KEY?mode=... -----|
| | runner script starts |
| | |
|-- POST /cmd-KEY ------------>| queued on disk |
|<-- 201 ----------------------| |
| (client may disconnect) | |
| |<-- GET /cmd-KEY (poll) ------|
| |-- 200 + command body ------->|
| | >>> prints cmd|
| | (y/N if supervised)
| | bash runs |
| | |
| |<-- POST /result-KEY ---------|
| | queued on disk |
| |--- 201 --------------------->|
| | <<< done |
|-- GET /result-KEY (poll) --->| |
|<-- 200 + output -------------| |
| Component | Role | First-party code |
|---|---|---|
| nginx | Reverse proxy, TLS, rate limiting, static landing page | Config only |
| PHP-FPM | Session API, runner script generator, file-backed queue | Yes |
| certbot | Automatic TLS issuance + renewal (optional, --profile tls) |
No |
No database. No worker. The only persistent state is a flat-file queue under
data/sessions/<KEY>/ (bind-mounted from the host into the container at
/var/data/sessions/<KEY>/), data-minimised and purged on idle TTL or
explicit delete.
cp .env.example .env
docker compose up -dThe shipped .env.example defaults to a self-consistent local HTTP setup -
no editing required for a first run. After the command above, the API and
the one-liners it hands out are both reachable at http://localhost:49180.
For a public deployment, edit .env first: set DOMAIN to your real
hostname, SCHEME=https, NGINX_MODE=tls, HTTP_PORT=80 (required so
Let's Encrypt's HTTP-01 challenge is reachable) and HTTPS_PORT=443, then:
docker compose --profile tls up -d # HTTP + HTTPS with automatic TLSThat's the whole install. Everything else runs in containers.
All knobs live in .env; nothing is hardcoded.
| Var | Default | Meaning |
|---|---|---|
DOMAIN |
localhost |
Public hostname for the API / landing page |
SCHEME |
http |
http or https - baked into returned one-liners |
PUBLIC_PORT |
49180 |
Port baked into generated URLs. Leave empty for the standard port of SCHEME (80/443) |
NGINX_MODE |
http |
http or tls. tls requires --profile tls |
HTTP_PORT / HTTPS_PORT |
49180 / 49443 |
Host ports published by nginx. Public HTTPS deploy: 80 / 443 (80 is required for the ACME HTTP-01 challenge) |
RATE_LIMIT |
10r/m |
Per-IP rate on POST /api/session (nginx limit_req syntax) |
RATE_LIMIT_BURST |
5 |
Burst slots before 503 |
PROXY_TIMEOUT |
3600 |
Max seconds either side of a pipe waits for the other end. Must comfortably exceed LONGPOLL_MS |
MAX_BODY_SIZE |
25m |
Single source of truth for body-size limits: nginx client_max_body_size on the queue endpoints, and the API derives its raw-body cap (post_max_size), memory limit, its gzip zip-bomb decoded-size cap (3x this value), and the runner's output-truncation cap (80% of this value) from it, so none of them can drift apart. 0 = unlimited at the nginx layer (PHP still keeps a finite decoded cap for safety) |
LONGPOLL_MS |
15000 |
Long-poll window (ms) GET /cmd-{key} / GET /result-{key} hold an empty slot open before returning 204, so listeners pick up work sub-second instead of on the next poll tick |
FPM_MAX_CHILDREN |
64 |
PHP-FPM pool size. Each active session pins ~2 workers for up to LONGPOLL_MS at a time, so this bounds how many sessions can be live concurrently before other requests start queueing |
FPM_MAX_REQUESTS |
500 |
Requests a PHP-FPM worker serves before it is recycled, so no slow leak accumulates over a long uptime. 0 = never recycle |
SESSION_TTL |
10800 |
Idle TTL for a session (seconds). Every request on the key resets it; expired sessions are purged with their queue + history. |
AUDIT_LOG |
0 |
1 = log key generation to container stderr. Independently of this, nginx's access log masks the key segment of every request line as <key> |
SOURCE_URL |
(empty) | Target of the landing page's source link and of GET /source. Empty hides the link |
CERTBOT_EMAIL |
(required for tls profile) | Contact address used when requesting certs from the ACME CA |
CERTBOT_STAGING |
0 |
1 = use the ACME staging environment (test-only certs, avoids rate limits) |
The defaults above are a ready-to-run local HTTP setup - cp .env.example .env && docker compose up -d gives a working roundtrip at http://localhost:49180
with no edits. A public HTTPS deploy needs DOMAIN set to your real hostname,
SCHEME=https, NGINX_MODE=tls, HTTP_PORT=80 (for the ACME HTTP-01
challenge) and HTTPS_PORT=443.
Behind your own reverse proxy? Skip the tls profile. Point your proxy at
HTTP_PORT on localhost and terminate TLS there. SCHEME and PUBLIC_PORT
describe what clients see, so behind a proxy on the standard port they are
https and 443 (or empty), not the internal HTTP_PORT.
The CI deploy renders .env with scripts/render-env.sh: .env.example
defaults overridden by same-named CI variables. For a non-localhost DOMAIN
it refuses SCHEME=http unless ALLOW_PLAIN_HTTP=1, and when SCHEME is
given without PUBLIC_PORT it uses the standard port of SCHEME, so a
public deploy cannot silently hand out http://DOMAIN:49180/ one-liners.
Generate a new session. Returns:
key- 32-char hex, 128-bit entropyttl_seconds- idle TTL (default 3h sliding; any request on the key resets it)urls.cmd/urls.result- queue push/pop endpoints for this sessionurls.runner- base URL of the runner script; append?mode=autofor unattended modeurls.api-/api/session/<key>for re-fetching the payloadremote_quickstart- ready-to-paste one-liner, supervised mode (y/N per command)remote_quickstart_auto- the same line with?mode=auto, for unattended hosts; show operators both and let them pickexec- one-liner template with aCOMMANDplaceholder for pushing from any HTTP clientmcp_messages- agent-facing message templates for the MCP client ({version, templates}, fromphp/app/mcp-messages.json). Serving the wording from the relay means prompt iterations deploy with the relay instead of requiring an npm re-publish ofremotify-mcp; clients keep baked-in fallbacks, so the field is optional. Tool names/schemas/descriptions stay in the npm package deliberately (host approval surface). Omitted if the file is missing or invalid.
Re-fetch the same payload for a known key. Touches the session (resets its idle timer) without consuming anything queued.
Cheap probe: returns {cmd_queued, result_queued, cmd_in_flight, cmd_in_flight_seconds_ago, listener_seen_seconds_ago} without consuming
anything:
cmd_queued/result_queued- whether a command/result is currently sitting in the hot slot.cmd_in_flight- a command was picked up by the listener and no result has been pushed back yet.cmd_in_flight_seconds_ago- seconds since that pickup (nullwhen nothing is in flight).listener_seen_seconds_ago- seconds since the listener last polledGET /cmd-{key}(nullif it never has).
Used by the MCP server to detect whether the listener has already picked up the queued command.
Recovery endpoint: drops any queued command, any queued result, and clears the
in-flight marker, while keeping the session (key, TTL, connected listener)
alive. Returns what it actually cleared:
{"reset": true, "cleared": {"cmd_queued": ..., "result_queued": ..., "cmd_was_in_flight": ...}}.
Rarely needed by hand - the relay self-heals a wedged in-flight marker as soon
as the listener reconnects, and the MCP server resets stale state on its own -
but useful for scripts and as an operator escape hatch.
Purges the session directory (queued cmd/result + any archived pushes) and returns 204.
POST pushes a command (plain bytes or Content-Encoding: gzip). GET consumes
the hot command slot - used by the listener. Last-write-wins: pushing while a
command is already queued replaces it; the old bytes survive as a timestamped
archive.
GET long-polls: if the slot is empty the PHP worker holds the connection
open (up to LONGPOLL_MS, default 15s) and returns the instant a command is
pushed - sub-second pickup in practice - or 204 once the deadline passes.
Pass ?nowait=1 to disable the hold and get an immediate 204/body, useful
for synchronous probes.
A GET also self-heals a wedged session: polling for new work while a
command is still marked in-flight proves that command's executor is gone (an
executing listener never polls), so the relay clears the marker and queues a
synthetic [remotify: ...] result for whoever is still waiting on it. A
listener that died mid-command (kill -9, closed terminal, reboot) therefore
stops blocking the session the moment it - or a replacement - reconnects.
Drops the hot command slot without consuming (no archive, no listener side effects). Used by the MCP server to cancel its own queued command when it gives up waiting for a listener, so the command won't execute as an orphan when one eventually connects.
Symmetric endpoints for the listener to post results and the client to fetch
them. GET /result-{key} long-polls exactly like GET /cmd-{key} (same
LONGPOLL_MS window, same ?nowait=1 escape hatch).
Returns a ready-to-bash runner script. The remote operator runs:
curl -fsSL 'https://relay.remotify.run/r/KEY' | bash # supervised
curl -fsSL 'https://relay.remotify.run/r/KEY?mode=auto' | bash # autosupervised previews every incoming command and waits for y/N (accepts y,
Y, yes, etc. - any response starting with y/Y). auto trusts every command
(only on hosts where the session key is fully private).
Both modes print each incoming command as >>> <cmd> before running it and
<<< done (N bytes pushed back) after the result is posted back, so the
operator can see traffic even in auto mode.
The runner exports a non-interactive env block (DEBIAN_FRONTEND=noninteractive,
CI=true, TERM=dumb, PAGER=cat, SYSTEMD_PAGER=cat, GIT_TERMINAL_PROMPT=0)
before executing each command, so apt, pagers, and git don't block on TTY input.
Sudo still prompts unless the caller uses sudo -n - the operator sees the
preview line and can choose to authenticate.
supervised mode needs an attached terminal to ask y/N; if none is present
(nohup, CI, piped in from something other than an interactive shell) the
runner exits immediately with a message pointing at ?mode=auto instead of
hanging. The output of any command that exits non-zero gets a trailing
[remotify: exit status N] marker so the client can tell success from
failure; very large output is truncated to a head plus a marker rather than
dropped or rejected outright. Result pushes are gzip-compressed when gzip
is available on the remote, falling back to a plain POST when it isn't, so a
box with only curl + bash still works.
A poll answered 410 (session gone) or 426 (hostname retired) ends the
runner with a message, because neither can be fixed by retrying. Every other
poll failure is retried with a widening wait, 2s doubling up to 30s and reset
by the next answered poll, so a relay that is down is not polled - and its
error line not reprinted - every two seconds for as long as the listener is
left running.
Returns {"ok": true, "service": "<DOMAIN>"}, so a monitor can also confirm which relay answered.
A name that still resolves here but is not DOMAIN is answered 426 Upgrade Required on every route, carrying the current endpoint and the fix. Nothing is
served on the old name; clients fail loudly until they are updated.
POST /api/session and GET /api/session/{key} are the exception: they answer
200 with a session payload whose quickstart lines and mcp_messages
templates carry the notice in place of a command. Clients render those and
discard error bodies, so it is the only way the message reaches an operator.
The payload is dead by construction - no session exists behind it, and its URLs
use a scheme no HTTP client can open - so nothing runs.
Skipped entirely when DOMAIN=localhost, so a local stack reached over
127.0.0.1 keeps working.
This includes health probes: an uptime monitor that hits the relay by IP or
by a hostname other than DOMAIN sees 426, not 200. Point monitors at
GET https://DOMAIN/api/health (or send a Host: DOMAIN header).
GET / serves a tiny zero-dependency HTML page that calls /api/session,
shows the generated key, the remote-side one-liner with a copy button, and the
curl-only exec template you can paste straight into any terminal. Useful
when an operator just needs the one-liner without hitting the API manually.
Two integration paths. Pick whichever your tool supports:
- MCP (most coding agents): your tool runs
mcp/server.jslocally and the model gets a nativeremote_exectool. - HTTP / curl (any tool with a Bash/shell tool): drop
templates/AGENTS.md.exampleinto your repo as the rules file your tool reads; the model calls the API itself with curl.
The MCP server is on npm as remotify-mcp - every host below can use npx to run it with zero install.
claude mcp add -s user remotify -- npx -y remotify-mcp@latest-s user registers the server globally (in ~/.claude.json at the user scope) so it's available from every working directory. Without -s user it lands in project-local scope and only appears in sessions whose cwd matches where you ran the command.
Verify:
claude mcp list # remotify should be listedInside a session, the /mcp slash-command shows the live status and the exposed remote_exec tool.
Edit ~/.cursor/mcp.json:
{ "mcpServers": { "remotify": { "command": "npx", "args": ["-y", "remotify-mcp@latest"] } } }Restart Cursor.
Edit ~/.codex/config.toml:
[mcp_servers.remotify]
command = "npx"
args = ["-y", "remotify-mcp@latest"]Edit ~/.gemini/settings.json:
{ "mcpServers": { "remotify": { "command": "npx", "args": ["-y", "remotify-mcp@latest"] } } }Windsurf, Continue, Cline/Roo Code, Zed, VS Code's built-in MCP - they all accept the same command + args shape, just in a config file they each document. Drop the snippet in and you're done.
Drop templates/AGENTS.md.example into your project as the rules file the tool reads - common filenames: AGENTS.md, CLAUDE.md, .cursorrules, CONVENTIONS.md, .github/copilot-instructions.md. It teaches the model to POST /api/session and use curl to push/pull. No server-side integration needed.
All snippets above default to https://relay.remotify.run. If you self-host, add one env var:
"env": { "REMOTIFY_URL": "https://remotify.example.com" }If it speaks HTTP, it can talk to the relay. The only API call needed to
get started is POST /api/session; everything after that is an HTTP GET or
POST against DOMAIN - there is no separate pipe. subdomain, it's all one vhost.
- Key = access. 128-bit random hex, unguessable at internet scale. Keep it private; anyone with the key can push commands to any listener on that key.
- TLS is mandatory in production. Keys travel in URL paths; HTTPS prevents interception.
- nginx rate-limits
POST /api/sessionper IP (seeRATE_LIMIT). - Data-minimised persistence. Each session's pending command/result and
push history live in a
0700directory on the relay host, readable only by the PHP worker user. Kept only as long as needed: purged on the idle TTL (default 3h) or an explicitDELETE. - Supervised mode on the remote.
/r/KEY?mode=supervisedpromptsy/Nbefore running each incoming command. --data-raw(rather than plain-d/--data) is used throughout to avoid curl's@filemagic - a command that starts with@will not be read as a filename.
Threat-wise this is roughly equivalent to handing someone an SSH session: commands run as whatever user pasted the remote one-liner. Do not paste the one-liner on a shell you would not SSH into.