← tmux-next EN · 中文 · GitHub

Deploy with Caddy, and add a login

tmux-next has no auth of its own and binds only to 127.0.0.1. To reach it from your phone or the internet, put a reverse proxy in front to do two things: HTTPS, and a login. This guide uses Caddy — it obtains and renews certificates automatically, and the config is short.

Understand the risk first. Anyone who can reach this service can run commands on your machine — it hands out a shell. Treat access to it like SSH access: auth is mandatory, the password must be strong, HTTPS only.

1Before you start

2Generate two things

A hash of your login password, and a random secret for the WebSocket (the next step explains why it is needed):

# password hash — put this in the Caddyfile, never a plaintext password
caddy hash-password --plaintext 'your-strong-password'

# random secret for the WebSocket — generate one and keep it
openssl rand -hex 24

The first prints a $2a$… bcrypt hash; the second prints a random hex string. You will use both shortly.

3Write the Caddyfile

Here is a complete, working config. Replace the three placeholders with your own values:

tmux.example.com {
    # WebSocket: authenticated by cookie instead — see the next step for why
    @ws path /ws
    handle @ws {
        @noauth not header Cookie *tn_auth=your-random-secret*
        respond @noauth 403
        reverse_proxy 127.0.0.1:7682
    }

    # everything else: Basic Auth, and on success plant the cookie
    handle {
        basic_auth {
            your-username $2a$…your-password-hash
        }
        header +Set-Cookie "tn_auth=your-random-secret; Path=/; Secure; HttpOnly; SameSite=Strict"
        reverse_proxy 127.0.0.1:7682
    }
}

The three placeholders: tmux.example.com becomes your domain; both copies of your-random-secret become the string from openssl in step 2 (they must match); your-username and $2a$… become your login name and the password hash from step 2.

4Why the WebSocket needs its own rule

This is the easiest thing to get wrong, and the easiest way to leave a hole. The terminal's live data goes over a WebSocket (the /ws path), and a browser sends no Basic Auth header on a WebSocket handshake. So if you protect the whole site with Basic Auth alone, the pages look locked, but /ws can't connect because it never receives the credentials — or worse, you open /ws up to make it work, and the terminal is wide open to anyone.

The config above sidesteps that: once a normal request passes Basic Auth, the response plants a cookie; a browser does send cookies on a WebSocket handshake, so the /ws block admits it by cookie. That is why the random secret must stay secret — it is effectively a key, and the Secure and HttpOnly flags keep it to HTTPS and out of reach of scripts.

5Start it and verify

caddy reload --config /path/to/Caddyfile

Then check three things from the command line (swap in your domain):

# pages require auth → expect 401
curl -so /dev/null -w "%{http_code}\n" https://tmux.example.com/

# WebSocket with no cookie → expect 403
curl -so /dev/null -w "%{http_code}\n" https://tmux.example.com/ws

# with the right cookie → expect 400 (passed auth, reached the service)
curl -so /dev/null -w "%{http_code}\n" \
  -H "Cookie: tn_auth=your-random-secret" https://tmux.example.com/ws

401 and 403 prove both doors are locked; the cookie request returning 400 is expected — that's the service saying "this isn't a valid WebSocket handshake," but the request reached it, which means auth let it through. All three correct, and you can open the domain on your phone and log in.

Different machine or domain? This config doesn't depend on any particular system. As long as tmux-next listens on 127.0.0.1:7682 somewhere and Caddy can resolve your domain, copy it as is. For start-on-boot (launchd / systemd) and more, see deploy.md in the repo.

6Advanced: a login page and tokens that expire

The Basic Auth above is rough in two known ways: the login box is the browser's native popup, which can't be styled; and the cookie secret is static and never expires, so a leak means editing the config by hand. For a proper login page plus tokens that expire on their own, use the caddy-security plugin — it solves both.

It isn't in standard Caddy; build a version that includes it with xcaddy:

xcaddy build --with github.com/greenpau/caddy-security

The config is then three parts. First generate a signing key (the JWT is signed and verified with it; a leak means anyone can forge a login):

openssl rand -hex 32

1. The global block — defines the user store, the login portal, and the authorization policy:

{
    # these two order the plugin's middleware correctly (copy as is, unrelated to whether the site uses basicauth)
    order authenticate before respond
    order authorize before basicauth

    security {
        # user store: passwords are bcrypt hashes, never plaintext
        local identity store localdb {
            realm local
            path /path/to/users.json
            user your-username {
                name Your Name
                email you@example.com
                password "bcrypt:10:$2a$10$…your-password-hash"
                roles authp/user
            }
        }

        # login portal: issues a JWT that expires
        authentication portal myportal {
            crypto default token lifetime 28800   # token lifetime in seconds (8 hours here)
            crypto key sign-verify your-signing-key
            enable identity store localdb
            # share the JWT cookie across the auth. and tmux. subdomains
            cookie domain example.com
        }

        # authorization policy: verify the JWT, send anyone without one to the portal
        authorization policy tmuxpolicy {
            # across subdomains this must be an absolute URL to the portal
            set auth url https://auth.example.com/auth/
            crypto key verify your-signing-key
            allow roles authp/user
        }
    }
}

2. The portal site block — this hosts the custom login page:

auth.example.com {
    route /auth* {
        authenticate with myportal
    }
}

3. The tmux-next site block — protected by the policy, redirecting to the login page when unauthenticated:

tmux.example.com {
    # every path (WebSocket included) requires a valid JWT first
    authorize with tmuxpolicy
    reverse_proxy 127.0.0.1:7682
}

Why no separate WebSocket rule this time? The JWT lives in a cookie, and a browser does send cookies on a WebSocket handshake — same idea as the cookie in step 4, except the cookie now holds a signed, expiring JWT rather than a static secret. authorize checks every path uniformly, /ws included, so there's nothing extra to write.

Keep the two keys straight, and secret. A leaked signing key (crypto key) lets anyone forge any login; the password hash is made with caddy hash-password and goes into password prefixed with bcrypt:10:. When the token lifetime is up you log in again — which is exactly why it beats a static cookie: a captured token only works until it expires.

caddy-security can also do GitHub / Google login (OAuth) and two-factor auth (TOTP / Yubikey). Given this service hands out a shell, 2FA is well worth it. Full options are in its documentation.