Short answer: Trading bot hosting means a server that keeps a bot running, restarts it safely, keeps its clock accurate and alerts a person when something breaks. A VPS is worth paying for once the bot must run while your machine is off. A lower ping alone is a weak reason: on 5 November 2026, one venue's two API hosts differed by a factor of 28 in TCP connect time but by only 12% in request time.
Check a request timestamp against the exchange's server time: Unix timestamp converter.
No host is named or ranked here, and there are no affiliate links. The Docker files and the shutdown and restart code below ran on 5 November 2026 on Docker 29.4.0 (Docker Desktop for Windows, shared with other containers) against a local mock exchange, so no key or real order was involved. A Linux server was not tested.
Server location does not change where you are or which rules apply. Rules depend on your jurisdiction and each platform's terms. Article 99/A of Capital Markets Law No. 6362 treats activity by foreign crypto-asset platforms directed at Turkish residents as unauthorized. Automated trading can lose money.
Why do you need a VPS for trading, and when not?
You need a server when the bot must keep working while your machine sleeps, reboots or goes offline, and not before.
| Situation | Your own machine? | What matters |
|---|---|---|
| Building and testing on a testnet | Fine | Logs and clean restarts |
| Rules on hourly or daily candles, orders resting on the exchange | Your call: what can an outage cost? | Restart, reconcile, alerts; a stopped bot manages nothing |
| Receiving webhooks from another service | Rarely | A stable public HTTPS address |
| API key restricted by IP | Rarely | A stable outbound address; see API key security |
| Reacting within milliseconds | No, and a VPS may not help | Measure first; a public VPS is not colocation |
Size it by measuring with docker stats: the drill bot and the mock exchange here used about 15 MiB each; a bot holding full order books needs far more. Choose a location for reliability and measured latency, never for access. A VPS is not a way round a geographic block, KYC or sanctions: if an exchange returns 403 or 451 from a location, the service is not available there, so stop.
Latency trading bot checks: what a ping really measures
A ping or TCP connect to a public exchange hostname often measures only the distance to the nearest CDN edge, so measure the request itself before paying for a location.
Setup: one desktop PC in Turkey, 5 November 2026 from 19:45 UTC; per host, 50 new TCP connections, then 50 requests on one reused HTTPS connection; public time endpoints, no account. Not a server test or a venue comparison; hosts are alphabetical.
| Host | Edge seen | TCP connect | Request, reused connection |
|---|---|---|---|
api.binance.com | CloudFront, Istanbul | 10.4 (20.5) | 255.4 (266.3) |
api.bybit.com | CloudFront, Istanbul | 9.9 (22.9) | 190.3 (200.6) |
api.kraken.com | Cloudflare, Amsterdam | 47.9 (54.2) | 75.0 (91.2) |
data-api.binance.vision | None seen; AWS Tokyo addresses | 289.2 (302.3) | 285.6 (289.1) |
openapi.okx.com | Cloudflare, Istanbul | 46.2 (54.0) | 293.4 (354.3) |
The two Binance hosts show the trap: TCP connect suggests one is 28 times closer, yet requests differ by 12%. X-Cache: Miss from cloudfront shows the edge forwarding the call, and Binance documents the second host for public market data only. Repeat this from any candidate server:
curl -s -o /dev/null -o /dev/null -w 'connect=%{time_connect} first_byte=%{time_starttransfer}\n' \
https://testnet.binance.vision/api/v3/time https://testnet.binance.vision/api/v3/time
curl -s -D - -o /dev/null https://testnet.binance.vision/api/v3/time | grep -i -E 'x-amz-cf-pop|x-cache'The second line shows the reused connection: connect=0.000000. Neither number is order acknowledgment time: an order also passes authentication and matching, which needs a real key and was not run.
Crypto trading bot Docker setup: Dockerfile and compose file
Run as a non-root user on a read-only filesystem, keep state on a named volume, and pass the key as a mounted file so that it never enters the image.
FROM python:3.12-slim
RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin bot \
&& mkdir /data && chown bot /data
WORKDIR /app
COPY bot.py rules.py ./
USER bot
ENV PYTHONUNBUFFERED=1
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request as u; u.urlopen('http://127.0.0.1:8080/', timeout=2)"]
CMD ["python", "bot.py"]services:
bot:
build: .
restart: on-failure:5
stop_grace_period: 30s
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
environment:
EXCHANGE_URL: http://exchange:9000 # a testnet URL; live only by explicit change
API_KEY_FILE: /run/secrets/exchange_api_key
ON_STOP: leave # or cancel: your written decision
secrets: [exchange_api_key]
volumes: ["bot-data:/data"]
logging:
driver: json-file
options: {max-size: "10m", max-file: "5"}
depends_on: [exchange]
exchange: # local mock for the test; remove for real use
image: python:3.12-slim
command: ["python", "/m/mock_exchange.py"]
volumes: ["./mock_exchange.py:/m/mock_exchange.py:ro"]
secrets:
exchange_api_key:
file: ./secrets/exchange_api_key.txt # on the host, outside git
volumes:
bot-data:In the running container: id returned uid 10001, writing to /app failed, and the mock key appeared 0 times in env, docker inspect, docker history and the logs. Docker's Compose documentation warns that API keys in environment variables risk exposure, and OWASP's Secrets Management Cheat Sheet advises against them unless other methods are impossible. Compose bind-mounts the secret file, which showed as root-owned mode 777 on this host, so check its owner and mode on a Linux server. List secrets/ and .env in .dockerignore. A secrets manager is stronger still, as the API key guide covers.
Graceful shutdown and a restart that reconciles first
On SIGTERM stop sending new orders and exit cleanly; on every start, compare the exchange with your journal before sending anything.
Excerpt of bot.py, standard library only. Not shown: main() runs reconcile() before the loop and, on a problem, logs reconcile_break with an alert and waits for SIGTERM without trading; watchdog() exits with code 70 when the loop stalls for 30 seconds, because an unhealthy status restarts nothing; api() has a 10-second timeout and raises on HTTP errors; lookup() returns an order's status, or never_arrived on the mock's 404 (check your venue's code); record() appends a JSON line with fsync; journal() replays the file into the latest state per ID; cancel() returns the new status, or alerts and returns None if the venue refuses.
UNSETTLED = {"sending", "canceling", "NEW", "PARTIALLY_FILLED"} # as your venue spells them
def send(order):
cid = uuid.uuid4().hex
record({"cid": cid, "state": "sending"}) # written before the request leaves
status = api("POST", "/order", {**order, "cid": cid}).get("status")
if not status: # odd reply: leave "sending" for reconcile
raise RuntimeError(f"no status for {cid}")
record({"cid": cid, "state": status})
log("order_sent", cid=cid, status=status)
def reconcile(): # returns a problem, or None
known = journal()
live = api("GET", "/open-orders")
strangers = [o.get("cid") or "(no client ID)" for o in live
if known.get(o.get("cid"), {}).get("state") not in UNSETTLED]
if strangers: # a list, so two missing IDs count twice
return f"open orders the journal does not expect: {strangers}"
for cid, entry in known.items():
if entry["state"] in UNSETTLED: # no reply yet, or open when the bot stopped
status = lookup(cid)
if not status:
return f"no status for {cid}"
if status == entry["state"]:
continue
record({"cid": cid, "state": status})
log("reconcile_resolved", cid=cid, status=status)
return None
def shutdown():
state.update(ready=False, reason="stopping")
on_stop = os.environ.get("ON_STOP", "leave")
log("shutdown_begin", on_stop=on_stop)
if on_stop == "cancel":
mine = journal()
try:
for order in api("GET", "/open-orders"):
cid = order.get("cid")
if cid in mine: # never cancel an order this bot did not place
record({"cid": cid, "state": "canceling"})
record({"cid": cid, "state": cancel(cid) or "canceling"})
except Exception as err: # network, JSON or odd replies
log("cancel_incomplete", error=str(err), alert=True)
log("shutdown_done")The journal entry is written before the request leaves, so a crash at the worst moment leaves a record to look up by client order ID. On a real venue, an empty lookup after a timeout is not final: Binance's request security page says a timeout reply does not always mean the request failed and says to query its status, so keep checking by client order ID. Whether a stop cancels open orders is your written decision, because canceling a resting exit during a deploy leaves a position unprotected; either way, the bot cancels only orders in its own journal. Positions and balances need the same check, as position reconciliation explains; other failure modes are in trading bot development.
Docker restart policy: what the drills showed
Docker restarts a process that exits with an error, and little else, so the bot has to turn a hang into an exit.
| Drill | Result |
|---|---|
docker stop, 11 times | SIGTERM reached Python as PID 1: 0.9 to 2.9 seconds, exit code 0, both shutdown lines logged; with ON_STOP: cancel it canceled only its own orders. Under sh -c "python ..." the shell stayed PID 1 and Python never got the signal: 11 seconds at a 10-second timeout, then exit code 137 |
| Reply delayed past the client's 10-second timeout | TimeoutError, exit code 1, one restart, then reconcile_resolved by client order ID. 16 order requests, 16 unique IDs: no duplicate |
docker kill during a delayed reply | Exit code 137, no restart, as for a manual stop; a manual start resolved it the same way (18 requests, 18 unique IDs) |
| Loop hung by a test hook | stalled_exit after about 36 seconds, one restart, orders resumed |
| Unexpected open orders at start: one placed by hand, two without a client ID | reconcile_break with an alert listing all three; no new order in 80 seconds; health unhealthy, no restart |
| Exchange unreachable at start | Exit code 1, five restarts, then stopped for good, all within about 36 seconds |
From the documents: a restart first waits 100 ms and the wait doubles up to a minute, so on-failure:5 can run out within a short outage, as it did here in 36 seconds; alert on it. on-failure does not restart after the daemon restarts, while always and unless-stopped differ on containers you stopped by hand, so a reboot needs your decision; neither was tested, as other containers shared this host. Restart loops also spend connection limits, such as Binance's 300 WebSocket connection attempts per 5 minutes per IP.
Clock sync for signed requests
Sync the host clock with NTP and alert on drift, because exchanges reject signed requests whose timestamp falls outside a narrow window.
Binance's documentation, last modified 30 October 2026, accepts a signed request only if its timestamp is less than 1 second ahead of server time and no more than recvWindow behind it (default 5,000 ms, maximum 60,000); Bybit's rule has the same shape and default. Containers read the host's clock, so sync on the host: chrony's makestep steps the clock at startup, and slewing corrects it afterwards, and chronyc tracking reports the offset as System time. A time endpoint is only a rough check: at 19:53 UTC on 5 November 2026 this PC read about 1,045 ms behind the Spot Testnet with a 255 ms round trip, so the true gap was 915 to 1,170 ms: accepted, but worth an alert.
Hypothetical example. With the default window, a clock 6 seconds behind is rejected, and raising recvWindow to 10,000 ms hides the fault. A clock 1.5 seconds ahead is rejected at any window, so alert on drift well under 1 second.
Monitoring that reaches a person
A health endpoint on a dead server tells nobody anything, so the bot must check in with a monitor on another machine that alarms when it goes quiet.
- Log alerts. Route lines carrying
alert: true, such as a reconcile break, to a channel someone reads. - Restart count. Alert on a rising
RestartCount, since a quiet restart loop looks healthy. - Drill it. Stop the bot on purpose and time how long a person takes to hear.
Who gets the alert and what they do are in trading bot risk management. By default, software commissioned from Fincode runs on your own server or cloud account with your own keys, as the algorithmic trading page says; naming exchanges here does not mean Fincode integrates them or has built on them. Tests show how software behaves, not future results.
Frequently asked questions
Do I need a VPS to run a trading bot?
Not to build or test one. You need one when the bot must run while your machine is off, or when webhooks or IP-restricted keys need a stable address.
Will a VPS closer to the exchange make my bot faster?
Measure it. Many API hostnames sit behind CDNs, so a ping may only reach the nearest edge. Time real requests from the candidate server.
Should I run a trading bot in Docker?
Docker gives repeatable builds, restart policies and isolation, but a restart must still reconcile orders before trading.
Can a VPS in another country get around an exchange block?
No. Server location does not change where you are or which rules apply. If an exchange returns 403 or 451, the service is not available there, so stop.
Sources
- Docker documentation: Start containers automatically (restart policies) (opens in a new tab)
- Docker documentation: docker container run (restart delay) (opens in a new tab)
- Docker documentation: Dockerfile reference (HEALTHCHECK, ENTRYPOINT, USER) (opens in a new tab)
- Docker documentation: docker container stop (opens in a new tab)
- Docker documentation: Secrets in Compose (opens in a new tab)
- OWASP: Secrets Management Cheat Sheet (opens in a new tab)
- chrony documentation: chrony.conf (makestep) (opens in a new tab)
- chrony documentation: chronyc (tracking) (opens in a new tab)
- Binance Spot API documentation: Request security (timing, recvWindow) (opens in a new tab)
- Bybit V5 API: Integration guidance (timestamp and recv_window) (opens in a new tab)
- Binance Spot API documentation: WebSocket Streams (connection limits) (opens in a new tab)
- Capital Markets Law No. 6362, consolidated text, Article 99/A (Turkish) (opens in a new tab)
- Binance Spot API documentation: General API information (base endpoints) (opens in a new tab)
- OKX API v5 documentation (production REST host) (opens in a new tab)
- AWS: published IP address ranges (ip-ranges.json) (opens in a new tab)
This guide is general information about software and markets, not investment, legal or tax advice. Automated trading can lose money, past or backtested performance does not guarantee future results, and the rules that apply depend on your jurisdiction and your exchange's terms. Sources, checks and corrections: see the editorial policy.