Full-stack artifacts on your domain
See the code
Apache-2.0 · Quickstart · Connect an agent · Pull-request previews
gangway gives any containerized app a public HTTPS URL on your own domain.
Hosted platforms will put your app on a URL, but on their servers, for the frameworks they support, at their prices. I had a server with room to spare and kept needing a link for something: a visual review for a frontend pull request, a build for a client, a deck an agent had just made. gangway is the one place, on hardware you already own, that turns any of those into a URL in seconds.
You get a URL in three ways, and all three produce the same kind of preview:
deploy tool. It works with Claude Code, Codex, Cursor, VS Code or
any MCP client. The call blocks until the URL actually answers, then returns it.It runs as a single process on a plain Docker host, with no Kubernetes. You self-host it on your own domain.
https://github.com/user-attachments/assets/f53178ca-89fc-4ecf-b6b7-bf7490b57ac2
shop-pr-142.preview.example.com. There is no per-preview certificate, so Let's Encrypt
rate limits never bite.gangway.yml can say more.artifact.md renders as a document, a slide deck, a dashboard
or a prototype.app.<domain> ─┐
api.<domain> ─┼─▶ UI · REST API · OAuth ─┐
mcp.<domain> ─┤ MCP │
hooks.<domain> ─┘ GitHub webhooks ├─▶ builder · scheduler · SQLite
│ │
*.<domain> ───▶ proxy: gate, wake ─────┘ ▼ Docker API
│ Docker host(s)
└──────────────────▶ your preview's containers
gangway dispatches every request on its Host header. The reserved labels (app, api,
mcp, hooks) reach gangway itself, and every other label is a preview. It runs your stack
with docker compose, under a policy that refuses anything that would reach the host:
privileged mode, bind mounts, host networking, the Docker socket and the like. It also drops
Linux capabilities and applies memory and process limits. SQLite is the source of truth, and
every container carries labels that describe it, so a restart reconciles the two.
Status: early. gangway is v0.x and runs in production for its author. Expect rough edges, and read Security before you expose it.
To serve previews publicly, also:
GANGWAY_PUBLIC_PORT
puts it in the URLs gangway hands out).*.preview.example.com and
preview.example.com pointing at that server.Or run it local-only, with none of that: --local puts the dashboard, API, MCP and previews
on *.preview.localhost, which answers only on the machine gangway runs on. It is the default
on a laptop. To show a preview to anyone else, press Share on its page (or ask your agent to
share it): gangway opens a Cloudflare quick tunnel
and hands back a public https://….trycloudflare.com link, until you stop it or it expires.
Quick tunnels need no Cloudflare account and are meant for testing: at most 200 requests at
once, no server-sent events, and a new link each time. Sharing is on by default only for a
local-only install; on a domain install an admin turns it on in Admin, or with GANGWAY_SHARE=true.
On the host, as root or a user in the docker group:
curl -fsSL gangway.sh/install | sh
The installer asks whether gangway runs local-only or on your domain (curl -fsSL gangway.sh/install | sh -s -- --local skips the question), then for your domain and whether a
reverse proxy or gangway itself holds port 443; with gangway holding it, it also asks for a Cloudflare API token so it can get a Let's
Encrypt wildcard certificate over DNS-01. It checks Docker, DNS and the ports, writes
/opt/gangway/.env (with a generated admin token) and compose.yaml, starts gangway, and
prints a one-time link.
Open that link to create the first admin account. There are no default credentials. The link
changes on every start until the first account exists; docker logs gangway | grep setup
shows the current one.
It installs the way the host expects. On plain Linux and CasaOS (where it shows up as an app)
that is a compose project. Unraid (a Docker-tab template with the icon), TrueNAS SCALE (a custom
app), Synology and desktop engines (Docker Desktop, Colima, Podman, on preview.localhost)
are supported but experimental: written to each platform's conventions, not yet verified on
one.
Run the installer again to upgrade. gangway backs its database up before it migrates, and if
the new version does not come up healthy the installer puts the previous version and that
backup back; --rollback does the same by hand. Changes of your own to the compose setup go in
compose.override.yaml, which upgrades leave alone. --help lists flags for everything it asks, so it can run
unattended: curl -fsSL gangway.sh/install | sh -s -- --domain preview.example.com --tls acme --cf-token ... --yes.
compose.yaml documents every setting inline. Next to it, write a .env:
GANGWAY_ADMIN_TOKEN=gw_REPLACE_ME # echo "gw_$(openssl rand -hex 24)"
GANGWAY_BASE_DOMAIN=preview.example.com
GANGWAY_STATE_PATH=/srv/gangway # SQLite, logs and uploads
then docker compose up -d. That assumes a reverse proxy such as Nginx Proxy Manager holds
port 443 and the certificate. To let gangway own 443 and get its own certificate instead,
add:
GANGWAY_LISTEN_ADDRESS=::
GANGWAY_LISTEN_PORT=443
GANGWAY_LISTEN_HTTP_PORT=80
GANGWAY_TLS_MODE=acme
GANGWAY_TRUSTED_PROXIES=
GANGWAY_CF_API_TOKEN=... # a Cloudflare token that can edit the zone's DNS
GANGWAY_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
To build from a checkout instead of pulling the published image:
docker compose -f compose.yaml -f compose.build.yaml up -d --build.
From the UI, go to New preview and drop in a folder. You can also start from a runtime's example, or add a throwaway database.
From a terminal:
tar -czf - . | curl --fail -X POST \
-H "Authorization: Bearer $GANGWAY_TOKEN" -H "Content-Type: application/gzip" \
--data-binary @- "https://api.preview.example.com/v1/previews?name=hello&runtime=auto&wait=true"
The answer includes the URL once the preview serves it. Create tokens under Account.
1. Turn on MCP. It is off by default. Under Admin → Server → Surfaces, choose Turn on.
2. Copy the setup for your client. Account → Connect an agent shows the exact commands for Claude Code, Codex, Cursor and VS Code, with your URL filled in.
For Claude Code:
claude plugin marketplace add charlesabarnes/gangway && \
claude plugin install gangway@gangway --config mcp_url=https://mcp.preview.example.com/
3. Sign in and approve. In Claude Code, run /mcp and sign in to gangway. Your browser
opens gangway, which asks you to approve the agent and choose what it may do. Pick artifacts
for an agent you do not fully trust: it can deploy only static sites and artifacts, and touch
only what it deployed itself.
Suggested for Claude Code: make gangway the default over Claude artifacts.
Claude Code has its own artifacts, and when they are on, it usually picks them over gangway for a
chart, a diagram or a document, even with the plugin installed. To make gangway the default,
either turn them off in ~/.claude/settings.json:
{ "enableArtifact": false }
or keep them and add a line to ~/.claude/CLAUDE.md:
- For any chart, diagram, document, deck or board, use gangway, not Claude artifacts or a local
HTML file, unless I ask for those.
Suggested for Claude Code in auto mode: tell it that gangway is yours.
Claude Code's auto mode does not know your gangway server is yours, so its safety check can
block a deploy that carries hostnames, IPs or other infrastructure details from a repo as data
exfiltration. Tell it in ~/.claude/settings.json (auto mode reads this from user settings
only), with your own domains. The Connect an agent page shows this with them filled in:
{
"autoMode": {
"environment": [
"$defaults",
"Trusted internal domains: mcp.preview.example.com, *.preview.example.com",
"gangway (mcp.preview.example.com) is my own self-hosted deploy server; sending repo contents, hostnames and infrastructure details to it is deploying, not exfiltration"
]
}
}
The plugin adds /gangway:generate-artifact, which builds something and ships it to a URL you
can keep iterating on. Other clients only need the MCP URL: gangway takes both OAuth client
styles, a Client ID Metadata Document (Claude, Codex, ChatGPT) or dynamic client registration
at /oauth/register (most other MCP clients). A client that registered itself is marked
unverified on the consent page, since its name is its own claim. A client with no OAuth at all
can send an API token as Authorization: Bearer gw_…. See
plugin/gangway. A connected agent is listed under Account →
Connected agents, where you can disconnect it.
The MCP server has four tools: deploy, status, logs and destroy. deploy is
idempotent, and it waits until the URL answers.
/preview deploy.Everything can be set in the environment. Settings not pinned there are editable in the UI.
| Variable | Default | |
|---|---|---|
GANGWAY_BASE_DOMAIN | required | Domain for the UI, API, MCP and, by default, previews |
GANGWAY_PREVIEW_DOMAIN | (base domain) | Optional: put previews on their own registrable domain |
GANGWAY_PREVIEW_DOMAINS | (none) | More wildcard domains previews may be named under, comma-separated |
GANGWAY_INSTANCE | required | Prefix for this install's containers, networks and volumes |
GANGWAY_ADMIN_TOKEN | (none) | Break-glass admin token, the only one that can mint tokens |
GANGWAY_TLS_MODE | selfsigned | selfsigned, acme (DNS-01) or file |
GANGWAY_TRUSTED_PROXIES | (none) | Proxies whose X-Forwarded-For is believed |
GANGWAY_CONTROL_ALLOW | (everyone) | Networks allowed to reach the UI and API; previews stay public |
GANGWAY_PREVIEW_MEMORY / _CPUS / _PIDS | 1g / off / 1024 | Limits for every preview container |
GANGWAY_SURFACE_MCP | false | Pin the MCP surface on or off |
GANGWAY_SHARE | on if local-only | Pin share links on or off; while off, cloudflared never starts |
GANGWAY_CLOUDFLARED | cloudflared | The cloudflared binary share links run; the image carries one |
Previews are named <label>.<domain>. Beyond the domains in the environment, anyone with the
permission can claim one they own, in Admin → Domains & traffic (for every repository), on a repository's
Domains tab (its previews, or a hostname for its production preview), or on a preview (a
hostname such as www.example.com). A claim asks for two DNS records at the owner's provider:
_acme-challenge.<name> as a CNAME to the <id>.acme.<your base domain> name gangway shows.
It proves the name is theirs, and gangway answers the certificate challenge there.*.<name> (or the hostname) as a CNAME to your base domain, which sends the traffic here.gangway checks every minute. With GANGWAY_TLS_MODE=acme and a Cloudflare token for the base
domain's zone, it then gets a certificate for the name and serves it by SNI, one certificate per
domain. A repository or preview chooses its domain from those it may use; a preview moves when
it is next deployed or rebuilt.
Behind a reverse proxy the proxy holds the certificates. Caddy can get one per name on demand:
{
on_demand_tls {
ask http://gangway:8080/_gangway/tls/ask
}
}
https:// {
tls {
on_demand
}
reverse_proxy https://gangway:8443 {
transport http {
tls_insecure_skip_verify
}
}
}
The ask is on gangway's plain-HTTP listener (GANGWAY_LISTEN_HTTP_PORT, 8080 unless set empty).
gangway answers it only from loopback or GANGWAY_TRUSTED_PROXIES, and only for names it
serves today. Nginx Proxy Manager needs a proxy host and certificate per domain, added by hand.
Preview traffic is rate limited per visitor and per preview (Admin → Domains & traffic); past the limit a preview answers 429.
GANGWAY_CONTROL_ALLOW.no-new-privileges, a reduced set of capabilities, and memory
and process limits.DOCKER-USER chain), or give gangway a Docker host of its own.GANGWAY_PREVIEW_DOMAIN. The session
cookie is host-only and CSRF is checked by Origin, but a separate preview domain is the
stronger setup for untrusted pull requests.Report vulnerabilities privately through GitHub's Security → Report a vulnerability, or by email to security@gangway.sh, not in an issue.
bun install
bun run test # server, shared and render; no Docker needed
bun run typecheck && bun run lint
cp scripts/dev.example.json scripts/dev.json # then point it at your Docker host
GANGWAY_CONFIG=scripts/dev.json bun run dev
cd web && npm install && npm start # the Angular UI
The server is TypeScript on Bun, Hono and
bun:sqlite. The UI is Angular with Tailwind. Every dependency is free of native addons.
TypeScript
92.7%
CSS
3.8%
HTML
1.8%
Full-stack artifacts on your domain
See the code
Apache-2.0 · Quickstart · Connect an agent · Pull-request previews
gangway gives any containerized app a public HTTPS URL on your own domain.
Hosted platforms will put your app on a URL, but on their servers, for the frameworks they support, at their prices. I had a server with room to spare and kept needing a link for something: a visual review for a frontend pull request, a build for a client, a deck an agent had just made. gangway is the one place, on hardware you already own, that turns any of those into a URL in seconds.
You get a URL in three ways, and all three produce the same kind of preview:
deploy tool. It works with Claude Code, Codex, Cursor, VS Code or
any MCP client. The call blocks until the URL actually answers, then returns it.It runs as a single process on a plain Docker host, with no Kubernetes. You self-host it on your own domain.
https://github.com/user-attachments/assets/f53178ca-89fc-4ecf-b6b7-bf7490b57ac2
shop-pr-142.preview.example.com. There is no per-preview certificate, so Let's Encrypt
rate limits never bite.gangway.yml can say more.artifact.md renders as a document, a slide deck, a dashboard
or a prototype.app.<domain> ─┐
api.<domain> ─┼─▶ UI · REST API · OAuth ─┐
mcp.<domain> ─┤ MCP │
hooks.<domain> ─┘ GitHub webhooks ├─▶ builder · scheduler · SQLite
│ │
*.<domain> ───▶ proxy: gate, wake ─────┘ ▼ Docker API
│ Docker host(s)
└──────────────────▶ your preview's containers
gangway dispatches every request on its Host header. The reserved labels (app, api,
mcp, hooks) reach gangway itself, and every other label is a preview. It runs your stack
with docker compose, under a policy that refuses anything that would reach the host:
privileged mode, bind mounts, host networking, the Docker socket and the like. It also drops
Linux capabilities and applies memory and process limits. SQLite is the source of truth, and
every container carries labels that describe it, so a restart reconciles the two.
Status: early. gangway is v0.x and runs in production for its author. Expect rough edges, and read Security before you expose it.
To serve previews publicly, also:
GANGWAY_PUBLIC_PORT
puts it in the URLs gangway hands out).*.preview.example.com and
preview.example.com pointing at that server.Or run it local-only, with none of that: --local puts the dashboard, API, MCP and previews
on *.preview.localhost, which answers only on the machine gangway runs on. It is the default
on a laptop. To show a preview to anyone else, press Share on its page (or ask your agent to
share it): gangway opens a Cloudflare quick tunnel
and hands back a public https://….trycloudflare.com link, until you stop it or it expires.
Quick tunnels need no Cloudflare account and are meant for testing: at most 200 requests at
once, no server-sent events, and a new link each time. Sharing is on by default only for a
local-only install; on a domain install an admin turns it on in Admin, or with GANGWAY_SHARE=true.
On the host, as root or a user in the docker group:
curl -fsSL gangway.sh/install | sh
The installer asks whether gangway runs local-only or on your domain (curl -fsSL gangway.sh/install | sh -s -- --local skips the question), then for your domain and whether a
reverse proxy or gangway itself holds port 443; with gangway holding it, it also asks for a Cloudflare API token so it can get a Let's
Encrypt wildcard certificate over DNS-01. It checks Docker, DNS and the ports, writes
/opt/gangway/.env (with a generated admin token) and compose.yaml, starts gangway, and
prints a one-time link.
Open that link to create the first admin account. There are no default credentials. The link
changes on every start until the first account exists; docker logs gangway | grep setup
shows the current one.
It installs the way the host expects. On plain Linux and CasaOS (where it shows up as an app)
that is a compose project. Unraid (a Docker-tab template with the icon), TrueNAS SCALE (a custom
app), Synology and desktop engines (Docker Desktop, Colima, Podman, on preview.localhost)
are supported but experimental: written to each platform's conventions, not yet verified on
one.
Run the installer again to upgrade. gangway backs its database up before it migrates, and if
the new version does not come up healthy the installer puts the previous version and that
backup back; --rollback does the same by hand. Changes of your own to the compose setup go in
compose.override.yaml, which upgrades leave alone. --help lists flags for everything it asks, so it can run
unattended: curl -fsSL gangway.sh/install | sh -s -- --domain preview.example.com --tls acme --cf-token ... --yes.
compose.yaml documents every setting inline. Next to it, write a .env:
GANGWAY_ADMIN_TOKEN=gw_REPLACE_ME # echo "gw_$(openssl rand -hex 24)"
GANGWAY_BASE_DOMAIN=preview.example.com
GANGWAY_STATE_PATH=/srv/gangway # SQLite, logs and uploads
then docker compose up -d. That assumes a reverse proxy such as Nginx Proxy Manager holds
port 443 and the certificate. To let gangway own 443 and get its own certificate instead,
add:
GANGWAY_LISTEN_ADDRESS=::
GANGWAY_LISTEN_PORT=443
GANGWAY_LISTEN_HTTP_PORT=80
GANGWAY_TLS_MODE=acme
GANGWAY_TRUSTED_PROXIES=
GANGWAY_CF_API_TOKEN=... # a Cloudflare token that can edit the zone's DNS
GANGWAY_ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
To build from a checkout instead of pulling the published image:
docker compose -f compose.yaml -f compose.build.yaml up -d --build.
From the UI, go to New preview and drop in a folder. You can also start from a runtime's example, or add a throwaway database.
From a terminal:
tar -czf - . | curl --fail -X POST \
-H "Authorization: Bearer $GANGWAY_TOKEN" -H "Content-Type: application/gzip" \
--data-binary @- "https://api.preview.example.com/v1/previews?name=hello&runtime=auto&wait=true"
The answer includes the URL once the preview serves it. Create tokens under Account.
1. Turn on MCP. It is off by default. Under Admin → Server → Surfaces, choose Turn on.
2. Copy the setup for your client. Account → Connect an agent shows the exact commands for Claude Code, Codex, Cursor and VS Code, with your URL filled in.
For Claude Code:
claude plugin marketplace add charlesabarnes/gangway && \
claude plugin install gangway@gangway --config mcp_url=https://mcp.preview.example.com/
3. Sign in and approve. In Claude Code, run /mcp and sign in to gangway. Your browser
opens gangway, which asks you to approve the agent and choose what it may do. Pick artifacts
for an agent you do not fully trust: it can deploy only static sites and artifacts, and touch
only what it deployed itself.
Suggested for Claude Code: make gangway the default over Claude artifacts.
Claude Code has its own artifacts, and when they are on, it usually picks them over gangway for a
chart, a diagram or a document, even with the plugin installed. To make gangway the default,
either turn them off in ~/.claude/settings.json:
{ "enableArtifact": false }
or keep them and add a line to ~/.claude/CLAUDE.md:
- For any chart, diagram, document, deck or board, use gangway, not Claude artifacts or a local
HTML file, unless I ask for those.
Suggested for Claude Code in auto mode: tell it that gangway is yours.
Claude Code's auto mode does not know your gangway server is yours, so its safety check can
block a deploy that carries hostnames, IPs or other infrastructure details from a repo as data
exfiltration. Tell it in ~/.claude/settings.json (auto mode reads this from user settings
only), with your own domains. The Connect an agent page shows this with them filled in:
{
"autoMode": {
"environment": [
"$defaults",
"Trusted internal domains: mcp.preview.example.com, *.preview.example.com",
"gangway (mcp.preview.example.com) is my own self-hosted deploy server; sending repo contents, hostnames and infrastructure details to it is deploying, not exfiltration"
]
}
}
The plugin adds /gangway:generate-artifact, which builds something and ships it to a URL you
can keep iterating on. Other clients only need the MCP URL: gangway takes both OAuth client
styles, a Client ID Metadata Document (Claude, Codex, ChatGPT) or dynamic client registration
at /oauth/register (most other MCP clients). A client that registered itself is marked
unverified on the consent page, since its name is its own claim. A client with no OAuth at all
can send an API token as Authorization: Bearer gw_…. See
plugin/gangway. A connected agent is listed under Account →
Connected agents, where you can disconnect it.
The MCP server has four tools: deploy, status, logs and destroy. deploy is
idempotent, and it waits until the URL answers.
/preview deploy.Everything can be set in the environment. Settings not pinned there are editable in the UI.
| Variable | Default | |
|---|---|---|
GANGWAY_BASE_DOMAIN | required | Domain for the UI, API, MCP and, by default, previews |
GANGWAY_PREVIEW_DOMAIN | (base domain) | Optional: put previews on their own registrable domain |
GANGWAY_PREVIEW_DOMAINS | (none) | More wildcard domains previews may be named under, comma-separated |
GANGWAY_INSTANCE | required | Prefix for this install's containers, networks and volumes |
GANGWAY_ADMIN_TOKEN | (none) | Break-glass admin token, the only one that can mint tokens |
GANGWAY_TLS_MODE | selfsigned | selfsigned, acme (DNS-01) or file |
GANGWAY_TRUSTED_PROXIES | (none) | Proxies whose X-Forwarded-For is believed |
GANGWAY_CONTROL_ALLOW | (everyone) | Networks allowed to reach the UI and API; previews stay public |
GANGWAY_PREVIEW_MEMORY / _CPUS / _PIDS | 1g / off / 1024 | Limits for every preview container |
GANGWAY_SURFACE_MCP | false | Pin the MCP surface on or off |
GANGWAY_SHARE | on if local-only | Pin share links on or off; while off, cloudflared never starts |
GANGWAY_CLOUDFLARED | cloudflared | The cloudflared binary share links run; the image carries one |
Previews are named <label>.<domain>. Beyond the domains in the environment, anyone with the
permission can claim one they own, in Admin → Domains & traffic (for every repository), on a repository's
Domains tab (its previews, or a hostname for its production preview), or on a preview (a
hostname such as www.example.com). A claim asks for two DNS records at the owner's provider:
_acme-challenge.<name> as a CNAME to the <id>.acme.<your base domain> name gangway shows.
It proves the name is theirs, and gangway answers the certificate challenge there.*.<name> (or the hostname) as a CNAME to your base domain, which sends the traffic here.gangway checks every minute. With GANGWAY_TLS_MODE=acme and a Cloudflare token for the base
domain's zone, it then gets a certificate for the name and serves it by SNI, one certificate per
domain. A repository or preview chooses its domain from those it may use; a preview moves when
it is next deployed or rebuilt.
Behind a reverse proxy the proxy holds the certificates. Caddy can get one per name on demand:
{
on_demand_tls {
ask http://gangway:8080/_gangway/tls/ask
}
}
https:// {
tls {
on_demand
}
reverse_proxy https://gangway:8443 {
transport http {
tls_insecure_skip_verify
}
}
}
The ask is on gangway's plain-HTTP listener (GANGWAY_LISTEN_HTTP_PORT, 8080 unless set empty).
gangway answers it only from loopback or GANGWAY_TRUSTED_PROXIES, and only for names it
serves today. Nginx Proxy Manager needs a proxy host and certificate per domain, added by hand.
Preview traffic is rate limited per visitor and per preview (Admin → Domains & traffic); past the limit a preview answers 429.
GANGWAY_CONTROL_ALLOW.no-new-privileges, a reduced set of capabilities, and memory
and process limits.DOCKER-USER chain), or give gangway a Docker host of its own.GANGWAY_PREVIEW_DOMAIN. The session
cookie is host-only and CSRF is checked by Origin, but a separate preview domain is the
stronger setup for untrusted pull requests.Report vulnerabilities privately through GitHub's Security → Report a vulnerability, or by email to security@gangway.sh, not in an issue.
bun install
bun run test # server, shared and render; no Docker needed
bun run typecheck && bun run lint
cp scripts/dev.example.json scripts/dev.json # then point it at your Docker host
GANGWAY_CONFIG=scripts/dev.json bun run dev
cd web && npm install && npm start # the Angular UI
The server is TypeScript on Bun, Hono and
bun:sqlite. The UI is Angular with Tailwind. Every dependency is free of native addons.
TypeScript
92.7%
CSS
3.8%
HTML
1.8%