Ship turns a local Dockerfile or Docker Compose project into a service under
*.your-domain.com. It is built for small self-hosted clusters, Mac mini
homelabs, and teams that want a thin deployment path without introducing a full
PaaS.
Ship is for deploying many apps from a self-hosted Mac mini or similar small server. Services stay private inside your tailnet by default, and you can expose them publicly only when you choose to.
The target workflow is deliberately short:
- Scaffold an SSR app.
- Run
ship --service demo. - Open
https://demo.your-domain.com. - Confirm the app is live right away.
Ship should make the path from a local project to a private-by-default service feel direct, with public exposure as a later explicit choice.
For prerequisites, .env values, Windows notes, troubleshooting, and uninstall
steps, see
docs/guide/installation.md.
Before you start, have:
- a domain managed by Cloudflare, such as
your-domain.com - a Tailscale account where you can create tags and OAuth credentials
Install the CLI on macOS or Linux:
curl -fsSL https://raw.githubusercontent.com/gronxb/ship/main/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
ship -vThe installer uses the latest GitHub Release binary by default, so you do not need to clone this repository to install or upgrade Ship.
Get the credentials, then create .env from the .env.example shape:
-
Cloudflare: create an API token with Zone DNS Edit, Zone Read, and Account Cloudflare Tunnel Edit. Ship uses it to create the wildcard DNS record during install, then to publish one public hostname at a time through Cloudflare Tunnel when you choose Expose Internet.
Token permissions should include at least these rows:
-
Tailscale: open the Tailscale admin console, go to Access Control → JSON Editor, then add or merge these entries into the policy:
{
"tagOwners": {
"tag:k8s-operator": [],
"tag:k8s": ["tag:k8s-operator"]
}
}Then create an OAuth client with write access for General/Services,
Devices/Core, and Keys/Auth Keys on tag:k8s-operator.
Cloudflare Tunnel internet exposure does not require Tailscale Funnel
nodeAttrs. If your tailnet policy already has nodeAttrs for another
service, keep it; Ship only needs the tagOwners block above for the default
Tailscale Gateway path.
SHIP_DOMAIN=your-domain.com
CLOUDFLARE_API_TOKEN=your-cloudflare-token
TAILSCALE_CLIENT_ID=your-tailscale-client-id
TAILSCALE_CLIENT_SECRET=your-tailscale-client-secret
# Optional. ship install can usually infer these from the token and domain.
# CLOUDFLARE_ACCOUNT_ID=your-cloudflare-account-id
# CLOUDFLARE_ZONE_ID=your-cloudflare-zone-id
# Optional dashboard service name. The dashboard always stays Tailscale-only.
# Defaults to k8s, which gives you k8s.your-domain.com.
# SHIP_DASHBOARD_SERVICE=opsThen run:
ship install
ship upgradePaste this prompt into Codex, Claude Code, Cursor, or another coding agent from the project you want to run Ship from:
Install and verify Ship for this project.
Read https://raw.githubusercontent.com/gronxb/ship/main/docs/guide/installation.md
and follow it end to end. Do not summarize the guide back to me; use it as the
runbook.
Work from the project root and do the following:
1. Install the Ship CLI if `ship -v` is not already available:
`curl -fsSL https://raw.githubusercontent.com/gronxb/ship/main/install.sh | sh`
then add `$HOME/.local/bin` to PATH for this session.
2. Ensure `.env` exists. If it does not, create it from the Ship `.env.example`
shape.
3. Inspect `.env`. If any required value is missing, empty, or still a
placeholder, ask me only for the missing values:
- `SHIP_DOMAIN`
- `CLOUDFLARE_API_TOKEN`
- `TAILSCALE_CLIENT_ID`
- `TAILSCALE_CLIENT_SECRET`
4. If I provide optional values, also preserve them:
- `CLOUDFLARE_ACCOUNT_ID`
- `CLOUDFLARE_ZONE_ID`
- `SHIP_ACME_EMAIL`
- `SHIP_DASHBOARD_SERVICE`
5. Never print secrets. Write the completed values to `.env`.
6. Once `.env` is complete, load it for this shell and continue automatically:
`set -a`
`. ./.env`
`set +a`
`ship install`
`ship upgrade`
7. Verify the dashboard rollout:
`kubectl rollout status deployment/${SHIP_DASHBOARD_SERVICE:-k8s} \
-n ship-services --timeout=180s`
8. Verify the user-facing dashboard:
`curl -I https://${SHIP_DASHBOARD_SERVICE:-k8s}.$SHIP_DOMAIN`
9. Finish with the dashboard URL, Kubernetes context, and any command that
failed with its relevant output.
Ship can deploy any project with either a Dockerfile or Docker Compose. The
framework and runtime are up to you. This example uses a Hono hello-world app on Bun. Hono is a small
web framework for the Web Platform, and its
Bun guide starts from the same tiny
app shape.
bun create hono@latest demo
cd demo
bun installUse the Bun template, then make sure src/index.ts returns a simple response:
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Ship!'))
export default appAdd a minimal Dockerfile:
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
COPY . .
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]Deploy it:
ship --service demoOpen https://demo.your-domain.com to see Hello Ship!. For your own app, keep
the same pattern: add a Dockerfile, then run ship --service <name>.
For a multi-container Compose project, Ship auto-detects compose.yaml,
compose.yml, docker-compose.yaml, or docker-compose.yml when no Dockerfile
exists. A service named gateway is selected automatically; otherwise use an
explicit service that publishes a TCP port:
ship --service demo --compose-file ./docker-compose.yml --compose-service gateway --env-file ./.envCompose stays on the host. Ship runs docker compose up --wait and connects the
selected published port to the cluster with a selectorless Service and managed
EndpointSlice. Compose files are trusted executable input. Ship never copies a
Compose env file into a Kubernetes Secret, never overrides the Compose project
name, and never removes Compose volumes. This host bridge currently requires a
local kind cluster.
Install the Ship skill once:
npx skills add gronxb/shipThen open a project in Claude Code, Codex, or another agent that can use the skill:
$ship deploy this project as demo
That is enough. The skill uses an existing Compose project when present or can
create a suitable Dockerfile, deploy with Ship, and give you
https://demo.your-domain.com.
Ship is private by default:
*.your-domain.comresolves to the Tailscale Gateway address as a DNS-only Cloudflare record.- only devices in your tailnet can reach deployed services.
- Dockerfile projects do not need host port mapping.
- Compose projects must publish the selected service port on a non-loopback host address.
ship install also creates a Cloudflare Tunnel connector in the cluster. It
does not make every service public. Internet exposure is a promotion path for a
service that already exists on the Tailscale route; a first deploy directly to
the internet is rejected. When you expose one service publicly, Ship keeps the
same hostname and adds a more specific proxied Cloudflare CNAME for that
service:
- private default:
demo.your-domain.com→ wildcard DNS-only record → Tailscale Gateway - public after Expose Internet:
demo.your-domain.com→ proxied CNAME → Cloudflare Tunnel → in-cluster service - private again: Ship removes the specific CNAME and tunnel route, so the wildcard Tailscale route works again
The Ship dashboard itself is never eligible for internet exposure. That rule is
based on the configured dashboard service/host, not on the literal service name
k8s: if you set SHIP_DASHBOARD_SERVICE=ops, ops.your-domain.com remains
Tailscale-only too.
To expose one non-dashboard service publicly from the CLI, deploy it on Tailscale first, then promote the existing service:
ship --service demo
ship --service demo --exposure internetLater redeploys keep the current network exposure unless you pass --exposure
explicitly. An internet-exposed service stays on the internet path; a
Tailscale-only service stays Tailscale-only.
Run tests:
make testMain directories: cmd/ship, internal/deploy, deploy-system, start-app,
and scripts.
Ship shells out to Docker, kubectl, kind, and registry tooling from the machine
where it runs. Treat that host as part of your deployment trust boundary. Review
generated manifests with ship --dry-run --json before applying changes to a
new cluster.
Report vulnerabilities using the process in SECURITY.md.
Issues and pull requests are welcome. Start with
CONTRIBUTING.md and run make test before submitting a PR.
MIT. See LICENSE.

