Colorful technical illustration of a phone chat app connected through a public server to a local desktop running an AI coding agent.

Put Codex Behind Mattermost, Then Stop Overbuilding It

A practical way to run Mattermost on a small server, expose it safely, and use the mobile app as a chat interface to a local Codex agent.

Mike Chumba Mike Chumba
10 min read
1966 words

The best remote control for a development machine is not always a terminal.

Sometimes it is a chat app.

Not because chat is magical. Not because every internal tool should become a bot. Most bots are bureaucracy with a webhook. But a private Mattermost server pointed at a local Codex CLI is different. It gives you a tiny, boring, mobile interface to a real machine.

You open your phone. You type:

codex check nginx and summarize anything unhealthy

The machine does the work where the files already live.

No laptop. No remote desktop. No dashboard shrine. Just chat, a queue, and a carefully limited bridge.

That is the whole trick.

Mattermost mobile Codex architecture

The Shape of the System

Here is the smallest useful model:

Phone
  -> https://chat.example.com
  -> public HTTPS frontend
  -> tunnel to the desktop
  -> local nginx
  -> Mattermost on 127.0.0.1:8065
  -> tiny bridge service
  -> codex exec

The desktop is the real server. The public HTTPS frontend is only the doorbell.

That frontend can be a VPS running Nginx, Caddy, Traefik, or a hosted tunnel like Cloudflare Tunnel. It does not need to run Mattermost. It does not need your code. It only needs to accept public HTTPS traffic and forward it to the desktop.

That distinction matters. You do not want to turn the public box into a second production environment just because it has an IP address. Keep it as a router. Let the machine with the code, SSH keys, Docker socket, local databases, and build cache do the work.

If the desktop reboots, systemd starts Mattermost and the bridge again. If the tunnel reconnects, the public URL comes back. Nothing about this needs a platform.

Why Mattermost?

Because it is boring in the right places.

It has users, teams, channels, private channels, bot accounts, personal access tokens, mobile apps, WebSockets, file uploads, pinned messages, and a normal HTTP API.

That is exactly what you need.

You do not need to invent accounts. You do not need to write a mobile app. You do not need to ship push notifications on day one. You do not need to create a fragile command center with 19 buttons.

Install Mattermost. Create one private team. Create a bot. Wire the bot to Codex.

Done.

Install the Boring Core

This assumes the machine running Mattermost is Ubuntu or Debian. In my case, that machine is the desktop. The public VPS, if you use one, is only for routing traffic back to it.

Install PostgreSQL:

sudo apt update
sudo apt install -y postgresql postgresql-contrib nginx

Create a database:

sudo -u postgres psql
CREATE USER mattermost WITH PASSWORD 'use-a-long-random-password';
CREATE DATABASE mattermost OWNER mattermost;
\q

Download Mattermost from the official release tarball and place it under /opt:

cd /tmp
curl -LO https://releases.mattermost.com/11.7.2/mattermost-11.7.2-linux-amd64.tar.gz
sudo tar -xzf mattermost-11.7.2-linux-amd64.tar.gz -C /opt
sudo useradd --system --user-group mattermost
sudo mkdir -p /opt/mattermost/data
sudo chown -R mattermost:mattermost /opt/mattermost

Edit /opt/mattermost/config/config.json.

The important settings are:

{
  "ServiceSettings": {
    "SiteURL": "https://chat.example.com",
    "ListenAddress": "127.0.0.1:8065",
    "WebsocketURL": "wss://chat.example.com",
    "EnableLocalMode": true,
    "EnableUserAccessTokens": true,
    "EnableBotAccountCreation": true
  },
  "TeamSettings": {
    "EnableOpenServer": false,
    "EnableUserCreation": false
  },
  "EmailSettings": {
    "EnableSignUpWithEmail": false
  }
}

Point SqlSettings.DataSource at your local PostgreSQL database:

postgres://mattermost:use-a-long-random-password@127.0.0.1:5432/mattermost?sslmode=disable&connect_timeout=10&binary_parameters=yes

Then give Mattermost a systemd unit:

[Unit]
Description=Mattermost
After=network.target postgresql.service
BindsTo=postgresql.service

[Service]
Type=notify
User=mattermost
Group=mattermost
WorkingDirectory=/opt/mattermost
ExecStart=/opt/mattermost/bin/mattermost
Restart=always
RestartSec=10
LimitNOFILE=49152

[Install]
WantedBy=multi-user.target

Enable it:

sudo systemctl daemon-reload
sudo systemctl enable --now mattermost
curl http://127.0.0.1:8065/api/v4/system/ping

You want status: OK.

Put nginx in Front

Mattermost should listen locally. nginx should be the local HTTP front door.

server {
    listen 80;
    listen [::]:80;
    server_name chat.example.com;

    client_max_body_size 100M;

    location ~ /api/v[0-9]+/(users/)?websocket$ {
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_read_timeout 86400;
        proxy_pass http://127.0.0.1:8065;
    }

    location / {
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_read_timeout 300;
        proxy_pass http://127.0.0.1:8065;
    }
}

Reload nginx:

sudo nginx -t
sudo systemctl reload nginx

At this point, local HTTP should work:

curl -H 'Host: chat.example.com' http://127.0.0.1/api/v4/system/ping

Expose It Without Moving the App

You have several choices. The important part is not which proxy you use. The important part is where the app runs.

For many people, the simplest answer is a hosted tunnel. Cloudflare Tunnel is excellent for this. It gives you a public HTTPS URL and forwards traffic to the desktop without opening inbound ports at home.

A small VPS is also fine. In that version, the VPS is just a public gateway:

public internet -> VPS -> reverse SSH tunnel -> desktop nginx

The VPS can use whatever HTTP frontend you already understand: Nginx, Caddy, Traefik, HAProxy, or something else. If you do not already have Traefik, do not install it just for this.

The minimal Nginx-shaped version looks like this:

chat.example.com -> VPS nginx -> 127.0.0.1:18080 -> reverse tunnel -> desktop:80

The desktop runs:

ssh -N \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  -R 18080:127.0.0.1:80 \
  tunnel-user@your-vps

Then systemd keeps that tunnel alive.

On the VPS, point your public proxy at 127.0.0.1:18080. With Nginx, the upstream line is just:

proxy_pass http://127.0.0.1:18080;

With Caddy, it is:

chat.example.com {
    reverse_proxy 127.0.0.1:18080
}

This is less glamorous than a managed tunnel. It is also very clear. Your public server is the public server. Your desktop is the application server. DNS chooses the public name. The desktop nginx chooses the local app.

That clarity is worth a lot.

Create the First User, Team, and Bot

Use Mattermost’s local mode or the API.

Create an admin user, then make a private team:

/opt/mattermost/bin/mmctl --local user create \
  --email you@example.com \
  --username you \
  --password 'use-a-real-password' \
  --system-admin

/opt/mattermost/bin/mmctl --local team create \
  --name home \
  --display-name Home \
  --private

Create two channels:

/opt/mattermost/bin/mmctl --local channel create \
  --team home \
  --name codex \
  --display-name Codex

/opt/mattermost/bin/mmctl --local channel create \
  --team home \
  --name codex-private \
  --display-name "Codex Private" \
  --private

Create a bot called codex, add it to the team and channels, then create a personal access token for it.

Depending on your Mattermost version, bot creation through mmctl --local may not be available. The REST API works fine:

curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "username": "codex",
    "display_name": "Codex",
    "description": "Local Codex bridge bot"
  }' \
  http://127.0.0.1:8065/api/v4/bots

Store the bot token like a password. It is a password.

The Bridge Should Be Small

The bridge has one job:

  1. Connect to Mattermost as the bot.
  2. Watch allowed channels and direct messages.
  3. Ignore everyone except your allowed username.
  4. Load the current Mattermost thread as context.
  5. Run codex exec.
  6. Reply in the same thread.

That is it.

This is not a plugin. This is not a framework. This is not an agent platform.

It is a service.

Here is the important part of the shape:

import { spawn } from "node:child_process";

const allowedUsers = new Set(["you"]);
const allowedChannels = new Set([process.env.CODEX_CHANNEL_ID]);

async function handlePost(post) {
  if (post.user_id === botUserId) return;

  const user = await getUser(post.user_id);
  if (!allowedUsers.has(user.username)) return;

  const channel = await getChannel(post.channel_id);
  const isAllowedChannel = allowedChannels.has(channel.id);
  const isDirectWithBot = channel.type === "D" || channel.type === "G";
  if (!isAllowedChannel && !isDirectWithBot) return;

  const prompt = post.message.replace(/^@?codex:?\s*/i, "").trim();
  if (!prompt) return;

  const threadContext = await getThreadTranscript(post.root_id || post.id);

  await reply(post.id, "Running Codex...");

  const child = spawn("codex", [
    "--ask-for-approval", "never",
    "exec",
    "--skip-git-repo-check",
    "--sandbox", "danger-full-access",
    "-C", "/home/you",
    "-"
  ]);

  child.stdin.end([
    "Use the Mattermost thread context for continuity.",
    "Treat the latest user prompt as the instruction to answer now.",
    "",
    "Mattermost thread context:",
    threadContext,
    "",
    "Latest user prompt:",
    prompt
  ].join("\n"));

  let out = "";
  let err = "";

  child.stdout.on("data", chunk => out += chunk);
  child.stderr.on("data", chunk => err += chunk);

  child.on("close", async code => {
    if (code === 0) {
      await reply(post.id, out || "Done.");
    } else {
      await reply(post.id, `Codex failed with exit ${code}\n\n${err}`);
    }
  });
}

The production version should add:

  • a queue, so prompts do not stampede
  • a timeout
  • output truncation
  • WebSocket reconnects
  • polling fallback
  • thread transcript truncation, so a huge Mattermost thread does not create an absurd prompt
  • allowed channel IDs in an env file
  • logs in journald

But do not lose the plot. The bridge is still just glue.

Run the Bridge with systemd

Put your bridge in /opt/mattermost-codex-bridge.

Create /etc/mattermost-codex-bridge.env:

MATTERMOST_URL=http://127.0.0.1:8065
MATTERMOST_WS_URL=ws://127.0.0.1:8065/api/v4/websocket
MATTERMOST_TOKEN=your-bot-token
MATTERMOST_BOT_USERNAME=codex
MATTERMOST_ALLOWED_USERNAMES=you
MATTERMOST_ALLOWED_CHANNEL_IDS=channel-id-1,channel-id-2
CODEX_BIN=/home/you/.local/bin/codex
CODEX_HOME=/home/you/.local/share/codex
CODEX_WORKDIR=/home/you
CODEX_MODEL=gpt-5.5
CODEX_TIMEOUT_MS=1200000
PATH=/home/you/.local/bin:/usr/local/bin:/usr/bin:/bin

Lock it down:

sudo chown root:root /etc/mattermost-codex-bridge.env
sudo chmod 600 /etc/mattermost-codex-bridge.env

Then create the unit:

[Unit]
Description=Mattermost Codex bridge
After=network.target mattermost.service
Requires=mattermost.service

[Service]
User=you
WorkingDirectory=/opt/mattermost-codex-bridge
EnvironmentFile=/etc/mattermost-codex-bridge.env
ExecStart=/usr/local/bin/node /opt/mattermost-codex-bridge/bridge.mjs
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Enable it:

sudo systemctl daemon-reload
sudo systemctl enable --now mattermost-codex-bridge
journalctl -u mattermost-codex-bridge.service -f

Now send a test message in Mattermost:

codex reply exactly with: bridge-ok

If you get bridge-ok in the thread, the machine is alive.

One useful detail: replies work too, and the thread becomes context.

If you keep replying inside the same Mattermost thread with new Codex prompts, the bridge sees those reply posts, loads the existing thread transcript, and sends the bot’s answer back into that same thread. The implementation is simple: when the incoming post already has a root_id, the bot uses that root to fetch the thread and to post the reply. If there is no root_id, it starts a new thread from the original prompt.

That means you can keep a single investigation together:

codex inspect the failing service
codex now check the last 50 log lines
codex draft the smallest fix

All of those can live in one Mattermost thread instead of scattering the work across the channel. The later prompts can refer to what happened earlier in the thread because the bridge includes the recent thread transcript in the Codex prompt.

Pin the Instructions Where You Will Actually Need Them

This is small, but it matters.

Create a private channel called Codex Private. Add yourself and the bot. Post the operating instructions there and pin them.

The pinned message should include:

  • the public server URL
  • the login email
  • which channels trigger Codex
  • the warning that Codex has local machine access
  • the service names to restart
  • where the bridge token and env file live

Documentation in a repo is good. Documentation inside the tool you will use half-asleep on a phone is better.

The Security Tradeoff

This setup is powerful because it is not pretending.

Codex is not answering from a sandboxed toy notebook. It is running on your machine. It can read files. It can run commands. It can edit things. If you give it Docker access, SSH keys, production configs, or cloud credentials, it can touch those too.

So use blunt controls:

  • disable public signup
  • allow only your Mattermost username
  • allow only specific channel IDs
  • run the bridge as your normal user, not root
  • keep the bot token in a root-readable env file
  • log every run
  • keep the public VPS as a router, not a second app host
  • do not expose Mattermost directly from your home network if a reverse tunnel is enough

Could you add approval buttons? Yes.

Could you put each job in a container? Yes.

Could you build a full role system? Of course.

But start with the honest version. One user. One bot. One machine. Explicit channels. Clear logs.

Then add complexity only when the pain is real.

What You Get

You get a private mobile operations channel for your own machine.

You can ask it to:

  • check service health
  • summarize logs
  • inspect a project
  • edit a config
  • restart a local service
  • draft a deploy note
  • turn an error into a patch

And the answer comes back in the same thread, on the same phone, without you carrying a laptop around the house.

This is not about replacing SSH. SSH is still the ground truth.

It is about having a softer front door for the work that does not need a full terminal.

The server is still yours. The files are still local. The interface is just chat.

That is a good trade.