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:

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:

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