Running MetaTrader 5 in Docker with Wine: what it took

7 min readMetaKit

The official MetaTrader5 Python package runs on Windows and talks to a terminal on the same machine. That's the whole deployment model, and it's fine for one trader with one VPS. We needed to run a lot of accounts, on Linux hosts, each one isolated from the others, and add capacity by adding machines.

So every MetaKit account runs MetaTrader 5 in Docker, under Wine, in its own container. This is the engineering post about what that involved, the parts that hurt, and the honest answer to "should I just do this myself".

Why one container per account

Three reasons, in order of how much they mattered.

Isolation. An MT5 terminal is one login. If a broker disconnects it, if a history sync hangs, if Wine decides today is the day, one account is affected and its neighbours don't notice. A shared terminal turns every one of those into an incident for everyone on the box.

Linux hosts. Everything else in our stack is Linux. A Windows VPS per customer doesn't automate, doesn't schedule, and doesn't fit in a container orchestrator. Wine lets the terminal be a process on a Linux host like any other, with the usual tooling around it.

Horizontal scaling. A container is a unit you can place. When capacity runs out you add a host, not a bigger Windows machine. The API layer keeps a mapping from account to container and forwards requests; nothing above that layer knows or cares which host a terminal is on.

The MetaTrader 5 Docker stack, from the outside in

Nothing here is exotic on its own. The combination is the work.

A Linux base with Wine installed. Inside the Wine prefix, a Windows build of Python, because the MetaTrader5 pip package is a native Windows module and won't import under Linux Python. The package only ships wheels for specific Python versions, so the Python version is pinned to the newest one that has a wheel, and bumping it breaks pip install until MetaQuotes publishes a new one. There are public base images that bundle Wine with a Windows Python; we started from one of those rather than building the prefix by hand.

The MT5 terminal, installed into that prefix at image build time. An X server: Xvfb for a virtual display, plus a minimal window manager, because the terminal is a GUI application and expects both. A small Python HTTP service running under the Windows Python that wraps the SDK: health, account info, symbols, deal history, ticks. The MetaKit API talks to that service over a port mapped per container; nothing else does.

Credentials arrive as environment variables at container start. The container writes them into the terminal's startup config, launches the terminal, waits for login, then starts the HTTP service, which attaches to the already-connected terminal.

That's the whole shape. One process tree per account, one port, one login.

The things that bit us

This is the section people actually search for. Roughly in the order we hit them.

The image is about 4 GB. Wine, a Windows Python, and the terminal add up to that, and there's no trimming it meaningfully. We tried. Accept it and make sure your registry and hosts are sized for it.

First boot used to install the terminal. The installer runs under Wine and takes minutes, which is fine once and terrible per container. Moving the install to build time turned container start from minutes into seconds. Related gotcha: white-label broker builds install into a broker-named folder, so locate the executable at start rather than assuming the standard path.

It wants a display even when nobody's looking. Without an X server the terminal won't start. Without a window manager, dialogs open in strange places and some never get focus. Xvfb plus a lightweight window manager fixed both. The GLX extension matters too; the terminal is happier with it.

Auto-login is a config-file problem. The terminal doesn't accept credentials on the command line. It reads a startup .ini passed with the /config switch, so the container generates that file at launch from the environment. Two traps inside this one: a config path containing spaces gets mangled crossing the shell-to-Wine boundary and fails with a "cannot load config" message that has a stray quote in it (write it to a space-free path), and without /portable mode the login is loaded into the Navigator without actually connecting.

Algo Trading is also a config-file problem. The SDK's order functions fail with "AutoTrading disabled by client" unless the terminal's Algo Trading toggle is on, and there's no API to flip it. The switch lives in the same startup .ini, under the Experts section. The wrong section name is accepted silently and does nothing, which cost us longer than we'd like to admit. Only followers need it, since the API doesn't place trades; the copier engine does.

A fresh install doesn't know your broker. The terminal ships knowing MetaQuotes' own servers and nothing else. Hand it a broker server name and the connection drops with no useful error. The fix is seeding the terminal's server database before launch so broker names resolve. And use the name, not a resolved address: some white-label brokers refuse connections from a raw IP and accept the same server by name.

The terminal is single-threaded. The SDK isn't safe to call concurrently, so the HTTP service serialises calls behind a lock. Also, if you reach for gunicorn out of habit: it's Unix-only and you're running Windows Python, so you need a WSGI server that works there.

Updates break things, from both sides. MT5 auto-updates its build. Wine releases change behaviour. Either one can turn a working image into a broken one overnight, and you don't get to choose the day. Pin the Wine version, control when the terminal updates, and test each build before it reaches a customer. The build 6060 changes were a recent example of a release that mattered.

Memory, not CPU, is the scaling constraint. Each container is a full terminal plus Wine plus a Python process plus a virtual display. Budget a real amount of RAM per account and a first-connect grace period for health checks of a few minutes, because login and history sync take that long on a cold start.

A VNC side-channel is invaluable. We run a VNC server against the virtual display, reachable only internally, so a human can watch the actual terminal when something's wrong. Half of the weird bugs turned out to be a modal dialog waiting for a click that was never going to come. Being able to see it is the difference between a five-minute fix and a day of log reading.

What we deliberately don't do

No shared terminal. The terminal can hold several accounts in its Navigator, but only one is logged in at a time and switching is a re-login. Even if it weren't, one customer's terminal should not be another customer's attack surface. One account per container, always.

No public exposure of the container service. The HTTP wrapper has no authentication of its own; it sits behind the API, which does. The mapping from account to container is not something a client ever sees.

The public order endpoints sit in front of that wrapper. The same /trade/* calls the copier uses back POST /v1/accounts/{id}/orders and friends, but only after the API has checked the key scope, the slot tier, the connection state, and the volume and stops against the symbol spec. The container never sees a request the API would have refused, and a hard 10 s timeout on OrderSend means a wedged terminal surfaces as a 504 rather than a hung socket.

Should you do this yourself

Honestly: yes, if you have one account and you enjoy this kind of thing. A weekend gets you a container that logs in and answers account_info(). It's satisfying, and you'll understand the terminal better than most people who use it every day. The MetaTrader5 package vs REST post covers what you get from talking to the SDK directly.

At two accounts you'll write a script to start containers. At ten you'll want health checks and restarts. At fifty, someone is on call for Wine. Broker server lists change, builds update, a container OOMs at 3am, and the person who has to care is you. That's not a criticism of the approach; it's what running terminals at scale costs, and it's the entire reason MetaKit exists. We took the ops job so an account is a POST /v1/accounts and a status to poll.

If you build it, keep the VNC port. You'll need it sooner than you think.