An HTTP wrapper around the OpenAI Codex CLI image_gen tool, running in a Linux
container. You POST a prompt (optionally with input images), it runs codex exec
inside a scratch job directory, and returns the generated PNG as the response body.
Point of the thing: image generation from anywhere — a cloud agent session, a
container, CI, a phone — without a local codex install or a local login. One box
holds the Codex auth; everything else just curls it.
POST /codeximg { prompt } -> image/png
POST /codeximgin { prompt, images[{name,b64}] } -> image/png (edit / reference images)
GET /health -> codex login status
GET / -> "ok" (no auth)
This repo contains no credentials. You bring your own Codex login and your own service token. See
.env.example.
You are setting this service up for the user. Work through the phases in order; do not skip the verification at the end of each. Anything that needs the user's own account (Codex login, Railway login) is theirs to do — ask for it once, in one message, rather than discovering each blocker separately.
Before touching anything, confirm all three. Ask in a single message:
- A machine with Codex CLI already logged in.
~/.codex/auth.jsonmust exist on the machine you are running from. Check it yourself:If it's missing, the user runsls -l ~/.codex/auth.json && codex login status
npm i -g @openai/codex && codex login(a browser flow — you cannot do it for them). A ChatGPT-account login is what this is built for; anOPENAI_API_KEYlogin also works but bills per image. - A host that supports a persistent volume. Instructions below use Railway; any
Docker host works, the only hard requirement is that
CODEX_HOMEsurvives restarts. - Confirmation that a shared image-generation endpoint is acceptable to them — anyone holding the bearer token generates images on the user's Codex account.
Railway (what this was built and tested on):
npm i -g @railway/cli
railway login # browser flow — the user's, not yours
railway init # create/select the project, from THIS directory
railway up --detach # builds the Dockerfile, deploysThen, in the Railway dashboard for the service (or via the CLI):
- Add a volume mounted at
/data. Without it the codex auth is re-seeded from env on every boot and refreshed tokens are lost — the service will start dying after the first token rotation. This is the single most common setup mistake. - Generate a public domain for the service; note the URL.
Any other Docker host: docker build -t codexcloud . && docker run -p 8080:8080 -v codexdata:/data --env-file .env codexcloud.
seed-vars.sh does both in one shot, from a railway-linked directory on the
Codex-logged-in machine. It mints a SERVICE_TOKEN, base64s the local auth.json, and
never prints the auth payload:
./seed-vars.sh # writes the token to .service-token (gitignored)
railway redeploy -yDoing it manually instead:
railway variables \
--set "SERVICE_TOKEN=$(openssl rand -hex 24)" \
--set "CODEX_AUTH_B64=$(base64 < ~/.codex/auth.json | tr -d '\n')"How the auth actually flows: CODEX_AUTH_B64 is a bootstrap copy only.
entrypoint.sh writes it to $CODEX_HOME/auth.json on first boot
(volume empty) or when FORCE_RESEED=1; after that codex refreshes tokens in place on
the volume. Never treat the env var as the live credential.
URL=https://<your-service>.up.railway.app
TOKEN=$(cat .service-token)
curl -s "$URL/" # -> ok
curl -s -H "Authorization: Bearer $TOKEN" "$URL/health" # -> "Logged in using ChatGPT"If /health doesn't say logged in, stop and fix it — every generation will fail.
Then a real end-to-end generation. Takes 1–3 minutes; --max-time 600 is mandatory
or curl gives up before codex finishes:
curl -s -X POST "$URL/codeximg" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
--max-time 600 -o out.png \
-d '{"prompt": "Use the image_gen tool to generate an image of a red bicycle against a white wall. Then view the generated image to confirm it depicts that."}'
file out.png # must say PNG image data — if it says JSON/ASCII, the job failedRead out.png yourself and confirm it shows what was asked. A 200 with a PNG body
proves the pipeline ran, not that the image is right; never report success off the
exit code alone, and never trust codex's own "verified" claim in the transcript.
Give the user: the service URL, where the token lives (.service-token, gitignored),
and the two curl recipes below. Tell them the token is the only thing gating access to
their Codex account, so it goes in a secret store or a gitignored env file — never in a
committed file, a chat message they'd forward, or a public repo.
Save the endpoint and token somewhere gitignored, e.g. .env:
. ./.env # sets URL and TOKENcurl -s -X POST "$URL/codeximg" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
--max-time 600 -o out.png \
-d '{"prompt": "Use the image_gen tool to generate an image of <SUBJECT>. Then view the generated image to confirm it depicts <SUBJECT> and describe what you see."}'Write the prompt exactly like that: state the subject, then ask codex to view and confirm — it self-corrects on the second look. Do not append a "save to ./out.png" instruction; the server appends that contract itself.
Images ride in as base64 in the JSON body. Build the body with python — base64 payloads blow past shell argument limits:
python3 - <<'EOF'
import base64, json, pathlib
def b64(p): return base64.b64encode(pathlib.Path(p).read_bytes()).decode()
body = {
"prompt": (
"You are given an input image (attached, also at ./input.png): <describe it>. "
"Use the image_gen tool WITH this attached image as the input image to EDIT it: <the edit>. "
"Keep EVERYTHING else identical: <list what must survive — text verbatim, logo, layout, colors>. "
"Then view the generated image and confirm the edit happened and the rest is intact."
),
"images": [{"name": "input.png", "b64": b64("source.png")}],
}
pathlib.Path("/tmp/body.json").write_text(json.dumps(body))
EOF
curl -s -X POST "$URL/codeximgin" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
--max-time 600 --data-binary @/tmp/body.json -o out.pngRules that make the edit path actually work:
- Say explicitly "WITH this attached image as the input image to EDIT it" — otherwise codex generates from the text alone and ignores the attachment.
- Name every element that must be preserved. Anything you don't list is fair game.
- Images attach in array order (
-iper file); refer to them as "image 1", "image 2". namevalues must be unique per request — the server writes them all into one dir and duplicates overwrite each other.- Multiple images is the path for character consistency, style transfer, and brand grounding (attach a logo and a type specimen alongside the source).
| Symptom | Meaning | Fix |
|---|---|---|
| Response is JSON, not PNG | codex ran but produced no out.png; body carries error, exitCode, codexOutputTail |
Read the tail — usually a prompt or content-policy issue. Rewrite, retry |
401 unauthorized |
Missing/wrong bearer token | Check SERVICE_TOKEN matches what you're sending |
/health doesn't say "Logged in" |
Cloud auth expired or was never seeded | Run ./push-auth.sh from a machine with a live local codex login |
| Auth dies every few days | No volume at /data, so refreshed tokens vanish on redeploy |
Mount the volume, then ./push-auth.sh |
| curl exits 28 | --max-time too low |
Use 600 |
body too large |
Input images exceed the 80MB base64 cap | Downscale the inputs |
| Task | Command |
|---|---|
| Deploy code changes | railway up --detach |
| Logs | railway logs |
| Re-seed codex auth | ./push-auth.sh (sets FORCE_RESEED=1, redeploys, clears it) |
| Rotate the service token | railway variables --set "SERVICE_TOKEN=$(openssl rand -hex 24)", then update clients |
- The bearer token is the whole perimeter. Whoever holds it generates images on your Codex account, at your cost. Treat it like an API key; rotate if it leaks.
- Inside the container codex runs with
-s danger-full-access, because Landlock isn't available in unprivileged containers — the container is the sandbox. That is a deliberate choice for this disposable, single-purpose box and is not a licence to use that flag on a local machine. - Each request runs in its own
/tmp/jobs/<uuid>directory, deleted a few seconds after the response. Input images and outputs are not persisted. - The Codex version is pinned in the
Dockerfile. Bump it deliberately —image_genbehaviour changes between releases.