Install with the one-line installer. It detects your OS/arch, downloads the release binary, verifies its SHA-256, and runs the binary's own exec-install — for the client that places it on PATH; for the server it sets up the systemd service (left stopped).
Server
Server (public Linux host; binds :80/:443, point your admin DNS at it). The systemd setup needs root, so install with sudo. It leaves the service stopped — configure the admin host, then start:
# 1. install the binary + set up the service (stopped). Needs root for systemd.
curl -fsSL https://seikan.okonomi.cloud/server/install.sh | sudo bash
# 2. set the admin host (+ options) — opens $EDITOR, or use --set for unattended installs
sudo seikan-server configure --set SEIKAN_ADMIN_HOST=admin.example.com
# 3. start it — prints the setup key in your terminal
sudo seikan-server start
# 4. open https://admin.example.com/_admin and create the first administrator
# (paste the setup key, then pick a username + password)
Steps 2–3 are scriptable: configure --set KEY=VALUE (repeatable) edits the env file with no editor. Add SEIKAN_ACME_STAGING=true while testing (avoids Let's Encrypt rate limits; the cert won't be browser-trusted). start refuses to run until the admin host is set — and the server itself refuses to run if that host's DNS doesn't actually resolve to this machine (checked at startup, so a typo or a record that hasn't propagated yet fails loudly instead of silently breaking certificate issuance). Behind Docker/NAT/a reverse proxy, where that's expected rather than a mistake, set SEIKAN_SKIP_ADMIN_HOST_CHECK=true. To upgrade later, run sudo seikan-server update — it checks for a newer release and, if found, swaps the binary in place and restarts only if it was already running. (Re-running the installer works too, and additionally backfills any new config settings.) To pin a version at install time: curl -fsSL https://seikan.okonomi.cloud/server/install.sh | sudo bash -s -- --version 0.17.0.
Client
Client (macOS or Linux) — no root needed; install just places the binary; configure after:
curl -fsSL https://seikan.okonomi.cloud/client/install.sh | bash
seikan init --server admin.example.com
init prints a confirmation code and waits; an administrator approves it under Requests in the admin UI, after checking the code matches, and the client picks up its own client key. Nothing secret has to be copied between the two machines. If you already have a key, pass it instead: seikan init --server admin.example.com --api-key sk_....
The installer installs the latest release. Update with seikan update (checks for a newer release and swaps the binary in place); remove with seikan uninstall --purge.
Run with Docker
Multi-arch (linux/amd64, linux/arm64) images publish to Docker Hub on every release, in two separate repos, each tagged both by version (e.g. 0.17.1) and latest:
okonomigmbh/seikan— the server; entrypoint isseikan-server runokonomigmbh/seikan-client— the client; entrypoint isseikan up
Both are configured entirely through the SEIKAN_* environment variables (see the server- and client-reference tables); an empty value is treated as unset, so unset a variable to fall back to its default.
Server
docker run -d --name seikan-server \
-p 80:80 -p 443:443 \
-e SEIKAN_ADMIN_HOST=admin.example.com \
-e SEIKAN_ACME_EMAIL=you@example.com \
-e SEIKAN_SETUP_KEY=pick-something-unguessable \
-e SEIKAN_SKIP_ADMIN_HOST_CHECK=true \
-v seikan-state:/var/lib/seikan \
okonomigmbh/seikan:latest
docker logs seikan-server # the setup key prints on first start (or set SEIKAN_SETUP_KEY yourself)
/var/lib/seikan holds the state DB and autocert cache — mount a volume so it survives restarts. SEIKAN_SKIP_ADMIN_HOST_CHECK is needed here specifically: the server checks at startup that SEIKAN_ADMIN_HOST resolves to one of its own network interfaces, but inside a container that's always the container's internal IP, never the public one your DNS actually points at — so the check would otherwise refuse to start even on a correct setup.
Client
The client image needs no setup call and no config file. Give it the admin host, a client key, and the domain (or TCP port) to serve, and it creates the frontend itself on first boot:
docker run -d --name seikan-client \
--network myapp_default \
-e SEIKAN_SERVER=admin.example.com \
-e SEIKAN_API_KEY=sk_... \
-e SEIKAN_DOMAIN=app.example.com \
-e SEIKAN_TARGET=app:80 \
okonomigmbh/seikan-client:latest
docker logs seikan-client # -> Tunnel live: https://app.example.com -> app:80
The client key is issued in the admin UI under Client keys — one key can serve any number of containers, and it can only manage its own frontends. DNS for app.example.com has to point at the server; the certificate issues on first request.
A raw TCP tunnel is the same command with the port in place of the domain:
docker run -d --name seikan-db \
--network myapp_default \
-e SEIKAN_SERVER=admin.example.com \
-e SEIKAN_API_KEY=sk_... \
-e SEIKAN_TCP_PORT=10005 \
-e SEIKAN_TARGET=db:5432 \
okonomigmbh/seikan-client:latest
SEIKAN_TCP_PORT is the public port, and the container asks for that same one every time it starts — so the address your users connect to (psql -h admin.example.com -p 10005) never moves. It has to be inside the server's SEIKAN_TCP_RANGE and published by the server container; a port outside the published range binds happily inside the container and is then simply unreachable. Omit it and the server assigns one, which is fine for something ephemeral but changes on every restart.
| Variable | Meaning |
|---|---|
SEIKAN_SERVER |
The admin host — admin.example.com, admin.example.com:443 and https://admin.example.com all work. |
SEIKAN_API_KEY |
A client key (sk_…). This is what lets the container manage its own frontend. |
SEIKAN_DOMAIN |
Domain(s) to serve. Comma-separate for several on one frontend; wildcards (*.example.com) are allowed. |
SEIKAN_TCP_PORT |
Public TCP port, instead of SEIKAN_DOMAIN. |
SEIKAN_TARGET |
The service to forward to, host:port. |
SEIKAN_TOKEN |
A connect token you minted yourself, instead of SEIKAN_API_KEY (see below). |
SEIKAN_INSECURE · SEIKAN_VERBOSE |
Skip server TLS verification (dev only) · log every request. |
What happens on a restart. The container looks for its own frontend and, finding it, asks the server for a fresh connect token rather than recreating anything — the frontend keeps its ID and its route, so nothing on the public side moves. (It has to ask: a connect token is shown only once, at creation, and a container has nowhere to keep it.) The previous token stops working at that moment, which is why exactly one container should run any given frontend; two sharing a domain will take it from each other in turn.
Where SEIKAN_TARGET points. It is dialled from inside the client container, so it is an address on the container's network — not on the docker host:
- Another container — the normal case. Put the client on that service's network and use the service (or container) name plus the port it listens on inside the network:
app:80. The target then needs noports:mapping at all, which is the shape you want — nothing is published on the docker host and the tunnel is the only way in. A bare port is rejected rather than assumed to meanlocalhost, since inside a container that would be the container itself. - A process on the docker host itself (not containerized). Then
host.docker.internal:3000, but two conditions apply: on Linux you must add--add-host host.docker.internal:host-gateway(Docker Desktop resolves the name already), and the process must listen on an address the container can reach — bound to127.0.0.1only it is not reachable from the container, so bind0.0.0.0or the bridge address. - Anything else routable from the container — another host on the LAN, an internal DNS name, a service in a different network the client is also attached to — works identically.
One tunnel per container. A container serves the single frontend its environment describes, so several tunnels means several containers (see the compose below). They are tiny and independent — one restarting doesn't disturb the others. Several domains onto one service is not several tunnels: pass them comma-separated in SEIKAN_DOMAIN and they share one frontend.
Bringing your own token. If you'd rather mint the frontend yourself — from a provisioning script, say — create it over the admin API and pass the token instead of the key:
curl -sS https://admin.example.com/api/frontends \
-H 'Authorization: Bearer sk_...' -H 'Content-Type: application/json' \
-d '{"type":"http","domains":["app.example.com"]}'
# -> {"id":"…","url":"https://app.example.com","token":"sk_t_…"}
Then run the container with SEIKAN_TOKEN=sk_t_… and no SEIKAN_DOMAIN/SEIKAN_TCP_PORT — the token already says which frontend it belongs to. The container makes no admin API calls at all in this mode, which also means it cannot recover if the token is ever invalidated. Setting both variables uses the key, and the token is ignored.
Compose
Server and clients normally run on different machines in production (server on the public host, clients next to the services they expose); this combines them into one file for reference, and shows both frontend types — a web app on a domain and a database on a TCP port — next to the containers they forward to:
services:
# Public tunnel/proxy host. Entrypoint is `seikan-server run`.
# The setup key prints to the logs on first start, unless you supply one:
# docker compose logs server
server:
image: okonomigmbh/seikan:latest
restart: unless-stopped
ports:
- "80:80" # ACME HTTP-01 + HTTP→HTTPS redirect
- "443:443" # HTTPS frontends + admin API
- "10000-10010:10000-10010" # TCP frontend range — keep in sync with SEIKAN_TCP_RANGE
environment:
SEIKAN_ADMIN_HOST: admin.example.com # reserved admin host; point its DNS here
SEIKAN_ACME_EMAIL: you@example.com # Let's Encrypt contact
SEIKAN_SETUP_KEY: pick-something-unguessable # authorizes creating the first admin, once
SEIKAN_TCP_RANGE: "10000-10010" # assignable + requestable public TCP ports (default 10000-20000)
SEIKAN_SKIP_ADMIN_HOST_CHECK: "true" # a container's own interface IP never matches the public DNS record
# SEIKAN_ACME_STAGING: "true" # uncomment while testing (avoids LE rate limits; cert not browser-trusted)
volumes:
- seikan-state:/var/lib/seikan # state DB + autocert cache — must persist
# --- the services being exposed, and one tunnel client per frontend ---
# Neither has a `ports:` mapping: nothing is published on the docker host and
# the tunnel is the only way in. SEIKAN_TARGET is dialled from INSIDE the
# client container, so it is a service name on this network plus the port the
# service listens on there — not a published host port.
app:
image: nginx:alpine # stand-in for your web service
restart: unless-stopped
db:
image: postgres:16 # stand-in for your TCP service
restart: unless-stopped
environment:
POSTGRES_PASSWORD: example
# Web service on a domain: https://app.example.com -> app:80
# DNS for app.example.com must point at the server.
client-app:
image: okonomigmbh/seikan-client:latest
restart: unless-stopped
depends_on: [app]
environment:
SEIKAN_SERVER: admin.example.com # the admin host
SEIKAN_API_KEY: sk_... # client key, issued in the admin UI
SEIKAN_DOMAIN: app.example.com # comma-separate for several; wildcards allowed
SEIKAN_TARGET: app:80 # service name + the port it listens on IN the network
# SEIKAN_VERBOSE: "true" # log one line per request
# SEIKAN_INSECURE: "true" # dev only: skip server TLS verification (staging certs)
# Raw TCP: tcp://admin.example.com:10005 -> db:5432
# The requested port must be inside SEIKAN_TCP_RANGE *and* published by the
# server above, and the client asks for it again on every restart — so the
# address your users point at never changes.
client-db:
image: okonomigmbh/seikan-client:latest
restart: unless-stopped
depends_on: [db]
environment:
SEIKAN_SERVER: admin.example.com
SEIKAN_API_KEY: sk_... # the same client key is fine
SEIKAN_TCP_PORT: "10005" # omit to have the server assign one (it will change on restart)
SEIKAN_TARGET: db:5432
# Exposing something that is NOT in this compose project:
#
# - already in another compose project — leave it there and attach the
# client to that project's network instead of moving the service here:
# networks: [myapp_default] + a top-level
# networks: {myapp_default: {external: true}}
# then SEIKAN_TARGET: app:80 resolves over that network.
#
# - a process on the docker host itself, not containerized — the one case
# for host.docker.internal. Linux needs the mapping added explicitly, and
# a process bound to 127.0.0.1 only is not reachable from the container:
# extra_hosts: ["host.docker.internal:host-gateway"]
# SEIKAN_TARGET: host.docker.internal:3000
#
# Already have a connect token you minted yourself? Pass SEIKAN_TOKEN instead
# of SEIKAN_API_KEY and drop SEIKAN_DOMAIN/SEIKAN_TCP_PORT — the token already
# says which frontend it belongs to.
volumes:
seikan-state:
docker compose up -d server
docker compose logs server # the setup key, if you didn't supply one
If the service you're exposing already runs in its own compose project, don't move it into this file — run the client there, or attach it to that project's network:
client-app:
image: okonomigmbh/seikan-client:latest
networks: [myapp_default] # the other project's network
environment:
SEIKAN_SERVER: admin.example.com
SEIKAN_API_KEY: sk_...
SEIKAN_DOMAIN: app.example.com
SEIKAN_TARGET: app:80
networks:
myapp_default:
external: true
And if the target isn't containerized at all but runs on the docker host, that is the one case for host.docker.internal — with the extra_hosts mapping, which Linux does not provide by default:
client-app:
image: okonomigmbh/seikan-client:latest
extra_hosts:
- "host.docker.internal:host-gateway" # required on Linux; Docker Desktop has it already
environment:
SEIKAN_SERVER: admin.example.com
SEIKAN_API_KEY: sk_...
SEIKAN_DOMAIN: app.example.com
SEIKAN_TARGET: host.docker.internal:3000 # the host process must not be bound to 127.0.0.1 only
Uninstall
Runs the binary's own exec-uninstall:
sudo seikan-server uninstall
seikan uninstall
- Server: removes the systemd service, unit and env file; to also drop the state DB, system user and binary, run the binary directly with
sudo seikan-server uninstall --purge. - Client: removes the binary. To also remove the per-user background service and saved config, run
seikan uninstall --purge.