A Render or GitHub Actions service that combines:
- Tailscale exit node — routes your traffic through Render's (or GH runner's) IP
- Shellinabox web terminal — browser-based root shell, accessible via your Tailnet
- File Browser — web-based file manager (upload/download/edit files)
- HTTP status page — placeholder page on the public URL
┌──────────────────────────────────────────────────────┐
│ Render Free Web Service OR GitHub Actions Runner │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ supervisor (PID 1 or managed) │ │
│ │ │ │
│ │ ┌─ tailscaled (userspace networking) ────┐ │ │
│ │ │ ↳ registers as exit node via authkey │ │ │
│ │ └───────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─ shellinaboxd (port 4200) ────────────┐ │ │
│ │ │ ↳ web terminal (Tailnet via Serve) │ │ │
│ │ └───────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─ File Browser (port 5800) ─────────────┐ │ │
│ │ │ ↳ web file manager (Tailnet via Serve)│ │ │
│ │ └───────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─ python http.server (port 8080) ──────┐ │ │
│ │ │ ↳ status page (health check) │ │ │
│ │ └───────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
↑ ↑
│ Tailscale Serve (443) │ Tailnet only
│ https://render-shell. │ (or localhost:8080)
│ <your-tailnet>.ts.net/ │
Go to https://login.tailscale.com/admin/settings/keys and generate:
- Reusable: yes (so it survives restarts)
- Ephemeral: no (so the node persists)
- Tags: optional (e.g.
tag:exit-node)
Copy the key — it looks like tskey-auth-XXXXXXXX-YYYYYYYYYYYYYYYY.
- Push this repo to GitHub
- On Render: New → Web Service → connect repo
- Environment: Docker
- Instance Type: Free
- Environment Variables:
| Key | Value | Purpose |
|---|---|---|
TAILSCALE_AUTHKEY |
tskey-auth-XXXX-YYYY |
Auto-registers the node on your Tailnet |
ROOT_PASSWORD |
your-strong-password |
Password for the shellinabox login |
TAILSCALE_HOSTNAME |
render-shell |
(optional) Custom hostname on your Tailnet |
After the first deploy:
- Go to https://login.tailscale.com/admin/machines
- Find the new node (
render-shellor similar) - Click the
⚠icon next to "Exit node" and approve it
On any device on your Tailnet:
# Use the exit node for all traffic
tailscale up --exit-node=render-shell
# Or use it for specific traffic
tailscale up --exit-node=render-shell --exit-node-allow-lan-access=trueThe shellinabox terminal is only accessible via your Tailnet (not the public Render URL):
http://render-shell.<your-tailnet>.ts.net:4200
Or find the IP:
tailscale status | grep render-shell
# → 100.x.x.x render-shell ...Then open http://100.x.x.x:4200 in your browser. Login with root + your ROOT_PASSWORD.
| Port | Where | What |
|---|---|---|
| 8080 | Public Render URL (https://...onrender.com) |
Status page (HTML) |
| 4200 | Tailnet only (http://render-shell:4200) |
Shellinabox web terminal |
| — | Tailscale | Exit node routing |
The web terminal is not on the public Render URL — only the status page is. This is intentional: the terminal should be private.
| Variable | Required | Default | Description |
|---|---|---|---|
TAILSCALE_AUTHKEY |
✅ yes | — | Tailscale authkey for auto-registration |
ROOT_PASSWORD |
✅ yes | change-me |
Password for shellinabox login |
TAILSCALE_HOSTNAME |
no | render-exit-node |
Hostname on your Tailnet |
TS_VERSION |
no | 1.86.2 |
Tailscale version (build arg) |
Run RenderShell on GitHub Actions runners instead of Render. This gives you:
| Metric | Render Free | GitHub Actions |
|---|---|---|
| RAM/CPU | 512 MB / 0.1 vCPU | 7 GB / 2 vCPUs |
| Sleep policy | After 15 min idle | Up to 6h continuous |
| Monthly hours | 750 (max 24/7 for 31d) | 2,000 (free), ∞ (public repo) |
| Runner OS | Alpine in Docker | Ubuntu 22.04 LTS |
| Max session | Indefinite (with keep-alive) | 6 hours per run |
| Docker | ✅ | ✅ (not needed — installs natively) |
A GitHub Actions workflow spins up the full stack on ubuntu-latest, connects to your
Tailnet, and exposes both the web terminal and file browser via Tailscale Serve:
https://render-shell.<your-tailnet>.ts.net/— web terminal (shellinabox)https://render-shell.<your-tailnet>.ts.net/files— file browser
Trigger manually or via scheduled cron (06/12/18 UTC) — 3 runs covers 18 hours/day.
# Create a new repo on GitHub, then:
git remote add origin git@github.com:your-user/render-shell.git
git push -u origin mainGo to https://login.tailscale.com/admin/settings/keys and generate:
- Reusable: yes (survives restarts across different runner IPs)
- Ephemeral: no (node persists in your tailnet)
- Tags: optional (e.g.
tag:exit-node)
Copy the key — it looks like tskey-auth-XXXXXXXX-YYYYYYYYYYYYYYYY.
Go to your repo Settings → Secrets and variables → Actions and add:
| Secret | Value | Purpose |
|---|---|---|
TAILSCALE_AUTHKEY |
tskey-auth-XXXX-YYYY |
Auto-registers the node on your Tailnet |
ROOT_PASSWORD |
your-strong-password |
Password for root shell + SSH login |
FILEBROWSER_PASSWORD |
your-filebrowser-password |
Password for file browser admin login |
Manual: Go to Actions → RenderShell → Run workflow → set duration → Run.
Scheduled: Runs automatically at 06:00, 12:00, and 18:00 UTC daily.
After the first run:
- Go to https://login.tailscale.com/admin/machines
- Find the new node (
render-shell) - Click the
⚠icon next to "Exit node" and approve it
On any device on your Tailnet:
# All traffic through RenderShell
tailscale up --exit-node=render-shell
# Or just use terminal/file browser:
# Open in browser:
# https://render-shell.<your-tailnet>.ts.net/ # web terminal
# https://render-shell.<your-tailnet>.ts.net/files # file browser| Service | Port | Access | Auth |
|---|---|---|---|
| Web Terminal | 4200 → 443 (Serve) | Tailnet only | root + ROOT_PASSWORD |
| File Browser | 5800 → 443/files (Serve) | Tailnet only | admin + FILEBROWSER_PASSWORD |
| SSH | 22 | localhost only | root + ROOT_PASSWORD |
| Status page | 8080 | localhost | none |
- Max runtime: 6 hours per run (configurable via
duration_hoursinput) - Auto-retrigger: Fires next session 2 min before expiry — infinite chain
- Fallback: Cron schedule at 06/12/18 UTC catches any missed retriggers
- Trigger:
workflow_dispatch(manual) +schedule(cron) - Files:
.github/workflows/render-shell.yml+setup-github-actions.sh - Arch: Installs natively on the runner (no Docker-in-Docker) — faster boot, simpler
GitHub Actions has a 6-hour limit for workflow_dispatch jobs. The workflow
auto-triggers the next session 2 minutes before expiry, creating an infinite
chain of 6-hour sessions with practically no downtime:
Session 1 ──────────────────────┐
06:00 11:58 ├── 🔄 trigger next
Session 2 ──────────────────────┐
12:00 17:58 ├── 🔄 trigger next
Session 3 ─── ...
18:00
The cron schedule at 06/12/18 UTC acts as a fallback in case the auto-retrigger ever misses (e.g., during a GitHub API blip).
To stop the chain, simply disable the workflow in the GitHub Actions UI or
set concurrency.cancel-in-progress: true temporarily.
Edit supervisord.conf, change the shellinabox command:
-s /:LOGIN → -s /:root
⚠ Anyone on your Tailnet gets a root shell without a password.
Edit supervisord.conf:
--css=/etc/shellinabox/options-enabled/00+Black-on-White.css
Available themes in /etc/shellinabox/options-enabled/.
Add a self-ping cron job in the Dockerfile:
RUN echo '*/10 * * * * wget -q -O- http://localhost:8080 >/dev/null' > /etc/crontabs/root
RUN apk add --no-cache dcron
# Add to supervisord.conf: [program:cron] command=crond -f⚠ Burns your 750 free hours faster.
"tailscale up" fails in logs
- Check that
TAILSCALE_AUTHKEYis set correctly in Render env vars - Check that the authkey hasn't expired (reusable keys last 90 days)
- Check Render logs for the exact error
Web terminal not reachable at http://render-shell:4200
- Verify the node is online:
tailscale status | grep render-shell - Userspace networking doesn't expose ports to the Tailnet directly — you may need to use
tailscale serveortailscale funnelto expose 4200
Exit node not routing traffic
- Approve the exit node in https://login.tailscale.com/admin/machines
- On the client:
tailscale up --exit-node=render-shell - Check:
curl https://api.ipify.orgshould show Render's IP, not yours
Container sleeps and drops the exit node
- This is expected on free tier (15 min inactivity)
- Either upgrade to paid tier, or add a self-ping (see above)