Container recipes¶
Complete setups for clients in container mode: pi packages, dsh plugins, a headless browser, tool servers, Postgres and web search for Open WebUI. Each one builds on Custom container images.
pi packages¶
A pi package adds extensions, skills, prompt templates or themes. Put it in the image to have it in every project, with no network needed:
- Write a Containerfile that installs the package into a folder of its own:
FROM gmlx.invalid/launch-pi:base
RUN npm install --prefix /opt/pi-packages --legacy-peer-deps pi-btw@0.7.1 \
&& npm cache clean --force
launch:
container:
clients:
pi:
build: ~/containers/pi
command: [pi, -e, /opt/pi-packages/node_modules/pi-btw]
- Launch pi from the project folder with
gmlx launch pi --container.
The example installs pi-btw, which
adds a /btw command for a side conversation. Add one -e for each
package, and pin an exact version. --legacy-peer-deps stops npm from
installing a second copy of pi.
To add a package to one project only, install it from the shell. It goes into the private home:
pi checks for updates to packages without a version, which fails under
network: none. Pin versions, or set
PI_OFFLINE=1 in env.
To bring the extensions, skills and prompts you keep on the Mac, copy their
folders with seed:
launch:
container:
clients:
pi:
seed: [~/.pi/agent/extensions, ~/.pi/agent/skills, ~/.pi/agent/prompts]
Seed these folders, not all of ~/.pi/agent, which also holds your
provider keys in auth.json.
dsh plugins¶
dsh installs plugins with pnpm, which the image gmlx builds does not have.
Plugins go into the gmlx profile in the private home, so each project has
its own:
- Write a Containerfile that installs pnpm:
- Name its folder with
build:
-
Launch dsh once in the project with
gmlx launch dsh --container, so thegmlxprofile exists before you add plugins to it. Adsh plugincommand before this launch makes a profile without the web app. -
Install the plugin from the shell, with the default network:
cd ~/src/my-project
gmlx launch dsh --shell -- -c "dsh plugin --profile gmlx add dsh-context@0.62.3"
The example installs
dsh-context, a context
dashboard for the web app. dsh loads it at its next start. The Plugins page
of the web app works too, once pnpm is in the image. Installed plugins keep
working under network: none.
Headless browsers¶
For screenshots of plain pages, add Chromium and fonts to the image:
launch:
container:
clients:
claude-code:
packages: [chromium, fonts-liberation, fonts-noto-color-emoji]
The client can then take a screenshot:
chromium --headless --no-sandbox --disable-dev-shm-usage \
--screenshot=/tmp/page.png https://example.com
--no-sandbox is needed because the client runs as root, and
--disable-dev-shm-usage because the container's shared memory is small.
Without the font packages, pages show text as empty boxes. Chromium prints
D-Bus errors in a container, which you can ignore.
For Puppeteer, add PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium to
env, because the Chrome it downloads has
no Linux arm64 build.
Tool servers¶
An MCP tool server in the container gives the client tools that reach only what the session reaches. This is the safe way to give a coding agent tools, because the tools of a served assistant run on the Mac.
The quickest way is the client's own MCP command, run from the shell. The entry stays in the private home, for that project. This gives pi a memory server:
cd ~/src/my-project
gmlx launch pi --shell -- -c 'pi mcp add memory \
--env MEMORY_FILE_PATH=$HOME/.pi/memory.jsonl \
-- npx -y @modelcontextprotocol/server-memory@2026.8.31'
The single quotes keep $HOME for the shell in the container, so the
memory file lands in the private home. A server that npx or uvx starts
is downloaded each time the client starts it, so it fails under
network: none.
To have a tool server work in every project and with network: none, put
it in the image. This gives Claude Code a browser it drives through the
Playwright tool server:
- Write a Containerfile that adds Chromium, its fonts and the server, and copies the entry into the image:
FROM gmlx.invalid/launch-claude-code:base
RUN apt-get update \
&& apt-get install -y --no-install-recommends chromium fonts-liberation fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @playwright/mcp@0.0.83 && npm cache clean --force
COPY mcp.json /etc/mcp/claude.json
- Beside it, write
mcp.json:
{
"mcpServers": {
"playwright": {
"type": "stdio",
"command": "playwright-mcp",
"args": ["--browser", "chromium", "--executable-path", "/usr/bin/chromium",
"--headless", "--no-sandbox", "--isolated",
"--output-dir", "/tmp/playwright-mcp"]
}
}
}
- Name the folder, and load the entry with Claude Code's
--mcp-configoption:
launch:
container:
clients:
claude-code:
build: ~/containers/claude-playwright
command: [claude, --mcp-config, /etc/mcp/claude.json]
- Launch Claude Code from the project folder with
gmlx launch claude-code --container. Its/mcpcommand listsplaywright.
The entry names Debian's Chromium, because Playwright's Chrome has no Linux
arm64 build. --isolated keeps the browser profile in memory. The entry
lives in the image, out of the client's reach.
Snapshots, and screenshots that the server names, go to
/tmp/playwright-mcp. A screenshot that the model names goes to the working
folder, which is the shared project folder.
Avoid project files such as .mcp.json in the share for tool servers. The
client can change them, and
clients on the Mac read them.
For opencode, start the Containerfile from
gmlx.invalid/launch-opencode:base, leave out the COPY line, and give the
entry in env:
launch:
container:
clients:
opencode:
build: ~/containers/opencode-playwright
env:
- 'OPENCODE_CONFIG_CONTENT={"mcp":{"playwright":{"type":"local","command":["playwright-mcp","--browser","chromium","--executable-path","/usr/bin/chromium","--headless","--no-sandbox","--isolated","--output-dir","/tmp/playwright-mcp"]}}}'
Postgres¶
Postgres can run in the container with its data on a volume. It cannot keep its data in a share, because every file there belongs to root. To run it in the container:
- Write a Containerfile that installs Postgres and copies a start script:
FROM gmlx.invalid/launch-claude-code:base
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql \
&& rm -rf /var/lib/apt/lists/*
COPY start-pg /usr/local/bin/start-pg
RUN chmod 755 /usr/local/bin/start-pg
- Beside it, write the start script
start-pg. It starts Postgres as thepostgresuser, then runs the client:
#!/bin/sh
set -e
data=/var/lib/postgresql/data
bin=$(echo /usr/lib/postgresql/*/bin)
mkdir -p "$data" /run/postgresql
chown postgres:postgres "$data" /run/postgresql
# A session never stops Postgres cleanly, so the pid file is stale.
rm -f "$data/postmaster.pid"
if [ ! -s "$data/PG_VERSION" ]; then
runuser -u postgres -- "$bin/initdb" -D "$data"
fi
runuser -u postgres -- "$bin/pg_ctl" -D "$data" -l /tmp/postgres.log start
exec "$@"
- Name the folder, the start script and a volume:
launch:
container:
clients:
claude-code:
build: ~/containers/claude-pg
command: [/usr/local/bin/start-pg, claude]
volumes: [claude-pg:/var/lib/postgresql:8G]
- Launch Claude Code from the project folder with
gmlx launch claude-code --container.
The data survives from one session to the next, and the client connects
with psql -U postgres. To use Postgres on the Mac instead, give it a
password and add forward: [5432], as
Forwarded ports describes.
Web search for Open WebUI¶
Open WebUI can search the web through SearXNG, a metasearch engine that runs in the same container, with no account at a search provider.
- In a new folder
~/containers/open-webui-searxng, write a Containerfile that installs SearXNG in a separate virtual environment:
FROM gmlx.invalid/launch-open-webui:base
ARG SEARXNG_COMMIT=19ffbcd30686e4008392e93de164200510dfb9d8
RUN set -eux; \
mkdir -p /opt/searxng/src; \
cd /opt/searxng/src; \
git init -q; \
git fetch -q --depth 1 https://github.com/searxng/searxng.git "$SEARXNG_COMMIT"; \
git -c advice.detachedHead=false checkout -q FETCH_HEAD; \
/usr/bin/python3 -m venv /opt/searxng/venv; \
/opt/searxng/venv/bin/pip install --no-cache-dir \
-r requirements.txt -r requirements-server.txt
COPY settings.yml /etc/searxng/settings.yml
COPY start-webui /usr/local/bin/start-webui
RUN chmod 755 /usr/local/bin/start-webui
- Beside it, write the SearXNG settings
settings.yml:
use_default_settings: true
search:
formats:
- html
- json
server:
limiter: false
public_instance: false
image_proxy: false
- Beside them, write the start script
start-webui:
#!/bin/sh
set -e
secret=$(/opt/searxng/venv/bin/python -c 'import secrets; print(secrets.token_hex(32))')
(
cd /opt/searxng/src
SEARXNG_SETTINGS_PATH=/etc/searxng/settings.yml SEARXNG_SECRET="$secret" \
exec /opt/searxng/venv/bin/granian --interface wsgi \
--host 127.0.0.1 --port 8888 searx.webapp:app
) >/tmp/searxng.log 2>&1 &
exec open-webui serve --host "$HOST" --port "$PORT" "$@"
- Name the folder, the start script and the search settings for the client:
launch:
container:
clients:
open-webui:
build: ~/containers/open-webui-searxng
command: [/usr/local/bin/start-webui]
network: default
env:
- ENABLE_WEB_SEARCH=true
- WEB_SEARCH_ENGINE=searxng
- SEARXNG_QUERY_URL=http://127.0.0.1:8888/search
- BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL=true
- BYPASS_WEB_SEARCH_WEB_LOADER=true
Open WebUI reads these only on its first start. If it already ran in a container, set the same values in Admin Panel > Settings > Web Search.
- Launch Open WebUI with
gmlx launch open-webui --container, and turn on Web Search in a chat from the Integrations menu next to+.
SearXNG asks other search engines, so the container needs
network: default. Some engines answer automated queries with a CAPTCHA,
so results vary. To pick up a newer SearXNG, change SEARXNG_COMMIT.
A few details of the files matter if you change them:
- The Containerfile calls
/usr/bin/python3, Debian's Python, because a barepython3in this image is the one in Open WebUI's environment. - SearXNG refuses to start with the secret key it ships with, so the start script gives it a new key at each start.
- The script changes folder only in the subshell that starts SearXNG. Open WebUI keeps its sign-in key in the folder it starts in, and a new folder signs you out at each session.
- Port 8888 must differ from the gmlx server's port and from every
forwardport.
The two BYPASS lines matter only for a model set to the legacy way of
calling tools. The first skips the embedding step. Without it, Open WebUI
embeds the results, which needs the embeddings service that
the RAG setup turns on. The second gives
the model snippets instead of whole pages. Remove it if the model has the
context to read whole pages.
To check SearXNG, open a shell in the running session with
gmlx launch open-webui --shell and run:
Its log is /tmp/searxng.log in the container.