Hexagon runs Claude Code in Docker containers and puts
them in your browser. You pick a repository and a base image; it clones the repository on
the host, starts a container with that clone mounted inside, runs tmux in it, and renders
that terminal on a web page. Close the tab and the agent keeps working; open it again and
you are back where you left off.
It is one binary — a Go server with the frontend built into it — and it talks to the Docker daemon on the machine it runs on.
A session is a repository, a container and a terminal. Choose an image, then a repository from any account you have connected — or none at all, for a session that starts on an empty workspace and clones something later.
The clone lives on the host, under the workspace directory, and is bind mounted at
/workspace in the container. The container runs as you, not as root, so every file the
agent writes stays yours.
Stopping a session shuts its container down and keeps the clone. Starting it again brings
the container back with a fresh tmux, so the previous scrollback is gone but the work is
not. Deleting removes the container, and removes the clone on disk only if you tick the box.
The session page attaches to the container's tmux. Closing the tab only detaches — Claude
Code keeps going — and reopening the page rejoins the same session. Opening a second tab
takes the terminal over from the first.
A session either opens with claude already running or leaves you at a shell prompt. It is
a checkbox when you create the session, on by default, and a switch on the session page
afterwards; since it is the command tmux was started with, flipping it on a running session
takes effect the next time that session starts. When Claude Code exits you get a shell
rather than a terminal that closes.
The tmux keys button in the header opens the shortcuts worth knowing, starting with how to scroll back through the output.
The Export /workspace button, on the session card and on the session page, downloads
/workspace as a workspace.tar.gz — nothing else: not $HOME, not a compose project's
named volumes. It is read straight from the clone on the host rather than from the
container, so it works for a stopped, failed or gone session exactly as it does for a
running one, and it is never staged on the server — the archive streams straight into the
download and nothing of it is kept once it finishes.
Pressing it checks first, and only offers the download if /workspace actually exists; a
session still being cloned does not offer it at all. A running session is read live, the
same way tar reads any directory that is still being written to: a file created after the
export passed its directory will not be in it, and one being written when the export reaches
it lands however much of it existed at that moment.
A session runs in a container built from a base image you register. There are three kinds:
- A Dockerfile. Hexagon builds it and follows the build log live. The image has to
provide
git,tmuxandclaudeon thePATHand a long-runningCMD; deploy/images/base/Dockerfile is offered as the starting point and is the reference for what a session needs. - A registry reference.
node:22-bookworm-slimand the like, pulled as it is. - A Dockerfile and a compose file. For a project that needs a database, a cache or a queue running beside it.
The repository is never baked into an image: it arrives as a bind mount when the session starts, so one image serves every project that needs the same tools.
Containers run as your host user, whose uid the image cannot know when it is built, so a
claude installed as root cannot update itself. Hexagon bridges that with a fixed group:
every container it runs Claude Code in — a session, the Claude login, the image editor —
gets gid 2000 as a supplementary group, and the reference Dockerfile hands the
Claude Code install to that group:
RUN chgrp -R 2000 /usr/local/lib/node_modules/@anthropic-ai \
&& chmod -R g+w /usr/local/lib/node_modules/@anthropic-ai \
&& chgrp 2000 /usr/local /usr/local/lib/node_modules /usr/local/bin \
&& chmod g+w /usr/local /usr/local/lib/node_modules /usr/local/binThe install is writable by that group only, never by everyone, and node, npm and the
rest of /usr/local stay root's. /usr/local itself is included because Claude Code turns
auto-updates on only when npm's global prefix is writable. The group is numeric, so the
image needs no /etc/group entry for it.
Keep these lines if you write your own Dockerfile and want the same behaviour; an image without them still runs, it just keeps the version it was built with. An image built from the template before this was added has to be rebuilt to pick it up. The update lives in the container, not the image: a session that is recreated starts again from the version the image carries.
The compose file describes the services a session needs next to the agent — Hexagon
supplies the agent's own service, so you never describe it yourself. It is called hexagon,
it starts after everything you listed, and it reaches your services on the project's network
by their service name: a session can talk to a db: service as db.
The file is checked before the image is saved, so a refusal arrives while you are still
looking at the editor. A service is refused if it uses build, bind mounts a host path
(named volumes are fine), fixes a host port, asks for privileged, cap_add,
security_opt or devices, sets network_mode, pid, ipc or uts to host, or runs
as root. deploy/images/base/compose.yaml is the starting
point. This mode needs docker compose on the server; without it, it is not offered.
Stopping such a session stops the whole project, and deleting it takes the project with it — including the named volumes, if you also chose to delete the workspace.
Under each editor there is a box: say what you want changed — "add the Go toolchain", "add a postgres 16" — and Hexagon rewrites the file for you, showing a one-line summary and an undo.
The call runs with every built-in tool removed, so it is a pure text transformation: no shell, no file access, no network of its own. A compose file it writes goes through exactly the same refusals as one you typed by hand.
It signs in the way a session does, and with the same order of preference: your default Claude account, then the key the server was configured with, then the machine-wide login — the file its browser login writes. Configure Claude once and both halves of Hexagon use it.
The call needs the Claude Code CLI, and where the server has no claude binary of its own —
a packaged install has none: the service user has no home to install one into — it runs the
CLI in a container instead, from the same image the browser login uses. That is the same
answer a little slower, plus a wait for that image the first time, and the box says which of
the two you are getting rather than leaving you to wonder why it is thinking. What it needs
either way is a Claude account, from the Accounts page.
Installing claude on the server is still worth it if you use this a lot: put it on the
PATH of the account the server runs as, or name it with claude.binary, and the box uses
it instead.
Every image row has a Backup button, next to Edit, Log and Delete. A dialog offers two
things to save independently — the Dockerfile and compose file, and the image itself — with
at least one required and the image export unavailable until the image is ready. The job
runs on the server; the transfers list below the images shows it moving from running to
ready, with a Download link once it is.
The archive is a plain .tar.gz, openable with nothing but tar:
hexagon-<name>-<timestamp>.tar.gz
├── spec/
│ ├── image.json (name, source type, registry reference, saved tag)
│ ├── Dockerfile (when the Dockerfile/compose half was saved)
│ └── compose.yaml (for a compose image, same condition)
└── image.tar.gz (only when the image export was saved — a plain `docker save | gzip`)
spec/image.json is what a restore reads to know what it is looking at; the two source
files are exactly what was in the image, and image.tar.gz loads with a bare
docker load < image.tar.gz on any machine, Hexagon or not.
Restore, at the top of the Images page, uploads an archive and shows what is in it: the name, the source type, and whether an image is included and roughly how large. From there:
- Load the files into the form does not create anything — it fills in the Dockerfile and compose fields of the create form above, exactly as if you had pasted them, and you press Build image yourself.
- Import the image (offered only when the archive has one) loads it into Docker and wires it into an image row directly — no build, ready as soon as the load finishes. If the name is already taken, you are asked to confirm before it overwrites that image in place: the row keeps its id and its local tag, so every session already using it keeps working off its current container until it is next rebuilt, which is the point at which the restored content takes over.
A staged upload and a finished backup are kept for 24 hours and then removed automatically — download or import what you need before then. Backups and restores in progress or waiting to be collected both show up in the same transfers list.
Every row in that list that holds an archive — a finished backup, or an upload nobody imported — also has a Restore button, so a backup already on the server can be restored without downloading it and uploading it back. It opens the same panel, with the same two choices, and a name field prefilled from the archive's own name: leave it as it is to restore into the image it was taken from (you are asked to confirm the overwrite), or change it to bring the backup back as a copy beside the original.
A repository with a long history is the slow half of creating a session, and most of what
that history costs is never read. Tick Shallow clone when you pick the repository and a
Depth field appears beside it: the clone is made with --depth, keeping that many
commits and no more. The default of 1 is the working tree and nothing behind it.
It is a creation-time choice — the clone happens once — and it comes with git's own rule
attached: --depth fetches the selected branch alone, so the other branches are not in the
clone. Checking one out inside the session means widening the refspec first, with
git remote set-branches origin <name> and a fetch. Leave the box unticked for a session
that moves between branches.
A session can publish container ports on the host: type them as a list — 3000, 5173 — with
the address they bind beside them. Docker picks the host port, so a second session of the
same project never fails to start over a port already taken, and the session page shows each
pair as a link once the container is up.
The address defaults to 0.0.0.0, because published ports are most useful when Hexagon runs
on another machine and a port bound to that machine's loopback is reachable by nobody. The
warning under the field is the whole point of it: on 0.0.0.0 those ports are open to
anyone who can reach the machine, with nothing in front of them — no password, and not
Hexagon's own sign-in. Use 127.0.0.1 to keep them local.
Ports can be changed later, but only while the session is stopped: Ports on its card in the sessions list, or on the session page, edits the list and the address. A container keeps the bindings it was created with, so saving builds a new container from the same image over the same workspace. The clone and the session's home directory survive — they are directories on this machine — and anything installed inside the old container by hand does not.
Tick the box when you create a session and its page gets a VS Code button that opens
code-server on the workspace in a new tab. The editor is not part of any image: one
release is downloaded into the data directory the first time a session asks for it, and
shared by all of them.
Unlike the ports above, this is a creation-time choice and stays one: the mount is part of the container, and there is no equivalent of the rebuild for it.
The Stats page answers the question an operator asks before starting another session: is
there room. Three gauges at the top show the CPU, memory and disk of the machine the server
process runs on; the CPU gauge shows the load average until a second reading lets it compute a
percentage. Below that is the daemon's own accounting — the same figures docker system df
reports — and the full list of images Docker holds, not just the ones Hexagon knows about,
sorted largest first, each tagged In use when a container is based on it and
Registered when a row on the Images page points at it.
Two buttons reclaim disk space, each behind a confirmation naming what it will remove:
- Prune unused images removes an image only if no container is based on it and no image row points at it. An idle image that is still registered is left alone on purpose — it is what a stopped session would otherwise have to rebuild from scratch to start again.
- Prune stopped containers removes every stopped container the daemon holds except the ones Hexagon manages, through a single filtered call — nothing Hexagon created can be touched by this button.
Neither button touches named volumes or the build cache, both of which are reported above them for the same reason as everything else on the page: a size is worth showing even when nothing here offers to reclaim it. Volumes hold a session's own data, and the build cache is what makes the next build fast.
Repositories come from the accounts you connect. GitHub is there already: it is how you signed in.
Bitbucket is added with an Atlassian account email and an API token, created under Atlassian
account settings → Security → API tokens with read:workspace:bitbucket and
read:repository:bitbucket, plus write:repository:bitbucket if Claude Code should push.
Both reads are needed because Bitbucket lists workspaces first and their repositories one
workspace at a time. App passwords are not supported — Atlassian removed them in July 2026.
The GitHub box also takes a personal access token, and it is worth setting. The
credential that arrives with the sign-in expires after a few hours, and a container keeps
the token it was built with, so a session outlives its own ability to fetch and push. A
personal access token does not expire, and sessions created after you paste one use it for
git. Make one under GitHub Settings → Developer settings → Personal access tokens, with the
repo scope for a classic token or read and write access to Contents for a fine-grained
one. It is used for git only — your repositories are still listed with the account you
signed in with — so its scopes can be as narrow as you like.
Credentials are checked against a real API call before they are stored, and sealed at rest. A token that turns out to belong to a different account is refused rather than stored: it would produce a session pushing commits under somebody else's name.
A session created from a repository carries that account's credentials unless you untick the box; a session created without one carries none unless you choose an account for it. The clone on the host uses the credentials either way — it could not reach a private repository otherwise — so the choice is only about what runs inside the container. Without the token nothing in the session can fetch or push.
It cannot be changed afterwards: a container keeps the environment it was created with. That is also why a GitHub session is worth giving a personal access token before it is created — see Accounts. Without one it carries the token from signing in, and when that expires the session can no longer fetch or push at all; the new-session dialog says so.
More than one Claude account can be configured on the Accounts page, without needing a shell
on the server: paste an API key from the Console, the token claude setup-token prints, or
add one that signs in with a subscription — press Log in on it to get a real terminal
running claude auth login. A pasted secret is checked against the CLI before it is stored.
One account is marked default, and a session created naming none gets it; naming another
one instead is a choice in the new-session dialog when there is more than one to choose
between.
A stopped session's account can be changed too — the Account button beside Ports, on its card in the sessions list or on the session page. Changing it rebuilds the container from the same image, over the same workspace, for the same reason changing its ports does: a container keeps the credential it was created with. What it ends up authenticating as is whatever that account holds at the moment of the rebuild, so one that has been signed out of since fails the rebuild rather than quietly producing a container that cannot authenticate.
Each subscription account gets its own directory to sign in to, so two of them can be logged
in to at once without either overwriting the other's credential. A machine-wide login further
down the Accounts page still exists too, for a session that names no account and has no
default configured either: it writes claude.credentials, exactly as it always has, and a
session that resolves to it mounts that file read-only.
Either kind of login runs claude in a throwaway container, which means it needs an image
with Claude Code in it. On a machine where you have not built one yet it uses Hexagon's own —
the same reference image the Images page starts from, built the first time something asks for
it. The dialog says so while that build is running, and offers your own images once you have
any.
Inside a session, claude opens straight into the repository: each session gets its own
$HOME/.claude.json marked as already onboarded and already trusting /workspace, so it
does not greet you with the first-run flow.
The whole configuration file, edited from the browser, grouped as the file groups it, each key labelled with the environment variable that overrides it.
Most settings are read once when the server starts, so the page shows what the process is running on beside what the file now says, and marks the ones waiting for a restart — including changes you made in the file by hand. Only the GitHub client id, client secret and allowlist take effect the moment they are saved.
Nothing is written until the candidate file has been loaded successfully, so a save cannot leave behind a configuration the server would refuse to start from. Everyone the allowlist admits can use the page: that list is also the list of administrators, and it cannot be saved without you on it.
Docker, and a GitHub account. The released binary is static and needs nothing else at
runtime except git, which the package pulls in for you. Building from source needs Go 1.26
and Node 22.
Debian and Ubuntu. Take the .deb from the
latest release and install it with
apt, which pulls its dependencies in — dpkg -i would leave the package half-configured:
curl -fsSLO https://github.com/acamb/hexagon/releases/download/v0.1.0/hexagon_0.1.0_amd64.deb
sudo apt install ./hexagon_0.1.0_amd64.debIt installs /usr/bin/hexagon, a system service running as a dedicated hexagon user, and
/etc/hexagon/config.json. Under DEBIAN_FRONTEND=noninteractive every answer is its
default, which is the wizard below. The package registers a systemd service; on Devuan
or any other Debian without systemd, use the installer below — the package still carries the
OpenRC init script, at /usr/share/doc/hexagon/examples/hexagon.openrc.
Anywhere else, with systemd or OpenRC. The installer downloads the latest release,
checks it against the release's SHA256SUMS, and sets up a service under whichever of the
two this machine runs. Download and run it.
curl -fsSLO https://raw.githubusercontent.com/acamb/hexagon/master/install.sh
sh install.shIt installs for you by default — ~/.local/bin, a service running as your account, with
your ~/.claude and your docker group — and offers a system-wide install:
sh install.sh --system, --yes for every default, and
--version v0.1.0 to pin a release. Piping it into sh works too and takes every default,
which is the unattended form.
Per-user means systemctl --user under systemd, and an OpenRC user service in
~/.config/rc/init.d (OpenRC 0.55 and newer) otherwise. OpenRC keeps a user's services in
that user's own session, so to have yours start at boot the installer prints the one root
command that arranges it — the counterpart of loginctl enable-linger. With neither init
system, the files go in and the installer says how to run the server yourself.
From source, which is also how you work on Hexagon:
make build && ./bin/hexagonmake dev runs the Go server on :8080 and the Vite dev server on :5173, which proxies
the API to it. Open http://localhost:5173 — not 127.0.0.1:5173: the origin check
compares hostnames, and the two are different origins. AGENTS.md has the
conventions.
Whichever path you took, the account the server runs as must be in the docker group —
the package puts the hexagon user there, and the installer tells you if you are not. It is
root-equivalent access to the machine, which is what running containers requires.
Both installers ask, as their first question, whether to configure Hexagon now or leave it to the wizard. The default is the wizard, and the rest of this section assumes it.
They ask for the listen address and the public URL either way, because the wizard is
a page in a browser and can only be reached where the server binds: the default,
127.0.0.1:8080, is unusable on a machine you reach over the network, and nothing in the
browser can move it — the address is read when the server starts. If the pair you give would
publish Hexagon in plaintext (an address open to the network and a public URL that is not
https) they ask once more, because whoever reaches that port controls the Docker socket and
the session cookie would travel in the clear. Saying yes writes insecureHttp; saying no
leaves the server refusing to start until the two agree, which is the same refusal it makes
on its own.
You can skip this: the first-time wizard asks for these values in the browser and shows the exact callback URL for the public URL you gave it. Doing it first, at https://github.com/settings/developers → New OAuth App:
| Field | Value |
|---|---|
| Application name | Hexagon |
| Homepage URL | http://127.0.0.1:8080 |
| Authorization callback URL | http://127.0.0.1:8080/api/auth/callback |
Then Generate a new client secret. The callback URL has to match exactly: GitHub
compares it verbatim against what the server sends, so it has to be built from the address
you actually open — http://localhost:5173/api/auth/callback when you run make dev.
Signing in asks GitHub for the repo scope, because that is what Claude Code needs to clone
and push.
Started with no GitHub client id and secret, Hexagon does not refuse to run. It logs a line like
WARN first-time setup is open until somebody signs in url=http://127.0.0.1:8080/setup password=K7QX-4M2A-...
As a service that line is in the journal, or — OpenRC having no journal — in the log file the init script names, which the installer prints when it finishes:
journalctl -u hexagon | grep 'first-time setup' # systemd, the package or --system
journalctl --user -u hexagon | grep 'first-time setup' # systemd, the installer's default
grep 'first-time setup' /var/log/hexagon.log # OpenRC, --system
grep 'first-time setup' ~/.local/state/hexagon/hexagon.log # OpenRC, per-userThe server serves a wizard at that address which asks for the password, then for the client
id, the client secret and the accounts allowed to sign in. It shows the exact callback URL
to register on GitHub, writes everything into the configuration file — creating it with mode
600 if it is not there — and reconfigures the running server, so signing in works without
a restart.
The password lives only in that process's memory: a restart prints a new one and retires the old. The wizard stays open until somebody signs in successfully, so a client secret with a typo can be fixed from the same page. After the first sign-in it closes for good.
sudo apt remove hexagon # or: sudo apt purge hexagon
sh uninstall.sh # what install.sh put there; --system for a system installuninstall.sh finds the layout and the init system by itself, so it takes no arguments
beyond --system. Both keep the session workspaces — git clones that may carry commits
nobody pushed — and print where they are. apt purge also removes /etc/hexagon and the database, and the
installer asks about both, defaulting to keeping them.
Hexagon has no TLS of its own. To reach it from anywhere but the machine it runs on, leave it bound to loopback and put a reverse proxy in front: deploy/proxy/ is an nginx configuration known to work, with a compose file and the two values to change.
The server refuses to start on an address the network can reach unless its public URL is
https, so serving it in plaintext is something you have to say out loud rather than
something you can do by accident.
Every setting can come from a JSON configuration file or from the environment, and has a default that suits a single user on a developer machine. The environment wins over the file, which wins over the defaults.
| Variable | File key | Default | |
|---|---|---|---|
HEXAGON_CONFIG |
— | ~/.config/hexagon/config.json |
Where the configuration file is; -config <path> overrides it |
HEXAGON_ADDR |
addr |
127.0.0.1:8080 |
The interface and port the server binds |
HEXAGON_PUBLIC_URL |
publicUrl |
http://127.0.0.1:8080 |
The origin your browser uses; the OAuth callback and the origin check derive from it |
HEXAGON_INSECURE_HTTP |
insecureHttp |
— | Set to anything to serve a non-loopback address without https, which the server otherwise refuses to do |
HEXAGON_DATA_DIR |
dataDir |
~/.local/share/hexagon |
Database and secret key |
HEXAGON_WORKSPACE_ROOT |
workspaceRoot |
<data dir>/workspaces |
One directory per session, holding its workspace |
HEXAGON_GITHUB_CLIENT_ID |
github.clientId |
— | From the first-time wizard when it is not set |
HEXAGON_GITHUB_CLIENT_SECRET |
github.clientSecret |
— | From the first-time wizard when it is not set |
HEXAGON_ALLOWED_USERS |
github.allowedUsers |
— | Who may sign in: comma-separated in the environment, a JSON array in the file. A number is a GitHub account id, anything else a login — prefer ids, since a login is released when an account is renamed and can then be claimed by somebody else |
HEXAGON_GITHUB_API_URL |
github.apiUrl |
https://api.github.com |
Override for GitHub Enterprise |
HEXAGON_BITBUCKET_API_URL |
bitbucket.apiUrl |
https://api.bitbucket.org/2.0 |
Override |
HEXAGON_SECRET_KEY |
secretKey |
<data dir>/secret.key |
32 bytes, base64. Generated on first run |
HEXAGON_DEBUG |
debug |
— | Set to anything for debug logging; from the settings page it applies without a restart |
HEXAGON_CLAUDE_CREDENTIALS |
claude.credentials |
~/.claude/.credentials.json |
Mounted read-only into a session that resolves to no Claude account. Empty disables the mount. Also the file the machine-wide login writes |
ANTHROPIC_API_KEY |
claude.anthropicApiKey |
— | Handed to a session that resolves to no Claude account |
HEXAGON_CLAUDE_BINARY |
claude.binary |
claude on PATH, then ~/.local/bin/claude |
Rewrites a Dockerfile or a compose file from the Images page. Without one, that runs in a container instead |
HEXAGON_CLAUDE_MODEL |
claude.model |
— | Model for that call; empty leaves the choice to the CLI |
HEXAGON_GIT_USER_NAME |
git.userName |
— | Git identity for clones and for commits made inside containers |
HEXAGON_GIT_USER_EMAIL |
git.userEmail |
— | |
HEXAGON_VSCODE_DIR |
vscode.dir |
<data dir>/code-server |
Where the code-server release is kept, downloaded once for the machine |
HEXAGON_VSCODE_VERSION |
vscode.version |
the pinned version | Release fetched when the directory is empty |
DOCKER_HOST |
docker.host |
SDK default | Docker Engine endpoint |
HEXAGON_DOCKER_CLI |
docker.cli |
docker on PATH |
Runs docker compose for images that carry a compose file |
HEXAGON_MAX_SESSIONS_PER_USER |
limits.maxSessionsPerUser |
20 |
Sessions one account may have at once |
HEXAGON_MAX_CONCURRENT_BUILDS |
limits.maxConcurrentBuilds |
2 |
Image builds one account may have in flight |
HEXAGON_PUBLIC_RATE_PER_MINUTE |
limits.publicRatePerMinute |
60 |
Requests a minute, per client address, to the routes that answer without a session |
config.example.json is a complete one. Copy it to
~/.config/hexagon/config.json, or keep it anywhere and point -config at it. The server
looks for -config, then HEXAGON_CONFIG, then the default path. A packaged install is
told about /etc/hexagon/config.json through HEXAGON_CONFIG in its unit, and the
examples land in /usr/share/doc/hexagon/examples/.
Five things are worth knowing:
- A file named with
-configorHEXAGON_CONFIGmust exist. Ignoring a path you asked for would start the server configured by accident. The default location is optional. - It must not be readable by other users. It can hold the OAuth client secret, an API
key and the key that seals stored tokens, so the server refuses a file with wider
permissions than
600. - The directory has to be writable too, for the Settings page and the first-time wizard
to save: the file is replaced by writing a new one beside it and renaming it, which never
leaves a half-written configuration or a moment with wider permissions. The packages give
/etc/hexagonto thehexagonuser for exactly this. Both pages say so when they find it otherwise, rather than failing at the save. - An unknown key is an error. A misspelled
allowedUserswould otherwise be dropped in silence, and a dropped allowlist is an authentication bypass. claude.credentialsis the one key where an empty string means something: mount nothing. Remove the key to go back to the default path.
Almost all of it can also be edited from the Settings page. The one exception is the secret key: a wrong value there could not be corrected from the page that wrote it, because it would make every stored token undecryptable.
The listen address can be edited there, and it is the one setting whose mistake the page cannot undo. It is read when the process starts, so a bad value is saved, marked as waiting for a restart, and correctable right up until that restart — after which the page is behind a port nobody can reach, and the only way back is the file.
Hexagon drives the Docker socket and holds credentials for your repositories. Anyone who can reach it and hold a session can run anything on the machine it runs on. These are the properties it maintains, and the ones it deliberately does not.
One instance is meant for one person, and that person owns the machine it runs on. The allowlist and the per-user scoping keep unauthenticated callers out and keep the data model honest; they are not a sandbox between two people who have both signed in. A session drives the Docker socket, so whoever holds one already controls the host — signing in is not a privilege boundary between people you would not hand a shell. Put such people on their own instance. What the container does wall off is the code a session runs — an untrusted repository, or the autonomous agent — from the host, which is why it runs as you and never as root.
- There is no way in without signing in. Every endpoint except the health check, the login handshake and the first-run wizard requires a session, the terminal WebSocket included. Admission is re-checked against the allowlist on every request, not only at login, so removing somebody takes effect immediately; their existing sessions are deleted at the next startup.
- It binds loopback by default and should stay there. To publish it, put a TLS reverse
proxy in front and leave the bind where it is. The server refuses to start on an address
the network can reach unless
publicUrlis https, orinsecureHttpsays the plaintext is deliberate. - Containers run as you, never as root, and a user-supplied compose file is screened
before anything is created from it — no
privileged, no added capabilities, no host namespaces, no host bind mounts, no fixed host ports, nobuild, no service running as root — against Docker's own normalized view of the file. Treat the screen as a guard-rail against a careless compose file, not a sandbox around a hostile one: the Compose format has other routes to the host it does not yet model (a named volume backed by a bind driver, a host network defined at the top level,device_cgroup_rules,env_file, and more). Since the file's author is the instance's own operator — see the one-person rule above — tightening it further is defense in depth, not a trust boundary. The container user is the user the server runs as, so the server refuses to start as root — a root server would give every session container root, which is the boundary against the code it runs turned off. SetHEXAGON_ALLOW_ROOTonly on a host that genuinely has no unprivileged user to run as. - A published session port has nothing in front of it. Neither has code-server on its own port. Hexagon's sign-in guards the API and the VS Code proxy, not a port you asked it to publish — which is why the address field carries a warning. code-server's own port always stays on loopback, whatever a session chose for its own.
- Tokens are sealed at rest with AES-256-GCM under the server key, and never leave the server: no response ever carries one. Only the SHA-256 of a session cookie is stored.
- Session cookies are
HttpOnlyandSameSite=Lax. Over https the cookie isSecureand named__Host-hexagon_session, a prefix the browser enforces so no sibling subdomain can overwrite it over plaintext. - A mutating API call is refused unless it shows a same-origin signal — a matching
Origin, orSec-Fetch-Site: same-origin— and a JSON content type. - The routes that answer without a session are rate limited per client address, and the
health check tells an unauthenticated caller only that the server is up. Behind the proxy
the address comes from
X-Forwarded-For, believed only because the peer is loopback; exposed directly, the header is ignored. - The
reposcope is broad, and that token is handed to a container running an autonomous agent. That is the deliberate trade-off of the project, and the reason it can be declined per session. A session gets one account's credentials at most, chosen when it is created, and never another's.





