Unattended IB Gateway in Docker. ibg-controller starts Gateway, logs in, completes 2FA, applies your API settings and keeps the session running with nobody at the screen. A Python controller runs the login state machine, and a small Java agent inside Gateway's JVM does the clicking and typing.
- TOTP 2FA without a phone. Set
TWOFACTOR_CODEand the weekly re-authentication completes on its own. - Gateway's own daily restart, adopted. When Gateway restarts itself, the controller follows the new process instead of launching a second one.
- Built to be monitored.
/healthfor probes and stableALERT_*log tokens for paging. - Signed releases. Multi-arch images, cosign-signed, with an SBOM.
Coming from IBC? IBC was retired on 1 September 2026 and its
repository is archived. Your existing env vars from
gnzsnz's image, like
TWS_USERID, TRADING_MODE and TWOFA_DEVICE, work unchanged. The
command server uses IBC's command names, and a one-shot tool converts
your config.ini: docs/FROM_IBC.md.
Release images build on gnzsnz's ib-gateway image, pinned by digest,
which supplies Gateway itself, Xvfb and VNC. UPSTREAM_IMAGE is a
build arg if you need a different base.
docker pull ghcr.io/code-hustler-ft3d/ibg-controller:latest
docker run -d --name ibkr \
--restart on-failure \
--env-file /path/to/your/.env \
-e USE_IBG_CONTROLLER=yes \
-e TRADING_MODE=paper \
-e TWS_SERVER_PAPER=cdc1.ibllc.com \
-p 127.0.0.1:4002:4004 \
ghcr.io/code-hustler-ft3d/ibg-controller:latestUSE_IBG_CONTROLLER=yes is required. Without it the image starts the
IBC build that ships in its base image.
Set a restart policy. When the controller can't recover a login on its
own it exits, and on-failure lets Docker start it again. Leave off the
retry count: Docker never resets it after a healthy run, only when you
start or recreate the container yourself, so on-failure:3 is a budget
for the container's whole life and quietly runs out. Unlimited is safe
because the stops that need you halt instead of exiting — a 2FA setup
problem, a persistent CCP lockout, and rejected credentials (at once when
Gateway says the password is wrong, or after two attempts in a row show
IBKR's rejection pattern in its log). A halted container stays up and
makes no further login attempts, so a restart policy never fires on it;
fix the cause and restart it yourself. Any other failed login is
relaunched three times with growing pauses before the controller exits
and the container restarts.
Tags: :latest, :<major>.<minor>, :<major>.<minor>.<patch> and
:v<major>.<minor>.<patch>. All
cosign-signed; verification recipe and digest pinning in
SECURITY.md.
If you use docker compose, set stop_grace_period: 90s. Docker's
default 10s is too short for the clean-logout chain, and cutting it
short strands IBKR session slots on every restart
(timing math):
services:
ib-gateway:
image: ghcr.io/code-hustler-ft3d/ibg-controller:latest
restart: on-failure
stop_grace_period: 90s # required
environment:
TRADING_MODE: paper
TWS_SERVER_PAPER: cdc1.ibllc.com
USE_IBG_CONTROLLER: "yes"
# ... your other env varsOne 2FA method only. If your IBKR account has both IB Key and Mobile Authenticator enabled, unattended login fails. Gateway pre-selects IB Key and issues its challenge as the dialog opens, so switching to Mobile Authenticator starts a second session and IBKR kicks the first one, leaving a "Re-login is required" box on screen. Remove the method you don't automate in Client Portal → Settings → User Settings → Security → Secure Login System. The alternatives, all of which need a human at login time, are in 2FA.
Deeper guides:
- Build your own image, or add the controller to an existing one:
docs/MIGRATION.md - Coming from IBC (
config.ini→ env vars):docs/FROM_IBC.md - Finding your regional server (
TWS_SERVER):docs/BOOTSTRAP.md - Why each piece exists:
docs/ARCHITECTURE.md
| Requirement | Notes |
|---|---|
Linux amd64/arm64 |
Ubuntu 24.04 base tested |
| IB Gateway 10.x | Release images pin 10.45.1j (gnzsnz :stable line) |
| Python 3.10+ | Runtime; stdlib only, no pip installs |
| JDK 17+ | Build time only — runtime uses the JRE bundled with Gateway |
python3, matchbox-window-manager, curl |
The only packages added on top of the upstream image |
| Feature | Gateway | TWS | Notes |
|---|---|---|---|
| Paper / live / dual-mode cold start | ✅ verified | dual mode = two isolated JVMs | |
| TOTP 2FA (single method) | ✅ verified | ||
| IB Key push 2FA | ✅ wait mode | ✅ wait mode | waits for you to approve on the phone |
| Multi-method 2FA | both dialog shapes are detected and driven, but IBKR kicks the session on most accounts we've seen when the method is switched mid-login; run a single method, see 2FA | ||
Passkey prompt (PASSKEY_AUTHENTICATE=yes) |
presses Authenticate; your authenticator completes WebAuthn; amd64 plus extra libraries; see 2FA | ||
| Existing-session dialog | ✅ verified | ||
Post-login config (READ_ONLY_API, TWS_MASTER_CLIENT_ID, auto logoff/restart times) |
✅ verified | ||
Command server (STOP, RESTART, RECONNECTACCOUNT, ENABLEAPI) |
✅ verified | RECONNECTDATA is TWS-only |
✅ verified = run end-to-end against a real IB account.
- Use Mobile Authenticator (TOTP) for unattended operation: set
TWOFACTOR_CODEto the base32 secret from IBKR's authenticator setup. This is the only method a headless container can satisfy on its own. - IB Key push requires a human to tap approve on a phone. The
controller will wait for that (leave
TWOFACTOR_CODEunset), which is fine attended and a dead end for automation. - Attended fallback over VNC. If automation can't finish 2FA —
wrong method on the account, a stale secret, a push you missed —
connect to the container's VNC (port
5900, password from the upstreamVNC_SERVER_PASSWORDenv var) and complete the login by hand. The controller sees the API port open and carries on monitoring; nothing needs restarting. You have the 2FA wait (TWOFA_EXIT_INTERVAL, default 120 s) plus the API-port wait (180 s) — about five minutes, during which the controller only watches. Slower than that and it exits, the container restarts and you get a fresh window, but a login in progress is interrupted; raiseTWOFA_EXIT_INTERVALif you need longer. LeaveTWOFA_TIMEOUT_ACTIONat its defaultnone:restartwould relaunch Gateway out from under you. Publish 5900 on localhost only (-p 127.0.0.1:5900:5900). Expect to repeat it roughly weekly — IBKR forces a full re-authentication at its Sunday ~01:00 ET reset (per IBC's user guide). (Suggested by @ldicarlo in #7.) - Passkey / WebAuthn accounts: the controller presses Authenticate,
you supply the authenticator. IBKR forced some regions (Hong Kong
and Japan as of 2026-08) onto passkeys; Gateway's Second Factor
dialog then asks you to "use your Passkey device". With
PASSKEY_AUTHENTICATE=yesthe controller presses Authenticate and stops there — the WebAuthn ceremony must be completed by an authenticator running alongside the container (a virtual FIDO device such as passless, a key passed through, or a person). The controller never holds a passkey and never emulates one; that is a different job than driving Gateway's dialogs. Unset, a passkey prompt fails loudly (ALERT_2FA_FAILED reason="passkey/WebAuthn 2FA flow ...") as it has since v0.8.1. Contributed and used in production by @jpike88; the maintainer has no passkey account, so this is⚠️ rather than ✅. Needs an amd64 base, because IBKR's arm64 installer ships no browser. The prompt opens Gateway's embedded browser, whose system libraries this image doesn't include: add the packages from gnzsnz/ib-gateway-docker#440 in an image of your own. See #22 and #29. - Accounts with more than one method: Gateway pre-picks one, and
the dialog shape varies by account — some get a code dialog
defaulted to one method, others a device-selector list. The
controller detects both shapes. If the pre-pick doesn't match
TWOFACTOR_CODEit selects the right device where the dialog allows it, and otherwise fails loudly (ALERT_2FA_FAILED) with the fix in the log — it never types the code into the wrong method. The switch itself rarely survives: the pre-selected method's challenge is already in flight when the selector opens, so choosing another one starts a second session and IBKR kicks the first, which is the "Re-login is required" box in #37 (COMPETE: session kicked outin Gateway'slauncher.log) and the server-side rejection in #20. The fix is to run one method: remove the one you don't automate in Client Portal → Settings → User Settings → Security → Secure Login System, and Gateway stops showing the selector altogether. In our experience deleting the IBKR Mobile app from the phone also deactivates IB Key, since it's device-bound — but confirm Mobile Authenticator logs you in first, or you can lock yourself out of the portal. If you can't change the account today, both remaining options need you at login: leaveTWOFACTOR_CODEunset and approve the IB Key push on your phone, or finish the login over VNC. Background: #7, #20, #33, #37.
| Var | Notes |
|---|---|
USE_IBG_CONTROLLER |
yes runs ibg-controller. Unset, the image starts the IBC build from its base image instead. |
| Var | Notes |
|---|---|
TWS_USERID / TWS_PASSWORD |
IB credentials |
TWS_USERID_PAPER / TWS_PASSWORD_PAPER |
Paper credentials, used when TRADING_MODE=paper |
TWOFACTOR_CODE |
The base32 secret from IBKR's Mobile Authenticator enrolment — not a generated six-digit code. Validated at startup; a wrong-shaped value exits with ALERT_2FA_FAILED reason="TWOFACTOR_CODE is not a base32 secret". Leave unset for IB Key push. |
TWS_PASSWORD_FILE, TWOFACTOR_CODE_FILE |
Docker-secrets variants: read the value from a file |
TRADING_MODE |
live, paper (default), or both. In both, a mode that fails to log in relaunches its own Gateway up to three times without touching the other. If that fails, live takes priority: a dead live controller stops the container so its restart policy brings both back, while a dead paper controller leaves live running and waits for the next restart. Run two single-mode containers if the modes must stay fully independent. |
TWOFA_DEVICE |
Multi-method accounts only: names the method TWOFACTOR_CODE satisfies (default Mobile Authenticator app). Matched against Gateway's device list without regard to case or spacing; if nothing matches, the log lists the entries it found. Ignored on single-method accounts. Setting this rarely makes a two-method account work unattended — on most accounts we've seen, IBKR kicks the session when the method is switched mid-login. See 2FA. |
PASSKEY_AUTHENTICATE |
yes makes the controller press Authenticate on Gateway's passkey prompt; an authenticator running alongside the container completes the WebAuthn ceremony. Unset, a passkey prompt fails loudly. Needs an amd64 base and extra browser libraries; see 2FA. |
| Var | Notes |
|---|---|
TWS_SERVER / TWS_SERVER_PAPER |
IBKR regional server hostname — see docs/BOOTSTRAP.md |
GATEWAY_OR_TWS |
gateway (default) or tws. TWS is experimental: this image ships IB Gateway only, so TWS means your own install. The controller then checks TWS's API port: API_PORT when set (the image's scripts export it), otherwise 7496 live / 7497 paper |
| Var | Notes |
|---|---|
EXISTING_SESSION_DETECTED_ACTION |
primary (default) / primaryoverride / secondary / manual |
TWOFA_EXIT_INTERVAL |
Seconds to wait for the 2FA dialog (default 120) |
TWOFA_TIMEOUT_ACTION |
On timeout: exit, restart, or none (default) |
RELOGIN_AFTER_TWOFA_TIMEOUT |
yes/no: re-drive the login form once before the timeout action |
BYPASS_WARNING |
Extra disclaimer button labels to auto-dismiss (comma/semicolon-separated). Bare OK is refused — it cancels in-progress logins. |
TWS_COLD_RESTART |
yes skips the warm-state copy and cold-starts Gateway |
TWS_ACCEPT_INCOMING |
What to do with the "Accept incoming connection" dialog Gateway or TWS shows when an API client connects from an address outside Trusted IPs: manual (default) leaves it to you, accept / reject answers it, as IBC's AcceptIncomingConnectionAction did. Adding your clients to Trusted IPs is safer than accept, which lets any client that can reach the port connect |
| Var | Notes |
|---|---|
TWS_MASTER_CLIENT_ID |
Master API client ID |
READ_ONLY_API |
yes/no |
AUTO_LOGOFF_TIME / AUTO_RESTART_TIME |
HH:MM / HH:MM AM/PM. Gateway shows one field or the other depending on account state; set both vars and the controller handles whichever is displayed. AM times don't work yet: Gateway keeps AM/PM in a separate control the agent can't reach, so an AM value is stored as PM and ALERT_CONFIG_NOT_APPLIED fires every login. Use a PM time or an external scheduler. Gateway's own logoff timer is unreliable, so if the same Gateway is still running 5 minutes after AUTO_LOGOFF_TIME, the controller logs it off itself (LOGOFF_BACKSTOP). It only does this for a time written with AM or PM (05:01 PM) that Gateway showed back on that login, for a session that logged in before that time, within an hour after it, and only when AUTO_RESTART_TIME is unset and Gateway's time zone (TIME_ZONE) matches the container's (TZ) — so it never adds a logoff Gateway wasn't already scheduled to do. With no AUTO_LOGOFF_TIME, nothing changes. |
AUTO_RESTART_ADOPT |
Default yes: when Gateway restarts itself at AUTO_RESTART_TIME, adopt the new JVM instead of launching a second one. Gateway normally carries the session across, so no login and no 2FA; when it doesn't, the controller re-drives login on the adopted JVM. no restores always-relaunch. Wait budget: AUTO_RESTART_ADOPT_TIMEOUT_SECONDS (90). |
| Var | Notes |
|---|---|
CONTROLLER_COMMAND_SERVER_PORT |
TCP port for STOP, RESTART, RECONNECTACCOUNT and ENABLEAPI, using IBC's command names. Unset = disabled. IBC's default port was 7462. |
CONTROLLER_COMMAND_SERVER_HOST |
Bind address, default 0.0.0.0 so a published port reaches it. Set CONTROLLER_COMMAND_SERVER_AUTH_TOKEN too: Docker's -p 127.0.0.1:... keeps the host's network out, but not other containers on the same Docker network |
CONTROLLER_COMMAND_SERVER_AUTH_TOKEN |
Optional shared secret; clients send AUTH <token> first. Strongly recommended if the port is reachable beyond localhost. |
| Var | Notes |
|---|---|
CONTROLLER_HEALTH_SERVER_PORT |
HTTP /health port (default 8080 in the shipped image; empty disables) |
CONTROLLER_HEALTH_SERVER_HOST |
Bind address, default 0.0.0.0 |
CCP_MAINTENANCE_RECOVERY_DELAY_SECONDS |
Delay before re-auth inside IBKR's nightly reset window (default 480) |
IBKR_RESET_WINDOWS / IBKR_RESET_TZ |
IBKR's daily reset window (default 23:30-02:00 America/New_York, covering the published North America resets and, most of the year, Europe's). Accounts hosted in Asia: set your region's times |
UPSTREAM_DOWN_GRACE_SECONDS |
How long Gateway may report no connection to IBKR before UPSTREAM_DOWN (default 600) |
UPSTREAM_RELAUNCH |
Whether the controller relaunches a mode stuck in UPSTREAM_DOWN. Default: only when the login is unattended (TWOFACTOR_CODE set); yes / no to force |
CCP_LOCKOUT_MAX_JVM_RESTARTS |
JVM restarts allowed on persistent CCP lockout (default 0 = halt loudly) |
| Var | Notes |
|---|---|
TWS_SETTINGS_PATH |
Gateway settings dir; set per instance by run.sh in dual mode |
GATEWAY_WARM_STATE |
Optional dir copied into the settings dir before launch (seeds jts.ini + autorestart tokens) |
GATEWAY_INPUT_AGENT_JAR / GATEWAY_INPUT_AGENT_SOCKET |
Agent jar / socket path overrides |
CONTROLLER_READY_FILE |
Readiness signal file override |
CONTROLLER_DEBUG |
1 = debug logging |
CONTROLLER_TEST_MODE |
1 = exit right after clicking Log In (smoke tests) |
GET /health returns controller state, JVM liveness, API port status,
and last-auth timestamp — HTTP 200 when logged in and serving, 503
otherwise. GET /ready is a process-liveness probe. The shipped
Dockerfile wires this into a Docker HEALTHCHECK.
Probe the published port, not 4001. Clients reach Gateway through a
socat forwarder on 4003 (live) and 4004 (paper). Gateway's own port can
be fine while every client is cut off, which is what happened when a
forwarder died in the field. /health reports both: api_port_open
for Gateway and socat_port_open for the forwarder. run.sh now
restarts a forwarder that dies, so socat_port_open: false should only
last a few seconds — longer means something is wrong. Expect it to read
false briefly after every login too, since the forwarder starts just
after Gateway is ready.
An open port doesn't mean Gateway is connected to IBKR. When Gateway
loses its upstream link, its API port stays open and clients connect,
but every request times out. The controller reads Gateway's own status
label: upstream_connected in /health. After 10 minutes disconnected
outside IBKR's reset window it reports UPSTREAM_DOWN (503) and logs
ALERT_UPSTREAM_DOWN. If your login runs unattended (TWOFACTOR_CODE
set), it also relaunches that mode, backing off to hourly until Gateway
reads connected; while upstream_recovery_active is true, let it work
rather than restarting the container. With IB Key, a passkey or a VNC
login it only reports, since a relaunch would mean a phone prompt or a
halt. UPSTREAM_RELAUNCH=yes|no overrides either way. A relaunch that
meets a CCP lockout is handled like any CCP lockout (by default a halt).
If anything in your stack restarts unhealthy containers, read this.
When a 2FA failure needs you, the controller halts instead of exiting,
and /health answers 503 — so the shipped HEALTHCHECK marks the
container unhealthy. Plain Docker ignores that, but Kubernetes liveness
probes, Swarm, autoheal sidecars and "restart unhealthy" monitors will
restart it and recreate the login loop the halt prevents. The same goes
for UPSTREAM_DOWN: a restart mid-recovery also logs out the other
mode. Point liveness
at /ready, which stays 200 while the process is deliberately alive,
and keep /health for readiness.
API clients must re-arm their subscriptions after a Gateway restart. A client that only reconnects its socket can report itself connected while market data stays frozen — the subscriptions died with the old JVM. Re-subscribe on reconnect, or watch your own bar timestamps rather than the connection flag.
The logs carry stable ALERT_* tokens (ALERT_2FA_FAILED,
ALERT_LOGIN_FAILED, ALERT_CCP_PERSISTENT, ALERT_PASSWORD_EXPIRED,
ALERT_SHUTDOWN, ...) that monitors can grep regardless of log level.
Token names and keys are a stability contract. Full inventory, field
semantics, and integration examples:
docs/OBSERVABILITY.md.
Operator playbook for failure scenarios (CCP lockout, 2FA failure, JVM
crash): docs/DISCONNECT_RECOVERY.md.
┌────────────────────────────────────────┐
│ Docker container (headless) │
│ │
│ Xvfb :1 ← matchbox WM │
│ │ │
│ ↓ │
│ IB Gateway JVM │
│ └─ -javaagent:gateway-input-agent.jar │
│ │ │
│ ↓ │
│ Unix socket (/tmp/gateway-input-{mode}.sock)
│ ↑ │
│ gateway_controller.py (Python) │
│ ├─ state machine: login → 2FA → │
│ │ config → ready → monitor │
│ ├─ command server (TCP) │
│ └─ /health endpoint │
│ │
└────────────────────────────────────────┘
Gateway's Swing fields reject every external input mechanism
(synthetic X11 events, AT-SPI writes), so a small Java agent
(~1,100 lines, no dependencies) is loaded into Gateway's JVM via
-javaagent: and does the UI work from inside — setText, doClick,
tree/list selection — over a line-based Unix-socket protocol. The
Python controller runs the state machine and never touches the UI
directly. Design history and the reasoning behind each piece:
docs/ARCHITECTURE.md.
The controller doesn't use IBC, oathtool or xdotool. It computes TOTP codes with the Python standard library, and the agent types from inside the JVM.
make # build the agent jar, stage controller into dist/
make test # build checks + full unit suite (stdlib unittest, no pip)
make release VERSION=x.y.z
make install DESTDIR=/home/ibgatewayNeeds make and a JDK 17+. No Maven, no Gradle, no pip. Release
tarballs with an install.sh are attached to each
GitHub release.
CCP LOCKOUT DETECTED / login retries looping. IBKR's auth server
rate-limits fresh logins after failed attempts. The controller detects
it, backs off exponentially (60s → 600s), and retries in-JVM — just
let it run; it typically clears in 5–60 minutes. If you're stuck after
an hour: check the userid matches the trading mode, check
TWS_SERVER against docs/BOOTSTRAP.md, and see
the full playbook.
On images before v0.5.12 this warning was usually a JVM-internal
deadlock, not a real lockout — upgrade.
"Gateway PID unknown (agent never reported one)". The in-JVM agent
didn't start. Check the -javaagent: flag is on the JVM command line,
the socket in GATEWAY_INPUT_AGENT_SOCKET exists, and
/tmp/jvm_console_${TRADING_MODE}.log for agent boot errors.
Container stays up but never becomes ready, log says HALTED.
Deliberate. A 2FA failure that only you can clear — two methods on the
account, a TWOFA_DEVICE that matches nothing, a passkey prompt
without PASSKEY_AUTHENTICATE — stops the controller instead of
exiting, because exiting lets Docker restart it and re-run the same
failing login every few minutes. The ALERT_2FA_FAILED line above the
halt says what to fix. VNC stays reachable, and if you finish the
login by hand within 300 s of the failure the controller picks the
session up and carries on.
"Existing session detected" loops forever. Something else keeps logging in as the same account (another container, TWS on your desktop, the mobile app). Shut the other session down.
"Auto Log Off Time" label not found. Gateway shows either the
logoff or the restart field depending on account state — set both
AUTO_LOGOFF_TIME and AUTO_RESTART_TIME and the controller handles
whichever is displayed.
Supply chain: no third-party dependencies (Python stdlib + one
dependency-free Java file), digest-pinned base image, cosign-signed
release images. Verification recipes and reporting:
SECURITY.md.
Deployment hygiene:
- Command server: set
CONTROLLER_COMMAND_SERVER_AUTH_TOKEN. Without a token, anyone who can reach the port can sendSTOP/RESTART, and publishing it as-p 127.0.0.1:7462:7462still leaves it open to other containers on the same Docker network.RESTARTandRECONNECTACCOUNTare refused while the controller is halted or already relaunching. - Credentials: use
--env-file(mode600) or the_FILEsecrets variants, never-eon the command line. - Logs: the controller redacts account numbers and (v0.6.3+) password
fields at the source, but window titles can still carry your
username, and Gateway's own
launcher.logis not under our control. Review before posting publicly. GATEWAY_WARM_STATEis trusted input — only point it at a directory you own.
- @rlktradewright for IBC, which with its predecessor IBController automated TWS and Gateway for 23 years until its retirement in September 2026. Most of what we know about driving Gateway's dialogs comes from reading it.
- Lcstyle/ibctl for the in-JVM agent idea and the edge-case catalog.
- @gnzsnz for ib-gateway-docker and for steering this tool's architecture in issue #366.