Cross-Chain DEX Arbitrage Scanner
Build a containerized DEX arbitrage scanner with Docker and GitHub Actions CI/CD.
Introduction
30 Second Summary
The same token can trade at slightly different prices on different exchanges across different blockchains. Traders who spot these gaps first can buy low on one chain and sell high on another.
In this project, you will build a command-line arbitrage scanner that fetches real-time prices from decentralized exchanges, detects cross-chain price discrepancies, and outputs a ranked table of opportunities. You will then containerize the app with Docker and automate its build and deployment with a CI/CD pipeline.
What You'll Build
A Node.js application that queries the DexPaprika API for token prices across Ethereum, BSC, Polygon, and Arbitrum, compares them to find arbitrage opportunities, and ships inside a production-ready Docker container with a fully automated GitHub Actions pipeline.
By the end of this project, you'll have:
- A working arbitrage scanner that fetches and compares cross-chain DEX prices in real time.
- A multi-stage Dockerfile optimized for production that packages your app into a lean, portable container.
- A GitHub Actions workflow that automatically tests, builds, and pushes your Docker image to Docker Hub on every commit.
- Secret Mission: add real-time price streaming with Server-Sent Events to detect arbitrage opportunities as they happen.
Are there any prerequisites?
You should have basic JavaScript knowledge and be comfortable using a terminal. You will also need Node.js, Docker (via WSL on Windows), and a GitHub account ready before starting.
Set Up Your Project
Before you can scan for arbitrage opportunities, you need a project workspace. This means a dedicated folder, a Node.js project configuration, and version control to track your changes.
In this step, you'll create the project directory, initialize it with npm, lay out the source file structure, and set up Git for version control.
In this step, get ready to:
- Create the project folder and initialize npm.
- Set up the source file structure for the scanner.
- Create a .gitignore and initialize Git for version control.
Create the project folder and initialize npm
Every Node.js project starts with a dedicated folder and a package.json file that describes the project. You'll create both from the terminal.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir arb-scanner
cd arb-scanner
- Confirm the folder exists by running:
ls
You should see an empty output since the folder has no files yet.
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
npm init -y creates a package.json file with default values.
This file is the identity card for your Node.js project. It stores the project name, version, scripts, and dependencies.
You should see a confirmation message showing the generated package.json contents.
Seeing 'npm: command not found'?
This means Node.js is not installed or not on your PATH. Verify by running node --version. You need version 22 or higher.
If you're in WSL, make sure you installed Node.js inside WSL, not just on Windows. Help me fix my Node.js installation in WSL.
Set up the source files and configure package.json
Your scanner will be split across three files inside a src/ folder. Each file handles a different responsibility: configuration, price fetching, and orchestration.
- Create the src/ directory and the three source files by running:
mkdir src
touch src/index.js src/scanner.js src/config.js
What are these files for?
- src/config.js will store the tokens and chains you want to scan.
- src/scanner.js will fetch live prices from the DexPaprika API.
- src/index.js will tie everything together and output the results.
- Confirm the files were created by running:
ls src/
You should see config.js, index.js, and scanner.js listed in the output.
Now you need to update package.json so it points to the correct entry file and includes useful scripts.
- Open the arb-scanner folder in VS Code by running:
code .
- In the VS Code Explorer sidebar, click package.json to open it.
- Select all the existing content and replace it with the following:
{
"name": "arb-scanner",
"version": "1.0.0",
"description": "Cross-chain DEX arbitrage scanner",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"test": "node src/index.js"
},
"keywords": ["blockchain", "arbitrage", "dex", "docker"],
"license": "MIT"
}
What changed from the defaults?
- main now points to src/index.js instead of the root folder.
- scripts.start and scripts.test both run the scanner so you can use them in Docker and CI/CD later.
- keywords and description make the project identifiable.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
'code .' not opening VS Code?
If the code command is not recognized, open VS Code manually. Then press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Shell Command: Install 'code' command in PATH, and press Enter.
Help me set up the VS Code terminal command.
Create .gitignore and initialize Git
A .gitignore file tells Git which files to leave out of version control. You want to exclude installed packages and any secret files.
- In the VS Code Explorer sidebar, click the New File icon at the top of the file list.
- Name the file .gitignore and press Enter.
- Paste the following contents into the file:
node_modules/
.env
What does this exclude?
- node_modules/ is where npm installs packages. It can be regenerated from package.json so it does not belong in version control.
- .env is where you would store secrets like API keys. Even though this project does not need one, it is good practice to exclude it.
- Save .gitignore.
- Initialize the Git repository by running this command in your terminal:
git init
You should see a message saying Initialized empty Git repository in... followed by your folder path.
Seeing 'git: command not found'?
Git needs to be installed in your WSL environment. Run sudo apt install git to install it, then try git init again.
Help me install Git in WSL.
- Verify your project structure looks correct by running:
ls -la
You should see .git/, .gitignore, package.json, and src/ in the output.
✔️ Awesome, I've got everything!
Great. Double check you have saved both package.json and .gitignore before moving on.
ⓧ I'd like to double check the full code
Here is the full package.json:
{
"name": "arb-scanner",
"version": "1.0.0",
"description": "Cross-chain DEX arbitrage scanner",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"test": "node src/index.js"
},
"keywords": ["blockchain", "arbitrage", "dex", "docker"],
"license": "MIT"
}
And here is .gitignore:
node_modules/
.env
Your project skeleton is ready. Next up, you'll fill in config.js and scanner.js to fetch real token prices from DEXs across multiple blockchains.
Build the Price Fetcher
Your project structure is in place. Now you need to connect to real blockchain price data so the scanner has something to compare across chains.
The DexPaprika API provides real-time token prices from decentralized exchanges across 33 blockchains. In this step, you'll configure which tokens and chains to track, then build the functions that fetch live prices from the API.
In this step, get ready to:
- Define the token configuration with contract addresses for each chain.
- Build functions to fetch prices from the DexPaprika API.
- Test the price fetcher with live data.
Define your token configuration
Before the scanner can fetch prices, it needs to know which tokens to look for and where they live on each blockchain. Each token (like WETH or USDC) has a different contract address on every chain.
- Open src/config.js in your code editor and replace the empty file with the following configuration:
const CONFIG = {
// DexPaprika API base URL - no API key required
apiBaseUrl: 'https://api.dexpaprika.com',
// Minimum price spread percentage to flag as an opportunity
minSpreadPercent: 0.1,
// Tokens to track with their contract addresses on each chain
tokens: [
{
symbol: 'WETH',
addresses: {
ethereum: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
arbitrum: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1',
polygon: '0x7ceb23fd6bc0add59e62ac25578270cff1b9f619',
},
},
{
symbol: 'USDC',
addresses: {
ethereum: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
bsc: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d',
polygon: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
arbitrum: '0xaf88d065e77c8cc2239327c5edb3a432268e5831',
},
},
{
symbol: 'USDT',
addresses: {
ethereum: '0xdac17f958d2ee523a2206206994597c13d831ec7',
bsc: '0x55d398326f99059ff775485246999027b3197955',
polygon: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f',
arbitrum: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9',
},
},
],
};
module.exports = CONFIG;
What does this code do?
This configuration maps three high-liquidity tokens (WETH, USDC, USDT) to their contract addresses on Ethereum, BSC, Polygon, and Arbitrum.
Each token has a unique contract address on every chain. The scanner will use these addresses to ask DexPaprika for the current USD price of each token on each chain. Different prices on different chains create arbitrage opportunities.
- Save src/config.js.
You won't see a change yet. The next chunk uses this config to call the API.
Build the fetchTokenPrice function
The DexPaprika REST API returns token data including price, liquidity, and trading volume. Your scanner calls the GET /networks/{network}/tokens/{token_address} endpoint and extracts the USD price from the response.
- Open src/scanner.js and replace the empty file with the following code:
const CONFIG = require('./config');
// Fetch the USD price of a single token on a specific network
async function fetchTokenPrice(network, tokenAddress) {
const url = `${CONFIG.apiBaseUrl}/networks/${network}/tokens/${tokenAddress}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${network}/${tokenAddress}: ${response.status}`);
}
const data = await response.json();
return {
network,
address: tokenAddress,
priceUsd: parseFloat(data.summary?.price_usd) || null,
symbol: data.symbol || 'UNKNOWN',
liquidity: parseFloat(data.summary?.liquidity_usd) || 0,
};
}
What does this code do?
The fetchTokenPrice function takes a network name (like ethereum) and a token contract address, then calls the DexPaprika API to get the current price.
It uses Node.js built-in fetch() (no npm packages needed). The response includes a summary.price_usd field with the current USD price, and summary.liquidity_usd for the total liquidity across all pools for that token.
- Save src/scanner.js.
You won't see a change yet. The next chunk adds the function that calls this for every token.
Build the fetchAllPrices function
A single price fetch is useful, but the scanner needs to fetch prices for every token on every chain in parallel. This function iterates through the config and fires off all requests at once.
- In src/scanner.js, add the following code below the fetchTokenPrice function:
// Fetch prices for all configured tokens across all chains
async function fetchAllPrices() {
const requests = [];
for (const token of CONFIG.tokens) {
for (const [network, address] of Object.entries(token.addresses)) {
requests.push(
fetchTokenPrice(network, address).then((result) => ({
...result,
tokenSymbol: token.symbol,
}))
);
}
}
const results = await Promise.allSettled(requests);
const prices = [];
for (const result of results) {
if (result.status === 'fulfilled' && result.value.priceUsd !== null) {
prices.push(result.value);
} else if (result.status === 'rejected') {
console.warn(`Warning: ${result.reason.message}`);
}
}
return prices;
}
module.exports = { fetchTokenPrice, fetchAllPrices };
What does this code do?
The fetchAllPrices function loops through every token and every chain in your config, creating a fetch request for each combination. It fires all requests simultaneously using Promise.allSettled.
Unlike Promise.all, Promise.allSettled never rejects. If one API call fails (e.g. a token address is not found on a chain), it logs a warning and continues processing the others. This makes the scanner resilient to partial failures.
Why Promise.allSettled instead of Promise.all?
When you query 11 different token/chain combinations, some might fail due to network issues or unsupported tokens. Promise.all would reject the entire batch if a single request fails.
Promise.allSettled gives you results for every request regardless of success or failure. Your scanner still produces useful output even if one or two chains are unreachable.
- Save src/scanner.js.
Seeing a syntax error when you save?
Make sure the module.exports line is the very last line in the file. Check that both functions (fetchTokenPrice and fetchAllPrices) have matching opening and closing braces.
help me fix a syntax error in my scanner.js file
Test the scanner with live data
Time to verify the scanner can actually reach the DexPaprika API and get real prices back. You'll temporarily add a few lines to the bottom of src/scanner.js that call fetchAllPrices() and print the results.
- In src/scanner.js, add the following lines at the very bottom of the file (below the module.exports line):
// Temporary test - remove after verifying
fetchAllPrices().then((prices) => {
console.log(`Fetched ${prices.length} price points:`);
for (const p of prices) {
console.log(` ${p.tokenSymbol} on ${p.network}: $${p.priceUsd}`);
}
});
- Run the scanner by executing this command:
node src/scanner.js
You should see output listing price points for each token on each chain, similar to this:
Fetched 11 price points:
WETH on ethereum: $2739.79
WETH on arbitrum: $2738.45
WETH on polygon: $2740.12
USDC on ethereum: $1.0001
USDC on bsc: $0.9998
...
What should I see?
You should see between 8 and 11 price points printed (the exact number depends on which API calls succeed). The prices will be real-time values. WETH will be in the thousands, while USDC and USDT will be close to $1.00.
If you see warning messages for some chains, that is normal. The scanner gracefully skips any failed requests.
Seeing 0 price points or connection errors?
Check that you have an active internet connection. The DexPaprika API requires no API key, but it does need network access.
If you see fetch is not defined, your Node.js version is too old. Run node --version and confirm it shows v22 or higher. The built-in fetch() requires Node.js 22+.
my arbitrage scanner is not fetching any prices from DexPaprika
- Once you've confirmed you see price data, remove the temporary test lines from the bottom of src/scanner.js (everything below module.exports).
- Save src/scanner.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved both src/config.js and src/scanner.js and that the temporary test lines are removed.
ⓧ I'd like to double check the full code
Here is the complete src/config.js:
const CONFIG = {
apiBaseUrl: 'https://api.dexpaprika.com',
minSpreadPercent: 0.1,
tokens: [
{
symbol: 'WETH',
addresses: {
ethereum: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
arbitrum: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1',
polygon: '0x7ceb23fd6bc0add59e62ac25578270cff1b9f619',
},
},
{
symbol: 'USDC',
addresses: {
ethereum: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
bsc: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d',
polygon: '0x2791bca1f2de4661ed88a30c99a7a9449aa84174',
arbitrum: '0xaf88d065e77c8cc2239327c5edb3a432268e5831',
},
},
{
symbol: 'USDT',
addresses: {
ethereum: '0xdac17f958d2ee523a2206206994597c13d831ec7',
bsc: '0x55d398326f99059ff775485246999027b3197955',
polygon: '0xc2132d05d31c914a87c6611c10748aeb04b58e8f',
arbitrum: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9',
},
},
],
};
module.exports = CONFIG;
Here is the complete src/scanner.js (without the temporary test lines):
const CONFIG = require('./config');
async function fetchTokenPrice(network, tokenAddress) {
const url = `${CONFIG.apiBaseUrl}/networks/${network}/tokens/${tokenAddress}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${network}/${tokenAddress}: ${response.status}`);
}
const data = await response.json();
return {
network,
address: tokenAddress,
priceUsd: parseFloat(data.summary?.price_usd) || null,
symbol: data.symbol || 'UNKNOWN',
liquidity: parseFloat(data.summary?.liquidity_usd) || 0,
};
}
async function fetchAllPrices() {
const requests = [];
for (const token of CONFIG.tokens) {
for (const [network, address] of Object.entries(token.addresses)) {
requests.push(
fetchTokenPrice(network, address).then((result) => ({
...result,
tokenSymbol: token.symbol,
}))
);
}
}
const results = await Promise.allSettled(requests);
const prices = [];
for (const result of results) {
if (result.status === 'fulfilled' && result.value.priceUsd !== null) {
prices.push(result.value);
} else if (result.status === 'rejected') {
console.warn(`Warning: ${result.reason.message}`);
}
}
return prices;
}
module.exports = { fetchTokenPrice, fetchAllPrices };
Your scanner can now pull live prices from DEXs across four blockchains. Next up, you'll build the logic that compares these prices and spots arbitrage opportunities.
Build the Arbitrage Detector
You have raw price data streaming in from multiple blockchains. The next challenge is comparing those prices to spot where the same token costs less on one chain than another.
In this step, you'll build the arbitrage detection logic and wire everything together into a command-line tool. It will output a ranked table of cross-chain opportunities sorted by profit potential.
In this step, get ready to:
- Create the arbitrage detection module that compares prices across chains.
- Build the main entry point that orchestrates fetching, detecting, and displaying results.
- Run the full scanner and verify it outputs live arbitrage opportunities.
Create the arbitrage detection module
The core of any arbitrage scanner is the comparison logic. You need to group prices by token, find the cheapest and most expensive chain for each, and calculate the percentage spread.
- Create the arbitrage detection function by pasting this code into src/arbitrage.js:
// Groups prices by token and finds the best buy/sell chains
function findOpportunities(prices) {
const grouped = {};
// Group all price points by their token symbol
for (const price of prices) {
if (!grouped[price.tokenSymbol]) {
grouped[price.tokenSymbol] = [];
}
grouped[price.tokenSymbol].push(price);
}
const opportunities = [];
// For each token, compare prices across chains
for (const [symbol, chainPrices] of Object.entries(grouped)) {
// Need at least 2 chains to compare
if (chainPrices.length < 2) continue;
let lowest = chainPrices[0];
let highest = chainPrices[0];
// Find the cheapest and most expensive chain
for (const cp of chainPrices) {
if (cp.priceUsd < lowest.priceUsd) lowest = cp;
if (cp.priceUsd > highest.priceUsd) highest = cp;
}
// Skip if the same chain has both lowest and highest
if (lowest.network === highest.network) continue;
// Calculate the percentage spread between chains
const spread = ((highest.priceUsd - lowest.priceUsd) / lowest.priceUsd) * 100;
opportunities.push({
symbol,
buyChain: lowest.network,
sellChain: highest.network,
buyPrice: lowest.priceUsd,
sellPrice: highest.priceUsd,
spreadPercent: spread,
});
}
// Sort by highest spread first (best opportunities on top)
opportunities.sort((a, b) => b.spreadPercent - a.spreadPercent);
return opportunities;
}
module.exports = { findOpportunities };
What does this code do?
The function takes the flat array of price points from your scanner and groups them by token symbol (WETH, USDC, USDT).
For each token, it scans all available chain prices to find the lowest (where you'd buy) and the highest (where you'd sell). The spread percentage tells you how much profit exists between those two chains.
- Save src/arbitrage.js.
Why sort by spread percentage?
Sorting descending by spread puts the most profitable opportunities at the top. A 0.5% spread on WETH is more interesting than a 0.01% spread on USDT, so it should appear first.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete src/arbitrage.js for comparison:
function findOpportunities(prices) {
const grouped = {};
for (const price of prices) {
if (!grouped[price.tokenSymbol]) {
grouped[price.tokenSymbol] = [];
}
grouped[price.tokenSymbol].push(price);
}
const opportunities = [];
for (const [symbol, chainPrices] of Object.entries(grouped)) {
if (chainPrices.length < 2) continue;
let lowest = chainPrices[0];
let highest = chainPrices[0];
for (const cp of chainPrices) {
if (cp.priceUsd < lowest.priceUsd) lowest = cp;
if (cp.priceUsd > highest.priceUsd) highest = cp;
}
if (lowest.network === highest.network) continue;
const spread = ((highest.priceUsd - lowest.priceUsd) / lowest.priceUsd) * 100;
opportunities.push({
symbol,
buyChain: lowest.network,
sellChain: highest.network,
buyPrice: lowest.priceUsd,
sellPrice: highest.priceUsd,
spreadPercent: spread,
});
}
opportunities.sort((a, b) => b.spreadPercent - a.spreadPercent);
return opportunities;
}
module.exports = { findOpportunities };
Create the main entry point
The main entry point ties your scanner and arbitrage detector together. It fetches prices, finds opportunities, filters them by a minimum spread threshold, and prints a formatted table.
- Replace the contents of src/index.js with the following code:
const { fetchAllPrices } = require('./scanner');
const { findOpportunities } = require('./arbitrage');
const CONFIG = require('./config');
async function main() {
console.log('Cross-Chain DEX Arbitrage Scanner');
console.log('=================================\n');
console.log('Fetching prices across chains...\n');
// Fetch live prices from the DexPaprika API
const prices = await fetchAllPrices();
// Exit early if no data came back
if (prices.length === 0) {
console.error('No price data received. Check your network connection.');
process.exit(1);
}
console.log(`Received ${prices.length} price points.\n`);
// Find arbitrage opportunities and filter by minimum spread
const opportunities = findOpportunities(prices);
const filtered = opportunities.filter(
(opp) => opp.spreadPercent >= CONFIG.minSpreadPercent
);
if (filtered.length === 0) {
console.log('No arbitrage opportunities found above the minimum spread threshold.');
console.log(`Current threshold: ${CONFIG.minSpreadPercent}%`);
} else {
console.log(`Found ${filtered.length} opportunities (min spread: ${CONFIG.minSpreadPercent}%):\n`);
// Print a formatted table header
console.log(
'Token'.padEnd(8) +
'Buy Chain'.padEnd(12) +
'Sell Chain'.padEnd(12) +
'Buy Price'.padEnd(14) +
'Sell Price'.padEnd(14) +
'Spread'
);
console.log('-'.repeat(70));
// Print each opportunity as a table row
for (const opp of filtered) {
console.log(
opp.symbol.padEnd(8) +
opp.buyChain.padEnd(12) +
opp.sellChain.padEnd(12) +
`$${opp.buyPrice.toFixed(4)}`.padEnd(14) +
`$${opp.sellPrice.toFixed(4)}`.padEnd(14) +
`${opp.spreadPercent.toFixed(4)}%`
);
}
}
console.log('\nScan complete.');
}
// Run the scanner and handle any fatal errors
main().catch((error) => {
console.error('Scanner failed:', error.message);
process.exit(1);
});
What does this code do?
- It imports your scanner and arbitrage modules, then runs them in sequence inside an async main() function.
- The CONFIG.minSpreadPercent filter (set to 0.1% in your config) removes noise by hiding tiny spreads that wouldn't cover transaction fees.
- The padEnd() calls align each column so the output reads like a clean table in your terminal.
- The .catch() at the bottom ensures any unhandled error exits with a clear message instead of a raw stack trace.
- Save src/index.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete src/index.js for comparison:
const { fetchAllPrices } = require('./scanner');
const { findOpportunities } = require('./arbitrage');
const CONFIG = require('./config');
async function main() {
console.log('Cross-Chain DEX Arbitrage Scanner');
console.log('=================================\n');
console.log('Fetching prices across chains...\n');
const prices = await fetchAllPrices();
if (prices.length === 0) {
console.error('No price data received. Check your network connection.');
process.exit(1);
}
console.log(`Received ${prices.length} price points.\n`);
const opportunities = findOpportunities(prices);
const filtered = opportunities.filter(
(opp) => opp.spreadPercent >= CONFIG.minSpreadPercent
);
if (filtered.length === 0) {
console.log('No arbitrage opportunities found above the minimum spread threshold.');
console.log(`Current threshold: ${CONFIG.minSpreadPercent}%`);
} else {
console.log(`Found ${filtered.length} opportunities (min spread: ${CONFIG.minSpreadPercent}%):\n`);
console.log(
'Token'.padEnd(8) +
'Buy Chain'.padEnd(12) +
'Sell Chain'.padEnd(12) +
'Buy Price'.padEnd(14) +
'Sell Price'.padEnd(14) +
'Spread'
);
console.log('-'.repeat(70));
for (const opp of filtered) {
console.log(
opp.symbol.padEnd(8) +
opp.buyChain.padEnd(12) +
opp.sellChain.padEnd(12) +
`$${opp.buyPrice.toFixed(4)}`.padEnd(14) +
`$${opp.sellPrice.toFixed(4)}`.padEnd(14) +
`${opp.spreadPercent.toFixed(4)}%`
);
}
}
console.log('\nScan complete.');
}
main().catch((error) => {
console.error('Scanner failed:', error.message);
process.exit(1);
});
Run the complete scanner
Everything is wired up. Time to run the full scanner and see real arbitrage opportunities in your terminal.
- Run the scanner by executing this command:
node src/index.js
- You should see a table of arbitrage opportunities with columns for Token, Buy Chain, Sell Chain, Buy Price, Sell Price, and Spread. The output ends with Scan complete.
What should I see?
The scanner fetches live prices, so your exact numbers will differ. You might see spreads of 0.01% to 2% depending on market conditions. If no opportunities appear, it means all token prices are within the 0.1% threshold across chains right now.
Seeing 'No price data received'?
- Check your internet connection. The DexPaprika API requires network access.
- If you see multiple warning lines about failed fetches, the API may be temporarily slow. Wait a minute and try again.
- Make sure you're running the command from your project's root folder (the one containing package.json).
Still stuck? Help me debug why my arbitrage scanner isn't returning price data.
Your scanner is pulling live data and spotting cross-chain price differences. Next up, you'll package it into a Docker container so it can run anywhere.
Containerize with Docker
Your scanner works on your machine, but what happens when you want to run it on a server, share it with a teammate, or deploy it in a pipeline?
This is where Docker comes in. By packaging your app into a container, you guarantee it runs the same everywhere. In this step, you'll create a production-ready Docker image using a multi-stage build.
In this step, get ready to:
- Write a multi-stage Dockerfile optimized for production.
- Create a .dockerignore to keep the build context lean.
- Build and run the container locally to verify the scanner works inside Docker.
Create the multi-stage Dockerfile
A multi-stage build uses two separate images. The first stage installs dependencies, and the second stage copies only what the app needs to run. This keeps your final image small and secure.
- Create a new file called Dockerfile in the root of your arb-scanner project folder (the same level as package.json).
- Paste the following code into your Dockerfile:
# Stage 1: Builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production --ignore-scripts
COPY src/ ./src/
# Stage 2: Production
FROM node:22-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/src ./src
COPY --from=builder /app/package.json ./
USER node
CMD ["node", "src/index.js"]
What does this Dockerfile do?
The builder stage starts from a lightweight Alpine Linux image with Node.js 22. It copies your package.json first (the * after package-lock.json handles cases where the lock file might not exist), installs dependencies, then copies your source code.
The production stage starts fresh from the same base image but only copies what's needed to run: the installed node_modules, your src/ folder, and package.json. The USER node line runs the app as a non-root user for security.
Why use a multi-stage build?
Without multi-stage, your final image would include build tools, intermediate files, and anything else from the install process. Multi-stage lets you discard all of that. Only the runtime essentials make it into the production image, which means a smaller image size and a reduced attack surface.
- Save Dockerfile.
Seeing a syntax error in the Dockerfile?
- Make sure the file is named exactly Dockerfile with a capital D and no file extension.
- Check that FROM, COPY, RUN, and CMD are all uppercase.
- help me debug my Dockerfile syntax
Create the .dockerignore file
When Docker builds an image, it sends every file in your project folder to the Docker engine as the "build context." A .dockerignore file tells Docker which files to skip, keeping the build fast and the image clean.
- Create a new file called .dockerignore in the root of your arb-scanner folder and paste the following:
node_modules/
.git/
.gitignore
.env
*.md
.github/
What does this exclude?
- node_modules/ is excluded because the Dockerfile installs dependencies fresh inside the container.
- .git/ and *.md are development files that the running app does not need.
- .env keeps sensitive environment variables out of the image.
- .github/ excludes CI/CD workflow files you will create in the next step.
- Save .dockerignore.
- Confirm both new files exist by running:
ls -a
You should see Dockerfile and .dockerignore listed alongside your existing files.
Build and run the container
Time to see if your containerized scanner actually works. You will build the image, then run it as a container to confirm it produces the same arbitrage output you saw earlier.
- Build the Docker image by running this command:
docker build -t arb-scanner .
What does this command do?
- docker build tells Docker to create a new image from the Dockerfile in the current directory.
- -t arb-scanner tags the image with the name arb-scanner so you can reference it easily.
- The . at the end sets the build context to your current folder.
The build takes a minute or so the first time as Docker downloads the node:22-alpine base image. You should see output ending with a line like naming to docker.io/library/arb-scanner.
Build failing with permission errors?
- Make sure Docker Desktop (or the Docker daemon in WSL) is running before you try to build.
- If you see permission denied, try running the command with sudo or add your user to the docker group.
- help me fix Docker build errors
- Run the scanner inside a container by running this command:
docker run --rm arb-scanner
What does this command do?
- docker run creates and starts a new container from the arb-scanner image.
- --rm automatically removes the container after it finishes, so you don't accumulate stopped containers.
You should see the same arbitrage table output you saw when running node src/index.js directly. The scanner fetches prices, compares them, and prints opportunities.
Container exits immediately or shows no output?
- Check that your Dockerfile CMD line reads CMD ["node", "src/index.js"] with the correct path to your entry point.
- If you see network errors, make sure your machine has internet access. The container needs to reach the DexPaprika API.
- help me debug my Docker container
✔️ Awesome, I've got everything!
Great. Double check you've saved both Dockerfile and .dockerignore.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
# Stage 1: Builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production --ignore-scripts
COPY src/ ./src/
# Stage 2: Production
FROM node:22-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/src ./src
COPY --from=builder /app/package.json ./
USER node
CMD ["node", "src/index.js"]
Here is the complete .dockerignore:
node_modules/
.git/
.gitignore
.env
*.md
.github/
Your scanner is now fully containerized and running inside Docker. Next up, you'll set up a CI/CD pipeline with GitHub Actions to automatically build and push this image on every commit.
Set Up GitHub Actions CI/CD
Your scanner works locally and runs inside a Docker container. But every time you update the code, you'd need to manually rebuild the image and push it to Docker Hub.
GitHub Actions is a CI/CD platform built directly into GitHub. In this step, you'll create a workflow that automatically tests your scanner and pushes a fresh Docker image to Docker Hub on every commit to main.
In this step, get ready to:
- Push your project to a new GitHub repository and configure Docker Hub secrets.
- Create a GitHub Actions workflow that tests and builds your scanner.
- Verify the pipeline passes and your Docker image appears on Docker Hub.
Push your code to GitHub and configure secrets
GitHub Actions workflows run inside a GitHub repository. You need to push your local project to GitHub and store your Docker Hub credentials as encrypted secrets so the workflow can authenticate.
- Open GitHub in your browser.
- Click the + icon in the top-right corner.
- Select New repository.
- Enter arb-scanner as the Repository name.
- Leave the repository set to Public.
- Do NOT check any of the initialization options (no README, no .gitignore, no license).
- Click Create repository.
GitHub displays a page with setup commands. You already have a local git repo, so you'll use the "push an existing repository" commands.
- Connect your local repo to GitHub and push your code by running these commands:
git add .
git commit -m "Initial commit: arbitrage scanner with Docker"
git branch -M main
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/arb-scanner.git"]]
git push -u origin main
What do these commands do?
- git add . stages all your project files for commit.
- git commit saves a snapshot of your project.
- git branch -M main ensures your default branch is named main.
- git remote add origin links your local repo to the GitHub repository.
- git push -u origin main uploads all your code to GitHub.
- Refresh your GitHub repository page in the browser. You should see all your project files listed, including the Dockerfile and the src/ folder.
Push failed or asking for credentials?
If git asks for a username and password, GitHub no longer accepts password authentication. Use a GitHub Personal Access Token or set up SSH keys instead.
If you see "remote origin already exists", run git remote set-url origin <your-url> to update the URL.
Help me push my code to GitHub
Now you need to store your Docker Hub credentials as repository secrets. GitHub Actions uses these secrets to log into Docker Hub and push your image.
- In your GitHub repository, click the Settings tab.
- In the left sidebar, click Secrets and variables, then click Actions.
- Click New repository secret.
- Enter DOCKER_USERNAME as the Name.
- Enter your Docker Hub username as the Secret value.
- Click Add secret.
- Click New repository secret again.
- Enter DOCKERHUB_TOKEN as the Name.
- Paste the Docker Hub Personal Access Token you saved earlier as the Secret value.
- Click Add secret.
You should now see both DOCKER_USERNAME and DOCKERHUB_TOKEN listed under your repository secrets.
Why use repository secrets?
Repository secrets are encrypted environment variables that GitHub injects into your workflow at runtime. They never appear in logs or code, keeping your Docker Hub credentials safe even in a public repository.
Create the GitHub Actions workflow file
GitHub Actions reads workflow definitions from YAML files inside a .github/workflows/ directory in your repository. Each file defines a pipeline with triggers, jobs, and steps.
- Create the workflow directory by running this command:
mkdir -p .github/workflows
- Create and open the workflow file .github/workflows/ci.yml in your editor.
- Paste in the following workflow definition:
name: CI/CD - Arbitrage Scanner
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test Scanner
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Run scanner test
run: node src/index.js
build-and-push:
name: Build and Push Docker Image
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/arb-scanner:latest
${{ secrets.DOCKER_USERNAME }}/arb-scanner:${{ github.sha }}
What does this workflow do?
This workflow has two jobs that run sequentially:
- test checks out your code, installs Node.js 22, runs npm ci to install dependencies, and executes the scanner to confirm it runs without errors.
- build-and-push only runs after test passes (needs: test). It logs into Docker Hub using your secrets, builds the Docker image with Buildx, and pushes it with two tags: latest and the commit SHA for version tracking.
- The if: condition ensures the Docker image is only built on direct pushes to main. Pull requests trigger the test job only.
- Save .github/workflows/ci.yml.
Seeing YAML indentation errors later?
YAML is extremely sensitive to indentation. Every nested level must use exactly 2 spaces (not tabs). If you see "Invalid workflow file" on GitHub, check that your steps: are indented under their job, and each - name: aligns with the others.
Help me fix my GitHub Actions YAML file
✔️ Awesome, I've got everything!
Make sure you've saved the file. Next you'll push it to trigger the pipeline.
ⓧ I'd like to double check the full code
name: CI/CD - Arbitrage Scanner
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test Scanner
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Run scanner test
run: node src/index.js
build-and-push:
name: Build and Push Docker Image
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/arb-scanner:latest
${{ secrets.DOCKER_USERNAME }}/arb-scanner:${{ github.sha }}
Push the workflow and verify the pipeline
Pushing the workflow file to GitHub automatically triggers the pipeline. GitHub detects the new .github/workflows/ci.yml file and starts running it immediately.
- Commit and push the workflow file by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add CI/CD workflow for Docker build and push"
git push
- In your browser, go to your GitHub repository.
- Click the Actions tab at the top.
You should see a workflow run in progress or recently completed. The run is named after your commit message.
- Click on the workflow run to see its details.
- Verify that both the Test Scanner job and the Build and Push Docker Image job show green checkmarks.
What should I see?
The Actions tab shows your workflow run with two jobs. The Test Scanner job runs first. Once it passes, Build and Push Docker Image starts and pushes your image to Docker Hub. Both should finish with green checkmarks within 2-3 minutes.
- Open Docker Hub in your browser.
- Click on Repositories in the top navigation.
- Verify that you see a repository named arb-scanner with a latest tag listed.
Workflow failed?
Click on the failed job to expand its logs. Common issues:
- "unauthorized" during Docker login means your DOCKER_USERNAME or DOCKERHUB_TOKEN secrets have a typo or the PAT lacks Read/Write permissions.
- "No price data received" in the test job means the GitHub Actions runner couldn't reach the DexPaprika API. This is rare but can happen if the API has momentary downtime. Re-run the workflow by clicking Re-run all jobs.
- "Invalid workflow file" means a YAML syntax error. Double check indentation uses spaces (not tabs) and all keys align correctly.
Help me debug my failing GitHub Actions workflow
Your arbitrage scanner now has a full automated pipeline. Every time you push code to main, GitHub Actions tests it and publishes a fresh Docker image. Anyone can pull and run your scanner with a single docker pull command.
Secret mission
Real-Time Streaming Arbitrage Alerts
Your scanner runs once and exits. What if you could watch prices update every second and catch arbitrage opportunities the instant they appear? In this secret mission, you will add live price streaming using DexPaprika's Server-Sent Events endpoint to detect opportunities in real-time.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on free services, so there are no ongoing costs.
Resources you used:
- Local project folder (arb-scanner) on your Desktop.
- Local Docker image (arb-scanner).
- Docker Hub repository with pushed images.
- Docker Hub Personal Access Token.
- GitHub repository with source code and Actions workflow.
- GitHub repository secrets (DOCKER_USERNAME, DOCKERHUB_TOKEN).
Keep everything running
No action needed. Choose this if you plan to keep developing the scanner or want to reference the code later. All services used in this project are free, so nothing will cost you money.
- Your Docker Hub image stays available to pull from anywhere.
- Your GitHub Actions workflow continues to run on every push to main.
- Your local Docker image stays cached for quick reruns.
Pause - I'll come back to this later
Free up local disk space by removing Docker images while keeping your remote repositories intact. You can rebuild everything from your GitHub repo when you return.
- Remove the local Docker image by running docker rmi arb-scanner in your terminal.
- Your GitHub repository and Docker Hub image remain available online.
- To resume later, clone your GitHub repo and run docker build -t arb-scanner . to rebuild.
Delete - I don't want to use this again
Remove all project resources from your machine and cloud accounts. This cannot be undone.
- Delete the local project folder from your Desktop.
- Remove the local Docker image by running docker rmi arb-scanner.
- Delete the Docker Hub repository by going to your repository page on hub.docker.com, selecting Settings, then Delete Repository.
- Revoke your Docker Hub PAT under Account Settings > Security.
- Delete the GitHub repository under Settings > General > Delete this repository.
That's a Wrap!
That's a Wrap!
Nice work! You just built a cross-chain DEX arbitrage scanner, containerized it with Docker, and shipped it with a fully automated CI/CD pipeline.
Here's what you learned:
- Fetched real-time token prices from DEXs across 4 blockchains using the DexPaprika API, then compared them to identify cross-chain arbitrage opportunities.
- Built a multi-stage Dockerfile that produces a lean, production-ready Docker container running as a non-root user.
- Set up a GitHub Actions CI/CD pipeline that automatically tests, builds, and pushes your Docker image to Docker Hub on every commit.
- Secret Mission: Added real-time SSE streaming with DexPaprika to detect arbitrage opportunities as they happen.
Ready to quiz yourself?