What this is: the runbook I used to stand up my own chat server and put an agent on it. Mostlymatter 11.10.0 (a fork of Mattermost that removes the user limit) plus PostgreSQL 16, both in Docker on a Windows machine, TLS and a proxy in front, then one bot account and one token per agent. My server reports
11.10.0build3915213as I write this.Placeholders:
yourdomain.com,D:\Docker\mattermost,MM_DB_PASSWORD. Swap in your own values. No LAN addresses, hostnames, or tokens from my network appear below.Verified here: every command, setting, and response in this post was run against my live server on 2026-09-09, or read back off its API. Steps that come from vendor documentation instead of my own box are labeled and linked.
Follow it top to bottom, or hand it to an AI agent to work through (the “Hand it to an agent” section at the end). Three prerequisites are yours to supply and are named as such: Docker Desktop with the WSL2 backend, an Nginx Proxy Manager instance that can join the compose network, and the agents you intend to point at the server.
I run six Hermes agents, one inside each of my computers. Each one is pointed at either a cloud API or a model served on my own hardware, depending on the task. This chat server is how I talk to them.
That is the whole reason it exists. I wanted one place to reach every agent from a browser on any device, with no third party in the middle and no per-seat bill. Mattermost is the obvious open source answer, and a bot account is the obvious way to give a non-human a name, an avatar, and a token of its own.
Two things pushed me off the stock build:
- The free tiers now carry caps. Mattermost’s free Enterprise mode, Entry, lists a 10,000-message channel history limit and is aimed at teams under 50 users (editions and offerings). I did not want a message-history cutoff on my own server.
- Mostlymatter removes the limit. Framasoft’s fork describes itself in one line: “a fork of Mattermost without users limit” (packages.framasoft.org, source). Their README is refreshingly specific about what they changed: they “multiplied the limits in
server/channels/app/limits.goby 1,000,” the patch is one file,limitless.patch, and it touches the backend binary only.
Read that README before you commit to the fork. Framasoft is equally specific about what they will not do: no SSO fixes, no contributions beyond the limit patch, and they warn they do not have the time or the Go skills to maintain anything heavier. For a family and a small fleet of agents, that trade is fine. For a company with SSO requirements, it is not.
What the finished stack looks like
| Component | Version | Role |
|---|---|---|
| Mostlymatter | 11.10.0 | Chat server (Mattermost Team Edition 11.10.0 base, fork binary) |
| PostgreSQL | 16 | Message store |
| Docker Desktop | current, WSL2 backend | Runs both containers on Windows |
| Nginx Proxy Manager | current | TLS termination, WebSocket pass-through |
| Cloudflare | DNS proxy | Public DNS and edge |
| Hermes agents | six | One per computer, each with its own bot account |
The server ends up reachable at https://chat.yourdomain.com, with the application port bound to 127.0.0.1 only. Nothing on the LAN talks to port 8065 directly.
Docker Desktop with the WSL2 backend is a prerequisite, not part of the build.
Installing it is out of scope here; follow Docker’s own Windows install guide
and confirm docker compose version answers before Phase 1. The same goes for
the proxy in Phase 4: an existing Nginx Proxy Manager instance attached to an
external Docker network. Standing one up is out of scope, and
nginxproxymanager.com/guide/ is the reference.
Acceptance criteria. I wrote these down before I started, and I checked every one against the live server. The measured column is what my box reports today.
| Criterion | Target | Measured |
|---|---|---|
| Web UI over HTTPS | 200 at the site URL | 200, server: cloudflare, HSTS present |
| App port not on the LAN | bound to loopback only | 127.0.0.1:8065 |
| Database and file store healthy | ping reports OK | database_status: OK, filestore_status: OK |
| Self-signup closed | open server off, email signup off | both false |
| Bot accounts possible | bot creation enabled | true |
| Agents can authenticate | each bot token returns its own identity | is_bot: true |
Phase 1: Build the Mostlymatter image
The fork ships only the server binary. You put it on top of the stock Mattermost Team Edition image.
Grab the binary and its checksum from the vendor page, then verify. The vendor page gives you two ways to do that, and I quote both verbatim:
# SHA512 (simplest)
curl -LO https://packages.framasoft.org/projects/mostlymatter/mostlymatter-amd64-v11.10.0
curl -LO https://packages.framasoft.org/projects/mostlymatter/mostlymatter-amd64-v11.10.0.sha512
sha512sum -c mostlymatter-amd64-v11.10.0.sha512
rm mostlymatter-amd64-v11.10.0.sha512
# minisign (if you have it installed)
minisign -Vm mostlymatter-* -P RWSoFpHRlDgrwN2JmiT+JoV2nQBDzvhOgk4GGdmpzGRuhSRD4/F81JX3
rm mostlymatter-*.minisig
Both commands come from the top of packages.framasoft.org/projects/mostlymatter. Do not skip them. You are replacing a server binary that will hold every message you write.
Put the verified binary in a mm/ directory next to your compose file and build with this Dockerfile.mm:
# Base: stock Mattermost Team Edition, pinned to the matching version.
FROM mattermost/mattermost-team-edition:11.10.0
# Mostlymatter 11.10.0 binary, checksum-verified before the build.
# Vendor: https://packages.framasoft.org/projects/mostlymatter/
COPY mm/mostlymatter-amd64-v11.10.0 /mattermost/bin/mattermost
The stock image path is /mattermost/bin/mattermost, which is the same path the fork’s own systemd instructions replace on a bare metal install. The build is a one-line overlay.
docker build -f Dockerfile.mm -t mattermost-mm:11.10.0 .
Pin the tag. Do not use latest. If you build this stack once and then let an unattended update pull a new base image under you, you get a server that changed version and a fork binary that no longer matches it.
Acceptance check: docker images mattermost-mm shows your tag, and docker run --rm mattermost-mm:11.10.0 /mattermost/bin/mattermost version prints 11.10.0.
Phase 2: Compose the stack
Two services: the app and PostgreSQL 16. Work in a directory with room for the volumes, for example D:\Docker\mattermost. Create a .env file beside the compose file, because the database password does not belong in version control:
# .env (chmod 600, never commit)
MM_DB_PASSWORD=<long random string>
Here is the compose file my container runs. Read the comments before you copy it, because a few of these settings are load-bearing and a couple of them are the exact reason a first boot fails.
name: mattermost
services:
mattermost-db:
image: postgres:16
container_name: mattermost-db
restart: unless-stopped
mem_limit: 768m
environment:
POSTGRES_USER: mm
POSTGRES_PASSWORD: ${MM_DB_PASSWORD}
POSTGRES_DB: mattermost
volumes:
- mm-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mm -d mattermost"]
interval: 10s
timeout: 5s
retries: 10
networks:
- default
mattermost:
build:
context: .
dockerfile: Dockerfile.mm
image: mattermost-mm:11.10.0
container_name: mattermost
restart: unless-stopped
depends_on:
mattermost-db:
condition: service_healthy
mem_limit: 1536m
read_only: true
tmpfs:
- /tmp
- /var/tmp
security_opt:
- no-new-privileges:true
ports:
- "127.0.0.1:8065:8065"
volumes:
- mm-config:/mattermost/config
- mm-data:/mattermost/data
- mm-logs:/mattermost/logs
- mm-plugins:/mattermost/plugins
- mm-client-plugins:/mattermost/client/plugins
- mm-bleve:/mattermost/bleve-indexes
environment:
# database
MM_SQLSETTINGS_DRIVERNAME: postgres
MM_SQLSETTINGS_DATASOURCE: "postgres://mm:${MM_DB_PASSWORD}@mattermost-db:5432/mattermost?sslmode=disable&connect_timeout=10"
# identity
MM_SERVICESETTINGS_SITEURL: "https://chat.yourdomain.com"
MM_SERVICESETTINGS_ENABLEOPENSERVER: "false"
MM_SERVICESETTINGS_ENABLEUSERCREATION: "true"
# tokens and bots (Phase 6 depends on both of these)
MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS: "true"
MM_SERVICESETTINGS_ENABLEBOTACCOUNTCREATION: "true"
# uploads
MM_FILESETTINGS_MAXFILESIZE: "104857600"
# privacy and noise
MM_TELEMETRY_ENABLE: "false"
MM_SERVICESETTINGS_ENABLEDIAGNOSTICS: "false"
MM_SERVICESETTINGS_ENABLESECURITYFIXALERT: "false"
MM_SERVICESETTINGS_ENABLEGIFPICKER: "false"
MM_SERVICESETTINGS_ENABLEPOSTUSERNAMELOCATION: "false"
MM_EMAILSETTINGS_SENDEMAILNOTIFICATIONS: "false"
MM_EMAILSETTINGS_ENABLESIGNUPWITHEMAIL: "false"
MM_TEAMSETTINGS_ENABLECREATION: "false"
MM_PLUGINSETTINGS_ENABLEMARKETPLACE: "false"
MM_PLUGINSETTINGS_ENABLEREMOTEMARKETPLACE: "false"
MM_PLUGINSETTINGS_AUTOMATICPREPACKAGEDPLUGINS: "false"
# Docker Desktop host gateway, only if a container must reach a service on the Windows host
MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS: "192.168.65.254"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8065/api/v4/system/ping"]
interval: 15s
timeout: 5s
retries: 10
start_period: 60s
networks:
- default
- npm_net
volumes:
mm-db-data:
mm-config:
mm-data:
mm-logs:
mm-plugins:
mm-client-plugins:
mm-bleve:
networks:
default:
name: mattermost_default
npm_net:
external: true
Four notes on the parts people get wrong:
MM_SQLSETTINGS_*, not the old names. On 11.x, MM_DB_HOST / MM_DB_USER / MM_DB_PASSWORD are ignored. The server starts, looks for a local Postgres, and fails. Set MM_SQLSETTINGS_DRIVERNAME and MM_SQLSETTINGS_DATASOURCE.
/var/tmp matters as much as /tmp. With read_only: true the container filesystem cannot be written. Mattermost’s local mode wants a socket under /var/tmp, so a tmpfs for /tmp alone still leaves you with a boot loop. Mount both.
npm_net is the network shared with the proxy. The app port is published to 127.0.0.1 for local debugging, but the proxy reaches the container by name over npm_net. Create that network before the first up:
docker network create npm_net
The health check needs curl in the image. The stock Team Edition image has it. If your base does not, replace the test with a wget --spider or a shell check against the port.
Start it:
docker compose up -d
docker compose ps
Acceptance check: docker compose ps shows mattermost as Up (healthy) and mattermost-db as Up (healthy). If the app is restarting, read the last 50 lines of docker compose logs mattermost before you change anything else.
Phase 3: First boot and the admin account
Open http://127.0.0.1:8065 from the Windows host, or put the proxy in front first and use the real URL. The first account you create on a fresh database becomes the system administrator. After that the signup form is closed, because EnableOpenServer is false and email signup is off.
Two settings I set at this point rather than trusting the environment variables:
- System Console, Site Configuration, Users: confirm
Enable open serveris off. - System Console, Integrations, Integration Management: confirm
Enable bot account creationandEnable personal access tokensare both on. Phase 6 needs them.
The environment variables above set all three on my box, and the API agrees:
EnableOpenServer = 'false'
EnableUserCreation = 'true'
EnableSignUpWithEmail = 'false'
EnableBotAccountCreation = 'true'
EnableUserAccessTokens = 'true'
Those five lines came back from GET /api/v4/config/client?format=old on my server, not from the compose file. The System Console is the source of truth if the two ever disagree.
Acceptance check: the login page loads, your admin account can sign in, and a private browser window does not offer you a signup form.
Phase 4: Put TLS in front
The app is on loopback. The proxy publishes 443.
In Nginx Proxy Manager, add a proxy host:
| Field | Value |
|---|---|
| Domain | chat.yourdomain.com |
| Scheme | http |
| Forward hostname | mattermost |
| Forward port | 8065 |
| Block common exploits | on |
| Websockets support | on |
| Access list | none needed |
Point the proxy host’s network at npm_net so it can resolve mattermost by name. Issue a Let’s Encrypt certificate in the same dialog, force SSL, and turn on HSTS once you have confirmed the site works over HTTPS.
Two proxy details that will bite you:
- WebSockets must pass through. Mattermost’s client holds a WebSocket for real time events. Without the
UpgradeandConnectionheaders the UI loads, the channel list renders, and messages never arrive until you refresh. Nginx Proxy Manager’s “Websockets support” toggle handles it. - Proxy upload limit must be at least the app limit. The app caps uploads at 100 MB (
104857600on my server). If your proxy defaults to 1 MB, uploads fail at the proxy with a 413 before Mattermost ever sees them. Setclient_max_body_sizeabove the app cap.
Note (unknown MM_* variables are ignored in silence). Mattermost
ignores an environment variable it does not recognize, with no warning and no
log line. Mistake 1 in the list at the end is that failure in its purest form.
Never conclude a setting applied because your compose file says so: read it
back from the API in the acceptance check above, and do the same for the
privacy block, key by key. The read-back is the only proof.
In Cloudflare, create the A or CNAME record for chat.yourdomain.com and proxy it. If you script anything against the API through Cloudflare, send a normal browser User-Agent. A scripted request with a bare curl or Python user agent got a 403 with error code: 1010 from the edge on my first probe, which is Cloudflare’s browser-integrity block, not Mattermost refusing you.
Acceptance check: curl -sS https://chat.yourdomain.com/api/v4/system/ping?get_server_status=true returns:
{"ActiveSearchBackend":"database","database_status":"OK","filestore_status":"OK","root_status":false,"status":"OK"}
Phase 5: Verify the build
Run this before you invite anyone or create a bot. It catches version drift, a wrong database, and a locked-down setting that silently did not apply.
curl -sS -o /dev/null -D - https://chat.yourdomain.com/ | grep -iE '^(HTTP|x-version-id|server)'
On my server that returns HTTP/2 200, x-version-id: 11.10.0.3915213..., and server: cloudflare. The version in that header is the running server version, which is how I confirm the fork binary is the build I think it is.
Then read the effective configuration back with an admin token:
curl -sS -H "Authorization: Bearer $MM_TOKEN" \
https://chat.yourdomain.com/api/v4/config/client?format=old \
| python3 -c 'import json,sys; c=json.load(sys.stdin); print({k:c[k] for k in ("Version","SiteURL","EnableOpenServer","EnableBotAccountCreation","MaxFileSize")})'
My output:
Version: 11.10.0
BuildNumber: 3915213
SiteURL: https://chat.mytrapp.com
EnableOpenServer: false
EnableBotAccountCreation: true
MaxFileSize: 104857600
If EnableBotAccountCreation is false here, stop and fix it before Phase 6. The bot dialog will not appear in the UI, and the API will refuse you.
Phase 6: Bot accounts for the agents
A Mattermost bot account is a user account that is marked as a bot. It has a username, a display name, an avatar, and a token. It cannot sign in through the login page. The bot accounts reference is short and worth reading; the two properties I care about are that a bot posts under its own name, and that its token can be revoked without touching anything else.
One bot per agent. Six agents, six bots. That gives me six separate tokens, so a leaked or rotated token affects exactly one agent, and the message history shows which agent said what.
Create a bot in the UI
- Sign in as the system administrator.
- Open the product menu (the grid icon, top left) and choose Integrations.
- Under Bot Accounts, choose Add Bot Account.
- Fill in the username, display name, and description. Usernames must be lowercase and are the agent’s public identity, so name them for the machine or the job, not for a token.
- Leave the role as Member. A bot does not need system administrator to post in a channel.
- Create it, then copy the token from the confirmation dialog immediately. It is shown once. If you lose it, mint a new one; you cannot read the old one back.
The bot appears in the user list like any other account, which means you can invite it to a team and add it to channels the same way you would a person.
Create a bot with the API
Same thing without the clicking. This call needs a token belonging to a human system administrator:
curl -sS -X POST https://chat.yourdomain.com/api/v4/bots \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"agent-one","display_name":"Agent One","description":"Agent on the workshop PC"}'
The response includes the new bot’s user_id. Keep it; that is the identifier you pass to the token call.
Here is the part I got wrong the first time, and it is worth stating plainly: a bot token cannot create bots. I tried it and got:
403 You do not have the appropriate permissions.
That is intentional in the API design, and it is good news. A compromised agent token cannot mint new identities on your server. Use a human admin token or the UI for creation, and use the bot’s own token for everything after that.
Mint the token
If you created the bot through the API, create its token in the next call. The user id in the path is the bot’s user id:
curl -sS -X POST https://chat.yourdomain.com/api/v4/users/$BOT_USER_ID/tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"description":"agent-one gateway"}'
The response is the only time you will see the token:
{"id":"<token id>","token":"<the token>","user_id":"<bot user id>","description":"agent-one gateway","is_active":true}
Two details from my run of this call that the docs do not shout about: it returns 200, not 201, so a script that checks for 201 will treat success as failure and skip the cleanup. And the id field is what you keep for revocation. Store the token in the agent’s own secret store, never in the compose file and never in this chat server’s message history.
Give the bot a team and a channel
Invite the bot to your team, then add it to the channel where you want it to live. In the UI, team menu, Invite People, search the bot’s username. The API equivalents are:
# add to team
curl -sS -X POST https://chat.yourdomain.com/api/v4/teams/$TEAM_ID/members \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"team_id\":\"$TEAM_ID\",\"user_id\":\"$BOT_USER_ID\"}"
# add to channel
curl -sS -X POST https://chat.yourdomain.com/api/v4/channels/$CHANNEL_ID/members \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"user_id\":\"$BOT_USER_ID\"}"
You can copy the channel id from the channel’s View Info dialog, or read it from the API by name:
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://chat.yourdomain.com/api/v4/teams/$TEAM_ID/channels/name/town-square"
Acceptance check: the bot shows as a member of the channel, and a post made as the bot shows the bot’s name and avatar, not yours.
Phase 7: Point an agent at its bot
Every agent needs four values: the server URL, its own token, who is allowed to talk to it, and where it posts by default. With Hermes the variables are:
MATTERMOST_URL=https://chat.yourdomain.com
MATTERMOST_TOKEN=<that agent's bot token>
MATTERMOST_ALLOWED_USERS=<your username>
MATTERMOST_HOME_CHANNEL=<channel id>
Two optional ones I use:
# reply in a thread under the triggering message instead of flat in the channel
MATTERMOST_REPLY_MODE=thread
# channels where the agent answers without being mentioned
MATTERMOST_FREE_RESPONSE_CHANNELS=<channel id>
Where an agent keeps those variables is that agent’s own configuration matter. The four names above are the contract; how your agent reads its environment is covered in that agent’s own documentation.
Restart the agent after changing them, then verify the identity it actually authenticated as:
curl -sS -H "Authorization: Bearer $MATTERMOST_TOKEN" \
https://chat.yourdomain.com/api/v4/users/me
Acceptance check: the response contains the bot’s username, and "is_bot": true. If it returns your own admin account instead, the agent is holding the wrong token, which is the single most common wiring mistake and the reason I check identity rather than just connectivity.
Repeat Phase 6 and this phase per agent. Do not reuse a token across two agents. The moment you do, you have lost the ability to revoke one without breaking the other, and the message history stops telling you which machine acted.
Phase 8: Token hygiene and revocation
Tokens are bearer credentials. Anyone who has one is that bot until you revoke it.
- Store each token in the agent’s own environment or secret file, permissions
600, outside the compose directory. - Never paste a token into a channel, an issue, or a commit. If you do, rotate it immediately.
- Keep the token id. That is the handle for revocation, and the token string itself is not enough.
- Rotate on suspicion, not just on proof.
Revocation is a POST, not a DELETE. The obvious DELETE /api/v4/users/{user_id}/tokens/{token_id} returns 404 on my server. The endpoint that works is:
curl -sS -X POST https://chat.yourdomain.com/api/v4/users/tokens/revoke \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"token_id":"<token id>"}'
It returns {"status":"OK"}. I verified the rest of the path by revoking a throwaway token I had just created: the next call with that token returned 401 Unauthorized, and the account’s token list showed only the token that was still supposed to be there. Create, use, revoke, confirm. Do that once on a test bot so you know the flow works before you need it in a hurry.
Verification battery
| Test | What it proves | Result |
|---|---|---|
curl -sS -o /dev/null -w '%{http_code}' https://chat.yourdomain.com/ | the site answers over TLS | 200, PASS |
x-version-id response header | the running binary is the build I pinned | 11.10.0.3915213, PASS |
/api/v4/system/ping?get_server_status=true | app, database, and file store are up | status: OK, database_status: OK, filestore_status: OK, PASS |
| effective config read-back | the lockdown settings actually applied | open server false, bot creation true, PASS |
GET /api/v4/users/me with a bot token | the agent is the bot I think it is | is_bot: true, PASS |
| revoke a throwaway token, then reuse it | revocation works and is immediate | {"status":"OK"}, then 401, PASS |
Avoid My Mistakes
- The container boot-loops and the log says it cannot open the database. You are using
MM_DB_HOSTor similar. On 11.x those are ignored. UseMM_SQLSETTINGS_DRIVERNAMEandMM_SQLSETTINGS_DATASOURCE. - The app exits immediately under
read_only: true. You mounted a tmpfs for/tmpbut not/var/tmp. Mattermost’s local mode needs both. - The bot creation dialog is missing.
EnableBotAccountCreationis off. Check it in the System Console and read it back from the API. - A bot token gets 403 when creating a bot. By design. Bots cannot create bots. Use a human admin token or the UI.
- Your script treats a successful token creation as a failure. The token endpoint returns 200, not 201.
DELETEon the token path 404s. Revocation isPOST /api/v4/users/tokens/revokewith atoken_idbody.- The UI loads but messages never arrive. WebSockets are not passing through the proxy. Turn on WebSockets support and confirm the
Upgradeheader reaches the container. - Uploads fail at 1 MB. The proxy body limit is below the app’s
MaxFileSize. Raise the proxy limit above the app limit, not the other way around. - Scripted API calls get
403 error code: 1010. That is Cloudflare’s edge, not Mattermost. Send a normal browserUser-Agent. - A version upgrade replaces the fork binary. Pin the base image tag and the binary version together, verify the checksum, and rebuild. A stock base with a fork binary from a different major version is not a supported combination.
- The first user is not the admin. On a fresh database, the first account created is the system administrator. Create it before you hand the URL to anyone else.
- You left the marketplace on and the container phones home. Turn off the marketplace, remote marketplace, automatic prepackaged plugins, diagnostics, telemetry, and the security fix alert, then confirm from the effective config.
Day-2 operations
# status and health
docker compose ps
docker stats --no-stream mattermost mattermost-db
# logs
docker compose logs --tail=100 mattermost
# stop and start
docker compose down
docker compose up -d
# database backup (writes a compressed dump to the current directory)
docker exec mattermost-db pg_dump -U mm mattermost | gzip > mattermost-$(date +%F).sql.gz
For an upgrade, do it deliberately: read the upstream release notes, wait for a Mostlymatter binary at the matching version, download it, verify the checksum, rebuild the image with the base tag bumped, and run docker compose up -d. Take a database dump first. The volumes hold the database, uploads, config, logs, plugins, and search index; if you delete a volume by accident, the dump is what saves you.
I keep the backup on a schedule and I test the restore path on a throwaway Postgres container rather than assuming it works.
Threat model
This is a chat server on the public internet running six agents with shell access on the other end of their tokens. The build is deliberately online, so the useful question is not whether it is exposed but what the exposure lets someone do.
What the guardrails stop. The application port is bound to loopback, so
the only way in is through the proxy over TLS, and HSTS is on once it works.
Self-signup is closed, so strangers cannot make accounts. Bot tokens are
scoped to an identity: a compromised agent token can post as that agent, and
per the reference it cannot mint new identities on your server. Revocation is
immediate, which the verification battery proves by revoking a throwaway token
and watching the next call return 401. The container runs on a read-only
rootfs with tmpfs for /tmp and /var/tmp, images are pinned, and the fork
binary is checksum-verified before it goes in.
What they cannot stop. Five, plainly. The server is internet-facing by
design, so its real attack surface is Mattermost’s own, and a pinned version
is the only lever you have there. A bot token that leaks into a log, a
screenshot, or a chat message is a live credential until you revoke it. The
MATTERMOST_FREE_RESPONSE_CHANNELS setting is a convenience that means
anyone who can post in those channels can drive the agent without mentioning
it, so that list is a trust boundary, not a preference. The agents read chat
content, which makes every message in those channels untrusted input to a
program with shell access on its own box. And the volumes hold every message
your agents and your family have exchanged, unencrypted on disk, with a
database dump as the backup.
The token that matters most. The admin bearer token used in Phases 6 and 8 is unscoped: it can create users, mint bot tokens, and read the effective config. Treat it as the most valuable secret in the build. Store it outside the compose directory at mode 600, use it for setup and verification only, and rotate it when the build is done. Bot tokens have their own rule in Phase 8, and that rule is about containment after a leak, not before one.
Minimum hardening. Give Docker Desktop a dedicated non-admin Windows account rather than your daily login. Keep the volume directory and the dumps off any share. Put an access list on the proxy host if the server does not need to be reachable by everyone. Leave the marketplace and telemetry off so the container is not calling out on its own. Test the restore path, because a backup you have never restored is a hope, not a control. And when an agent acts, have it report what it did in the channel so a human sees the action, not just the result.
Hand it to an agent
If you want an AI agent to do the build, give it the whole runbook and these constraints rather than a summary:
Build the Mostlymatter stack from this post on a Windows machine with Docker Desktop.
Constraints:
- Base image mattermost/mattermost-team-edition:11.10.0, pin every tag, never latest.
- Download the Mostlymatter 11.10.0 binary and verify its sha512 before building.
- Postgres 16 with a named volume; password from .env, chmod 600.
- App port published to 127.0.0.1 only; proxy joins the npm_net network by name.
- read_only rootfs with tmpfs on both /tmp and /var/tmp.
- Leave EnableBotAccountCreation and EnableUserAccessTokens on, everything else in
the privacy block off.
- After boot, verify with the ping endpoint and read the effective config back
with an admin token. Show me both outputs before you create any bot.
Stop and ask before deleting a volume or changing the database.
The two things worth insisting on: verify the binary checksum before the build, and read the effective configuration back from the API instead of trusting that the environment variables took.
The part I would not skip: check that identity after every change. A chat server that accepts your agent’s token is not the same as a chat server where your agent is the bot you think it is.
