A production-style, real-time market data streaming server
Built with Go · WebSockets · Redis Pub/Sub · Binance Streams
WebSocket clients subscribe to crypto symbols and receive live market updates with automatic feed management, reconnection handling, metrics, and horizontal scalability.
|
|
┌──────────────────┐
│ Binance WS Feed │
└─────────┬────────┘
│ live trade stream
▼
┌────────────────────────────┐
│ Binance Ingestion Service │
│ │
│ · Dynamic Feed Management │
│ · Reconnection Logic │
└─────────────┬──────────────┘
│ publish
▼
Redis Pub/Sub Channel
│ subscribe
▼
┌───────────────────────────┐
│ Redis Subscriber │
└─────────────┬─────────────┘
│ broadcast
▼
┌───────────────────────────┐
│ Hub │
│ · Register / Unregister │
│ · Broadcast Events │
│ · Symbol Tracking │
└────────┬──────────┬───────┘
│ │
▼ ▼
Client A Client B
BTCUSDT ETHUSDT
stock-sim/
├── cmd/
│ └── main.go # Entry point
│
├── internal/
│ ├── domain/
│ │ ├── binance.go
│ │ ├── client.go
│ │ ├── feed_command.go
│ │ ├── market_event.go
│ │ ├── stock.go
│ │ └── subscription.go
│ │
│ ├── hub/
│ │ └── hub.go # Client registration & broadcasting
│ │
│ ├── market/
│ │ ├── binance.go # Binance WS ingestion
│ │ └── subscriber.go # Redis subscriber
│ │
│ ├── metrics/
│ │ └── metrics.go # Custom HTTP metrics
│ │
│ ├── redis/
│ │ └── client.go # Redis client wrapper
│ │
│ └── websocket/
│ └── pumps.go # Read/write pumps per client
│
├── Dockerfile
├── docker-compose.yml
├── .env
└── README.md
Client ──► WebSocket ──► Hub
│
SymbolCount == 1?
│
└──► FeedCommand(subscribe) ──► Binance Feed
Binance ──► Ingestion Service ──► Redis Publish
│
Redis Subscribe
│
Hub
│
Subscribed Clients
| Environment | URL |
|---|---|
| Local | ws://localhost:8080/ws |
| Production (HTTPS) | wss://your-domain/ws |
{
"type": "subscribe",
"symbol": ["BTCUSDT", "ETHUSDT"]
}{
"type": "unsubscribe",
"symbol": ["ETHUSDT"]
}{
"symbol": "BTCUSDT",
"price": 63542.12
}GET /metrics
Example Response:
{
"active_symbols": 2,
"binance_reconnects_total": 0,
"connected_clients": 5,
"messages_received_total": 1245,
"messages_sent_total": 9860,
"top_symbols": [
{ "symbol": "BTCUSDT", "count": 3 },
{ "symbol": "ETHUSDT", "count": 2 }
]
}The Stock Simulator is optimized for high concurrency and has been load-tested with 10,000 concurrent WebSocket clients to verify message delivery and system stability under high-throughput conditions.
- Concurrently Connected Clients: 10,000
- Subscription: Every client successfully subscribes to the live
BTCUSDTstream. - Heartbeat & Connection Management: Keeps connections active without drops, responding to server-initiated pings within the timeout threshold.
- Implementation: Driven by the custom load-test script in internal/test/load.go.
A visualization of the WebSocket load test running against the dashboard:
load_testing.mp4
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP/WS server port |
REDIS_ADDR |
localhost:6379 |
Redis connection address |
AWS example:
REDIS_ADDR=<aws-redis-host>:6379# Build the images
docker compose build
# Start services
docker compose upRedis and the Go server both start automatically via Docker Compose.
# Install dependencies
go mod tidy
# Start the server
go run ./cmd/main.go| Endpoint | URL |
|---|---|
| Server | http://localhost:8080 |
| Metrics | http://localhost:8080/metrics |
| WebSocket | ws://localhost:8080/ws |
The project runs on a single EC2 instance: Docker Compose runs the Go server and Redis, and Nginx sits in front to handle public traffic and the WebSocket upgrade.
Client (Postman / browser / wscat)
│ ws://<EC2_PUBLIC_IP>/ws
▼
AWS Security Group ── allows 22 (my IP only), 80, 443
▼
Nginx (port 80/443) ── proxies HTTP + WebSocket upgrade
▼
stock-sim container (127.0.0.1:8080, not public)
▼
redispubsub container (internal Docker network only, not public)
▼
Binance WebSocket stream (outbound)
Stock Simulator running on an AWS EC2 instance:
Postman WebSocket client connected to the deployed server:
| Item | Choice | Notes |
|---|---|---|
| Region | A region where Binance is reachable (e.g. Mumbai ap-south-1) |
Binance blocks some countries, such as the US |
| OS | Ubuntu Server LTS | |
| Instance | t3.micro (demo) |
1 GB RAM: add swap before building (see step 5) |
| Disk | 16 GB recommended | Docker images use several GB |
| Key pair | RSA .pem |
Keep it private |
Security group rules
| Port | Source | Purpose |
|---|---|---|
| 22 | My IP | SSH administration |
| 80 | Anywhere (0.0.0.0/0) |
HTTP and WebSocket through Nginx |
| 443 | Anywhere (0.0.0.0/0) |
HTTPS / wss:// (optional) |
| 6379, 8080 | not opened | Redis and the app stay private |
Never open 6379 to the internet. An exposed Redis server with no password gets attacked quickly.
ssh -i your-key.pem ubuntu@<EC2_PUBLIC_IP>The public IP changes every time the instance is stopped and started, so copy the current one from the EC2 console.
sudo apt update
sudo apt install -y docker.io docker-compose-v2 git
sudo usermod -aG docker ubuntu
exitReconnect with SSH so the Docker group change takes effect.
git clone https://github.com/prathamesh1301/Stock-Simulator.git
cd Stock-Simulator
touch .envThe default docker-compose.yaml publishes Redis on port 6379. For the server, replace it so Redis stays on the internal Docker network, the app is reachable only by Nginx, and containers restart automatically:
cat > docker-compose.yaml <<'EOF'
services:
redis:
image: redis:7-alpine
container_name: redispubsub
restart: unless-stopped
app:
build: .
container_name: stock-sim
env_file:
- .env
environment:
- REDIS_ADDR=redispubsub:6379
ports:
- "127.0.0.1:8080:8080"
depends_on:
- redis
restart: unless-stopped
EOF| Change | Reason |
|---|---|
No ports on Redis |
The app reaches it as redispubsub:6379 inside Docker; nothing public needs it |
127.0.0.1:8080:8080 |
Only Nginx on the same machine can reach the app |
restart: unless-stopped |
Containers come back after a crash or reboot |
redis:7-alpine |
Pinned version instead of latest |
The Go compiler can be killed on a 1 GB instance (signal: killed). A swap file prevents that:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
free -hCheck that Binance is reachable from the server, then start everything:
curl -s https://api.binance.com/api/v3/ping # should print {}
docker compose up -d --build
docker compose ps # both containers running
curl http://localhost:8080/metrics # metrics JSONsudo apt install -y nginx
sudo rm -f /etc/nginx/sites-enabled/default
sudo tee /etc/nginx/conf.d/stock-sim.conf > /dev/null <<'EOF'
server {
listen 80;
server_name _;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
EOF
sudo nginx -t
sudo systemctl reload nginxThe Upgrade and Connection headers let the WebSocket handshake pass through, and the long timeouts stop Nginx from closing idle connections after 60 seconds.
Use the public address with no port number:
| Check | Address |
|---|---|
| Metrics | http://<EC2_PUBLIC_IP>/metrics |
| WebSocket | ws://<EC2_PUBLIC_IP>/ws |
With Postman: New → WebSocket → enter ws://<EC2_PUBLIC_IP>/ws → Connect → send:
{ "type": "subscribe", "symbol": ["BTCUSDT", "ETHUSDT"] }With wscat:
npm install -g wscat
wscat -c ws://<EC2_PUBLIC_IP>/wsLive {"symbol": "...", "price": ...} messages should start arriving (see the Postman screenshot above).
Point a domain (for example a free DuckDNS name) at the instance, set it as server_name in the Nginx config, then:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d your-domainClients then connect with wss://your-domain/ws. Open port 443 in the security group first.
| Task | Command (run in the project folder) |
|---|---|
| View app logs | docker compose logs -f app |
| Deploy an update | Push to main — GitHub Actions handles the rest automatically |
| Restart | docker compose restart |
| Stop everything | docker compose down |
| Free disk space | docker image prune -f |
| Symptom | Likely cause and fix |
|---|---|
signal: killed during build |
Out of memory: add the swap file from step 5, or build on a larger instance |
env file .env not found |
Run touch .env in the project folder |
docker: permission denied |
Reconnect over SSH after the usermod command |
/metrics works on the server but times out from outside |
Port 80 is missing from the security group, or the public IP changed |
| WebSocket connects, then drops after about a minute | The Nginx Upgrade headers or proxy_read_timeout are missing |
| Connected but no prices | Check docker compose logs app for Binance errors, such as a region block |
502 Bad Gateway |
The containers are not running: check docker compose ps |
- Stop the instance when you are not using it. The public IP changes on restart, and the containers start again on their own.
- Terminating the instance deletes its disk. Redis holds only live pub/sub traffic, so nothing important is lost.
Every push to main automatically tests, builds, and deploys the application to the AWS EC2 instance via GitHub Actions — no manual steps required.
The badge below reflects the live status of the last workflow run directly from GitHub Actions:
Click the badge to open the full run history, logs, and per-step output.
GitHub Actions run — all steps passing:
git push → main
│
▼
┌─────────────────────────────────────────┐
│ GitHub Actions Runner │
│ │
│ 1. go test ./... (all tests) │
│ 2. go build ./... (compile) │
│ 3. docker build (image) │
│ 4. docker save → .tar (export) │
│ 5. SSH → copy .tar + compose to EC2 │
│ 6. SSH → docker load + compose up │
└─────────────────────────────────────────┘
│
▼
EC2: new container running, old one replaced
| Event | Test & Build | Deploy to EC2 |
|---|---|---|
Push to main |
✅ | ✅ |
Pull request to main |
✅ | ❌ |
Manual (workflow_dispatch) |
✅ | ✅ |
Pull requests only run tests and build — they never touch the live server.
| Step | What it does |
|---|---|
| Checkout | Pulls the repository code |
| Set up Go | Installs the Go version from go.mod, with module cache |
| Run tests | go test ./... — fails fast if any test breaks |
| Build Go app | go build ./... — validates the binary compiles cleanly |
| Build Docker image | Builds stock-simulator-app:latest from the Dockerfile |
| Save image | docker save exports the image to a .tar archive |
| Configure SSH | Writes the private key and known_hosts from GitHub Secrets |
| Copy to EC2 | Streams the .tar and docker-compose.yaml to the instance over SSH |
| Deploy | docker load + docker compose up -d --no-build --force-recreate app |
Set these in Settings → Secrets and variables → Actions before the first run.
| Name | Type | Description |
|---|---|---|
EC2_SSH_PRIVATE_KEY |
Secret | Private key (ed25519 .pem) used to SSH into the instance |
EC2_KNOWN_HOSTS |
Secret | Output of ssh-keyscan <EC2_PUBLIC_IP> — prevents MITM |
EC2_HOST |
Secret | Public IP or DNS of the EC2 instance |
EC2_USER |
Variable | SSH username (e.g. ubuntu) |
EC2_APP_DIR |
Variable | Absolute path to the project folder on EC2 (e.g. /home/ubuntu/Stock-Simulator) |
The Docker image is built entirely on the GitHub Actions runner and shipped to EC2 as a .tar file. The EC2 instance only needs to:
docker loadthe pre-built imagedocker compose up -d --no-build --force-recreate app
This means the instance never needs Go, Git, or enough RAM to compile — making t3.micro (1 GB) a viable production target.
If the Binance connection drops:
- Reconnect automatically
- Restore the WebSocket connection
- Re-subscribe to all active symbols
- Continue streaming — zero server restarts needed
On SIGINT / SIGTERM:
- Stop accepting new requests
- Cancel background goroutines
- Close WebSocket connections
- Close Redis connections
- Exit cleanly ✅
| Layer | Technology |
|---|---|
| 🐹 Language | Go |
| 🔌 WebSockets | Gorilla WebSocket |
| 📈 Market Data | Binance Streams |
| 📨 Message Broker | Redis Pub/Sub |
| ⚡ Concurrency | Goroutines + Channels |
| 🐳 Containerization | Docker |
| ☁️ Deployment | AWS EC2 |
| 🔄 CI/CD | GitHub Actions |
| 📊 Metrics | Custom HTTP Metrics |
Built to learn and demonstrate real-world event-driven backend architecture using Go.
⭐ Star this repo if you found it helpful!


