Containerize a Blockchain API with Docker

Build and deploy an ERC-20 token API in Docker with a CI/CD pipeline.

Introduction

30 Second Summary

Every time a wallet app shows your token balance or a portfolio tracker displays your holdings, there is a backend API translating raw blockchain data into simple JSON. That translation layer is the invisible infrastructure behind every blockchain product you use.

In this project, you will build a REST API that reads ERC-20 token data from the Ethereum blockchain, package it inside a Docker container, automate testing and builds with a CI/CD pipeline, and deploy it live to the internet.

What You'll Build

A Token Balance API built with Express.js and ethers.js, containerized with Docker, tested and built automatically with GitHub Actions, and deployed to Render.

By the end of this project, you'll have:

  • A working REST API that reads ERC-20 token data from the Ethereum blockchain, running inside a Docker container.
  • A GitHub Actions CI/CD pipeline that automatically runs tests and builds your Docker image on every push.
  • Your containerized API deployed and live on the internet via Render's free tier.
  • Secret Mission: Deploy your own smart contract to Sepolia testnet and add a write endpoint to your API.

Are there any prerequisites?

This project assumes you have some experience with JavaScript and Node.js, basic Git knowledge, and familiarity with Ethereum and smart contracts. You do not need prior Docker or CI/CD experience.

Set Up Docker Desktop and Project Scaffold

Before you can build and ship your Token Balance API, you need two things in place. Docker packages your app into a portable container that runs identically everywhere. A project scaffold gives you the folder structure and dependencies to start coding immediately.

In this step, you will install Docker Desktop, create your project folder, and set up the Node.js project with all the packages your API needs.

In this step, get ready to:
  • Install Docker Desktop with the WSL 2 backend.
  • Create the project folder and install dependencies.
  • Set up the file and folder structure for your API.
Install Docker Desktop

Docker Desktop is the application that lets you build and run containers on your local machine. Your API will eventually run inside one of these containers.

  • Open Git Bash and check if Docker is already installed by running this command:
docker --version

✔️ I see a version number

Docker is already installed and ready to go. Skip ahead to the next substep.

ⓧ Command not found

Docker is not installed yet. Follow these steps to get it set up.

  • Go to the Docker Desktop download page: https://docs.docker.com/desktop/setup/install/windows-install/
  • Click the download button for Windows.
  • Run the installer once the download finishes.
  • When prompted, make sure the Use WSL 2 instead of Hyper-V option is selected.
  • Complete the installation and restart your computer when asked.
  • After your computer restarts, Docker Desktop launches automatically. Wait for the engine status (bottom-left corner) to show green.
  • Open Git Bash again and verify the installation by running:
docker --version

You should see output like Docker version 28.x.x confirming Docker is ready.

What is WSL 2?

WSL 2 (Windows Subsystem for Linux 2) is a lightweight Linux kernel that runs inside Windows. Docker Desktop uses it to run Linux containers natively on your machine without a full virtual machine.

Docker Desktop won't start?

Make sure WSL 2 is enabled on your system. Docker Desktop requires Windows 10/11 Pro, Enterprise, or Education version 22H2 or later. Your machine also needs at least 8 GB of RAM.

If the engine stays yellow or red, try restarting Docker Desktop from the system tray. Help me troubleshoot Docker Desktop not starting on Windows.

Create the project folder and initialize npm
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir token-balance-api
cd token-balance-api
  • Confirm the folder exists by running:
ls ~/Desktop | grep token-balance-api

You should see token-balance-api in the output.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the token-balance-api folder, then click Select Folder.
  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Initialize your Node.js project by running this command:
npm init -y

You should see a package.json file appear in the Explorer sidebar on the left.

What does npm init -y do?

This command creates a package.json file with default values. The -y flag skips the interactive prompts and accepts all defaults. This file tracks your project's metadata, scripts, and dependencies.

  • Install the three production dependencies by running this command:
npm install express@5.2.1 ethers@6.16.0 dotenv@16.5.0

What are these packages?

  • express@5.2.1 is the web framework that handles HTTP requests and routing for your API.
  • ethers@6.16.0 is the library for connecting to the Ethereum blockchain and interacting with smart contracts.
  • dotenv@16.5.0 loads environment variables from a .env file into your app. This keeps config like RPC URLs out of your source code.
  • Install the testing framework as a dev dependency by running:
npm install --save-dev jest@30.0.2

You should see a node_modules folder and a package-lock.json file appear in your Explorer sidebar.

Seeing permission errors or install failures?

Make sure you are inside the token-balance-api folder before running npm install. Run pwd to check your current directory.

If you see network errors, check your internet connection and try again. Help me fix npm install errors in my token-balance-api project.

  • Open package.json in VS Code by clicking on it in the Explorer sidebar.
  • Find the "scripts" section. It looks like this:
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
}
  • Replace the "test" value so the scripts section looks like this:
"scripts": {
  "test": "jest --detectOpenHandles --forceExit"
}
  • Save package.json by pressing Ctrl+S.
Create the project structure

Your API code will live in a src folder, split across three files for clean separation of concerns.

  • Create the folder structure by running these commands in your VS Code terminal:
mkdir -p src/routes
mkdir -p src/services
  • Create the empty source files by running:
touch src/index.js
touch src/routes/token.js
touch src/services/blockchain.js
  • Create the environment and config files by running:
touch .env
touch .gitignore
  • Confirm all files were created by running:
ls -R src && ls .env .gitignore

You should see the src folder with its subfolders and files listed, plus .env and .gitignore at the project root.

  • Open .env in VS Code by clicking it in the Explorer sidebar.
  • Paste the following content into the file:
PORT=3000
ETHEREUM_RPC_URL=https://eth.llamarpc.com

What is an RPC URL?

An RPC (Remote Procedure Call) URL is the endpoint your app uses to communicate with the Ethereum blockchain. The https://eth.llamarpc.com endpoint is a free public RPC provider that works for read-only queries.

  • Save .env.
  • Open .gitignore in VS Code.
  • Paste the following content into the file:
node_modules
.env

Why exclude these files from Git?

The node_modules folder contains thousands of downloaded dependency files that anyone can recreate by running npm install. The .env file contains configuration that may include sensitive values in production. Neither belongs in your repository.

  • Save .gitignore.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is what your .env file should contain:

PORT=3000
ETHEREUM_RPC_URL=https://eth.llamarpc.com

Here is what your .gitignore file should contain:

node_modules
.env

Your package.json should have these key sections (other fields from npm init are fine to leave):

{
  "name": "token-balance-api",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "jest --detectOpenHandles --forceExit"
  },
  "dependencies": {
    "dotenv": "^16.5.0",
    "ethers": "^6.16.0",
    "express": "^5.2.1"
  },
  "devDependencies": {
    "jest": "^30.0.2"
  }
}

Your development environment is ready and your project has a clean structure waiting for code. Next up, you will build the blockchain service that reads real token data from Ethereum.

Build the Blockchain Service and API

The Ethereum blockchain stores token data inside smart contracts. Reading that data requires encoding function calls, connecting to RPC nodes, and parsing binary responses.

Your API hides all of that complexity behind simple HTTP endpoints. In this step, you'll build three files: a blockchain service that talks directly to Ethereum, a routes file that defines the endpoints, and an entry point that wires everything into a running server.

In this step, get ready to:
  • Write a blockchain service that reads ERC-20 token data from Ethereum.
  • Create API routes that expose token data over HTTP.
  • Wire up the Express server and verify it runs locally.
Write the blockchain service foundation

The blockchain service is the layer that knows how to talk to Ethereum. It uses ethers.js to connect to an RPC node and call smart contract functions.

  • In VS Code, open src/services/blockchain.js from the Explorer sidebar.
  • Add the imports, ABI definition, and provider function by copying this code:
const { ethers } = require("ethers");

// Minimal ABI for reading ERC-20 token data
const ERC20_ABI = [
  "function name() view returns (string)",
  "function symbol() view returns (string)",
  "function decimals() view returns (uint8)",
  "function balanceOf(address account) view returns (uint256)",
];

// Creates a connection to the Ethereum network
function getProvider() {
  const rpcUrl = process.env.ETHEREUM_RPC_URL || "https://eth.llamarpc.com";
  return new ethers.JsonRpcProvider(rpcUrl);
}

What does this code do?

ERC20_ABI defines the functions your service can call on any ERC-20 token contract. This uses the human-readable ABI format that ethers.js supports, where each function is described as a plain string instead of verbose JSON.

getProvider() creates a connection to Ethereum through an RPC endpoint. It reads the URL from your .env file, falling back to a public RPC if none is set.

You won't see a change yet. The next function uses this foundation to query actual token data.

Add the token info function
  • Below the getProvider() function, add the getTokenInfo function by copying this code:
// Fetches token metadata (name, symbol, decimals) from a contract address
async function getTokenInfo(tokenAddress) {
  const provider = getProvider();
  const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);

  // Call all three read-only functions in parallel for speed
  const [name, symbol, decimals] = await Promise.all([
    contract.name(),
    contract.symbol(),
    contract.decimals(),
  ]);

  return {
    address: tokenAddress,
    name,
    symbol,
    decimals: Number(decimals),
  };
}

What does this code do?

This function takes any ERC-20 token's contract address and returns its metadata. ethers.Contract creates an interface to the on-chain contract using the ABI you defined earlier.

Promise.all() fires all three contract calls simultaneously instead of waiting for each one to finish before starting the next. This cuts the response time significantly.

You won't see a change yet. One more function completes the service module.

Add the balance function and export the module
  • Below the getTokenInfo function, add the getTokenBalance function and the module export by copying this code:
// Fetches the token balance for a specific wallet address
async function getTokenBalance(tokenAddress, walletAddress) {
  const provider = getProvider();
  const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);

  // Fetch balance, decimals, and symbol in parallel
  const [balance, decimals, symbol] = await Promise.all([
    contract.balanceOf(walletAddress),
    contract.decimals(),
    contract.symbol(),
  ]);

  return {
    tokenAddress,
    walletAddress,
    symbol,
    // Convert raw balance from smallest unit to human-readable format
    balance: ethers.formatUnits(balance, decimals),
    rawBalance: balance.toString(),
  };
}

module.exports = { getTokenInfo, getTokenBalance };

What does this code do?

getTokenBalance queries how many tokens a wallet holds. Token balances on Ethereum are stored as raw integers (e.g., 1000000 for 1 USDT), so ethers.formatUnits() converts that into a human-readable string using the token's decimal places.

The module.exports line makes both functions available to other files in your project.

  • Save src/services/blockchain.js.

Seeing a red underline or syntax error?

  • Check that each function has matching opening and closing braces. A missing } is the most common cause.
  • Make sure the first line is exactly const { ethers } = require("ethers"); with the curly braces around ethers.
  • help me fix a syntax error in my blockchain.js file

✔️ 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

const { ethers } = require("ethers");

const ERC20_ABI = [
  "function name() view returns (string)",
  "function symbol() view returns (string)",
  "function decimals() view returns (uint8)",
  "function balanceOf(address account) view returns (uint256)",
];

function getProvider() {
  const rpcUrl = process.env.ETHEREUM_RPC_URL || "https://eth.llamarpc.com";
  return new ethers.JsonRpcProvider(rpcUrl);
}

async function getTokenInfo(tokenAddress) {
  const provider = getProvider();
  const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);

  const [name, symbol, decimals] = await Promise.all([
    contract.name(),
    contract.symbol(),
    contract.decimals(),
  ]);

  return {
    address: tokenAddress,
    name,
    symbol,
    decimals: Number(decimals),
  };
}

async function getTokenBalance(tokenAddress, walletAddress) {
  const provider = getProvider();
  const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);

  const [balance, decimals, symbol] = await Promise.all([
    contract.balanceOf(walletAddress),
    contract.decimals(),
    contract.symbol(),
  ]);

  return {
    tokenAddress,
    walletAddress,
    symbol,
    balance: ethers.formatUnits(balance, decimals),
    rawBalance: balance.toString(),
  };
}

module.exports = { getTokenInfo, getTokenBalance };

Why a public RPC instead of Infura or Alchemy?

Public RPC endpoints like eth.llamarpc.com work fine for read-only queries at low volume. This avoids signing up for another service just to complete this project. You can swap in an Infura or Alchemy key later for production use.

Build the Express API routes

The routes file translates HTTP requests into blockchain service calls. Each route catches errors and returns proper status codes so clients know whether the request succeeded.

  • Open src/routes/token.js in VS Code.
  • Add the full routes module by copying this code:
const express = require("express");
const router = express.Router();
const { getTokenInfo, getTokenBalance } = require("../services/blockchain");

// GET /api/token/:address - Returns token metadata
router.get("/:address", async (req, res) => {
  try {
    const tokenInfo = await getTokenInfo(req.params.address);
    res.json(tokenInfo);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// GET /api/token/:tokenAddress/balance/:walletAddress - Returns token balance
router.get("/:tokenAddress/balance/:walletAddress", async (req, res) => {
  try {
    const balance = await getTokenBalance(
      req.params.tokenAddress,
      req.params.walletAddress
    );
    res.json(balance);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

module.exports = router;

What does this code do?

This file creates an Express Router with two GET endpoints. The first endpoint (/:address) returns a token's name, symbol, and decimals. The second (/:tokenAddress/balance/:walletAddress) returns how much of that token a specific wallet holds.

Both routes wrap the service call in a try-catch block. If the blockchain call fails (invalid address, network timeout), the API returns a 500 status with the error message instead of crashing.

  • Save src/routes/token.js.

You won't see a change yet. The next file wires these routes into a running server.

Seeing a require path error?

  • The require path "../services/blockchain" goes up one folder from routes/ into src/, then into services/. Make sure you have the two dots at the start.
  • Confirm your file is saved at src/routes/token.js (not directly in src/).
  • help me fix my require path in token.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

const express = require("express");
const router = express.Router();
const { getTokenInfo, getTokenBalance } = require("../services/blockchain");

router.get("/:address", async (req, res) => {
  try {
    const tokenInfo = await getTokenInfo(req.params.address);
    res.json(tokenInfo);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

router.get("/:tokenAddress/balance/:walletAddress", async (req, res) => {
  try {
    const balance = await getTokenBalance(
      req.params.tokenAddress,
      req.params.walletAddress
    );
    res.json(balance);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

module.exports = router;
Wire up the Express server

The entry point file creates the Express app, mounts your routes, and starts listening for requests. It also exports the app separately from the server start, which is a common pattern that lets test files import the app without starting the server.

  • Open src/index.js in VS Code.
  • Add the full server setup by copying this code:
const express = require("express");
const dotenv = require("dotenv");
const tokenRoutes = require("./routes/token");

// Load environment variables from .env file
dotenv.config();

const app = express();
const PORT = process.env.PORT || 3000;

// Parse JSON request bodies
app.use(express.json());

// Health check endpoint for monitoring and deployment checks
app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

// Mount token routes under /api/token
app.use("/api/token", tokenRoutes);

// Only start the server when this file is run directly (not imported by tests)
if (require.main === module) {
  app.listen(PORT, () => {
    console.log(`Token Balance API running on port ${PORT}`);
  });
}

module.exports = app;

What does this code do?

dotenv.config() loads your .env file into process.env, making PORT and ETHEREUM_RPC_URL available everywhere in your app.

The /health endpoint is a simple check that returns {status: "ok"}. Deployment platforms like Render ping this endpoint to know your service is alive.

The require.main === module check is key. It means the server only starts listening when you run the file directly with node src/index.js. When a test file imports the app, it gets the app object without starting a conflicting server.

  • Save src/index.js.
  • Start the server by running this command in your VS Code terminal:
node src/index.js
  • You should see Token Balance API running on port 3000 in your terminal.
  • Open your browser and go to http://localhost:3000/health.

You should see {"status":"ok"} displayed in your browser. Your API is running.

  • Go back to your terminal and press Ctrl+C to stop the server.

Not seeing the expected output?

  • If you see Cannot find module './routes/token', check that your file is at src/routes/token.js (not routes/token.js at the project root).
  • If you see EADDRINUSE, port 3000 is already in use. Close any other terminal that might be running a server, then try again.
  • If the browser shows This site can't be reached, make sure the server is still running in your terminal (you should see the "running on port 3000" message).
  • help me fix my Express server startup

✔️ 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

const express = require("express");
const dotenv = require("dotenv");
const tokenRoutes = require("./routes/token");

dotenv.config();

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

app.use("/api/token", tokenRoutes);

if (require.main === module) {
  app.listen(PORT, () => {
    console.log(`Token Balance API running on port ${PORT}`);
  });
}

module.exports = app;

Your API is alive and serving responses. Next up, you'll write a test for it and package the whole thing into a Docker container that runs anywhere.

Write Tests and Containerize with Docker

Your API is functional. You tested it locally and confirmed it reads real token data from the Ethereum blockchain.

Now you need two things before shipping: automated tests to catch bugs early, and a Docker container to make your app portable. In this step, you'll write a test suite and package your API into a Docker image that runs identically on any machine.

In this step, get ready to:
  • Write a health check test and run it with Jest.
  • Create a Dockerfile to containerize your API.
  • Build and run the Docker container locally.
Write a health check test

Automated tests validate your API works correctly before every deployment. If a test fails, you'll know something broke before it reaches production.

Your src/index.js already exports the Express app without starting the server (thanks to the require.main === module pattern). This means your test can import the app, start it on a random port, and make requests against it.

Why test the health endpoint?

The health endpoint is the simplest possible test target. It doesn't depend on external services like the Ethereum RPC, so it will always pass regardless of network conditions.

This gives your CI/CD pipeline a reliable signal that the app starts up and responds to requests. You can add more complex tests later.

  • Create a folder called tests inside your token-balance-api folder, then create a file called health.test.js inside it.
  • Add the following test code to tests/health.test.js:
const http = require("http");
const app = require("../src/index");

let server;

// Start the app on a random available port before tests run
beforeAll((done) => {
  server = app.listen(0, done);
});

// Shut down the server after all tests finish
afterAll((done) => {
  server.close(done);
});

// Verify the health endpoint returns a 200 status and correct body
test("GET /health returns status ok", async () => {
  const port = server.address().port;
  const response = await fetch(`http://localhost:${port}/health`);
  const data = await response.json();
  expect(response.status).toBe(200);
  expect(data.status).toBe("ok");
});

What does this code do?

  • app.listen(0, done) starts the server on port 0, which tells the OS to pick any available port. This avoids conflicts with other processes.
  • server.address().port retrieves the actual port the OS assigned, so the test knows where to send requests.
  • The test uses the global fetch (available in Node.js 20+) to make a real HTTP request to the running server.
  • expect() assertions verify both the HTTP status code and the JSON body match what the health endpoint should return.
  • Save tests/health.test.js.
  • Run your test suite by running this command in the VS Code terminal:
npm test

You should see output showing 1 test passed with a green checkmark.

Test failing or hanging?

  • If the test hangs and never finishes, make sure your package.json test script includes --detectOpenHandles --forceExit. These flags ensure Jest shuts down cleanly.
  • If you see Cannot find module '../src/index', check that your tests folder is at the root of token-balance-api (not inside src).
  • If you see a port conflict error, make sure you stopped your local server (Ctrl+C) from the previous step.

Why is my Jest test failing or hanging?

Create the Dockerfile

A Dockerfile is a recipe that tells Docker exactly how to package your app into an image. The image contains everything needed to run your API: the operating system, Node.js, your dependencies, and your source code.

Anyone with Docker can run your image and get an identical environment, regardless of their operating system or installed software.

  • Create a new file called Dockerfile (no file extension) in the root of your token-balance-api folder.
  • Add this content to your Dockerfile:
# Use a lightweight Node.js 20 image based on Alpine Linux
FROM node:20-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy dependency files first to leverage Docker layer caching
COPY package.json package-lock.json ./

# Install only production dependencies
RUN npm ci --only=production

# Copy the application source code
COPY src ./src

# Document which port the container listens on
EXPOSE 3000

# Start the API server
CMD ["node", "src/index.js"]

What does this Dockerfile do?

  • FROM node:20-alpine uses Node.js 20 LTS on Alpine Linux, a minimal base image that is roughly 5x smaller than the default Debian-based image.
  • COPY package.json package-lock.json ./ copies dependency files before the source code. Docker caches each layer, so if your code changes but dependencies stay the same, Docker skips the slow install step on rebuild.
  • npm ci --only=production installs exact versions from the lockfile and skips dev dependencies like Jest. This keeps the image small.
  • COPY src ./src copies only the source code folder. Tests, environment files, and other development files stay out of the image.
  • Save the Dockerfile.

You also need a .dockerignore file to prevent unnecessary files from being sent to Docker during the build.

  • Create a new file called .dockerignore in the root of your token-balance-api folder.
  • Add these exclusions to .dockerignore:
node_modules
.env
.git
tests
.github

Why exclude these files?

node_modules gets rebuilt inside the container by npm ci. .env contains secrets that should never be baked into an image. tests and .github are development-only files that the running API doesn't need.

  • Save .dockerignore.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on to the build step.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile:

FROM node:20-alpine

WORKDIR /app

COPY package.json package-lock.json ./

RUN npm ci --only=production

COPY src ./src

EXPOSE 3000

CMD ["node", "src/index.js"]

Here is the complete .dockerignore:

node_modules
.env
.git
tests
.github
Build and run the Docker container locally

With your Dockerfile ready, you can now build an image and spin up a container. This proves your API runs correctly in an isolated environment before you deploy it anywhere.

  • Make sure Docker Desktop is running. You should see the Docker whale icon in your system tray.
  • If your local server from the previous step is still running, stop it with Ctrl+C.
  • Build your Docker image by running this command in the VS Code terminal:
docker build -t token-balance-api .

The build takes 30-60 seconds the first time as it downloads the Alpine Linux base image. You should see output ending with a line like Successfully tagged token-balance-api:latest.

What does this command do?

  • docker build reads your Dockerfile and executes each instruction to create a new image.
  • -t token-balance-api tags (names) the image so you can reference it easily when running a container.
  • . tells Docker to use the current directory as the build context (the files it has access to).

Build failing?

  • If you see error during connect or Cannot connect to the Docker daemon, Docker Desktop is not running. Open it and wait for the engine status to show "Running".
  • If you see COPY failed: file not found, make sure your terminal is in the token-balance-api folder (the same folder where your Dockerfile lives).
  • If you see package-lock.json not found, run npm install once to generate the lockfile, then retry the build.

Why is my Docker build failing?

  • Now start a container from your image by running this command:
docker run -p 3000:3000 --env-file .env token-balance-api

What does this command do?

  • -p 3000:3000 maps port 3000 on your machine to port 3000 inside the container, so you can access the API from your browser.
  • --env-file .env passes your environment variables into the container at runtime. This keeps secrets out of the image itself.
  • token-balance-api is the name of the image you just built.

You should see Token Balance API running on port 3000 in your terminal. Your API is now running inside a Docker container.

  • Open your browser and go to http://localhost:3000/health.
  • Confirm you see {"status":"ok"} in the browser.
  • Now test a real token query by navigating to:
http://localhost:3000/api/token/0xdAC17F958D2ee523a2206206994597C13D831ec7

You should see JSON data for USDT (Tether), including its name, symbol, and decimals. This proves your containerized API successfully connects to the Ethereum blockchain.

  • Go back to your terminal and press Ctrl+C to stop the container.

Container starts but endpoints return errors?

  • If you see Cannot GET /, make sure you are hitting /health or /api/token/:address. There is no route at the root path.
  • If the token endpoint returns a 500 error, your .env file may not be loading. Check that .env exists in your token-balance-api folder and contains your ETHEREUM_RPC_URL.
  • If the browser cannot connect at all, confirm port 3000 is not already in use by another process.

Why is my Docker container returning errors?

Your API now runs identically inside a container, no matter what machine it's on. Next up, you'll set up a CI/CD pipeline so every push to GitHub automatically tests and builds your Docker image.

Set Up GitHub Actions CI/CD

Your API runs in a Docker container locally. But every time you change code, you have to manually rebuild the image and hope nothing broke.

A CI/CD pipeline automates this. Every push to your repository triggers tests and a Docker build automatically. If anything fails, you know immediately before broken code reaches production.

In this step, get ready to:
  • Push your project to a public GitHub repository.
  • Create a GitHub Actions workflow that runs tests and builds your Docker image.
  • Verify the pipeline triggers automatically on every push.
Push your project to GitHub

Your code needs to live in a GitHub repository before GitHub Actions can run workflows on it. Your .gitignore file already exists from the previous step, so Git will automatically exclude node_modules and .env from your commits.

  • Initialize a Git repository and commit your code by running these commands in your VS Code terminal:
git init
git add .
git commit -m "Initial commit"

What do these commands do?

  • git init creates a new Git repository in your project folder.
  • git add . stages all files (except those in .gitignore) for your first commit.
  • git commit -m "Initial commit" saves a snapshot of your project with a message.

Now you need a remote repository on GitHub to push your code to.

  • Open your browser and go to https://github.com/new.
  • Enter token-balance-api in the Repository name field.
  • Select Public for the visibility.
  • Leave all other options unchecked (no README, no .gitignore, no license).
  • Click Create repository.

Why leave everything unchecked?

You already have a local repository with files. Adding a README or .gitignore on GitHub would create a conflict when you push. Starting with an empty remote keeps things clean.

  • GitHub shows you setup commands after creating the repo. Connect your local repository to GitHub and push your code by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/token-balance-api.git"]]
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin links your local repo to the GitHub repo you just created.
  • git branch -M main renames your default branch to main.
  • git push -u origin main uploads your code to GitHub and sets main as the default tracking branch.
  • Refresh your GitHub repository page in the browser. You should see all your project files listed (src/, Dockerfile, package.json, etc.).

Push failed or authentication error?

  • If you see a permission error, make sure you are logged into Git with your GitHub credentials. Run git config --global user.email to check your email is set.
  • If Git asks for a password, GitHub no longer accepts passwords for HTTPS. Use a Personal Access Token instead (Settings > Developer Settings > Personal Access Tokens on GitHub).
  • If you see "remote origin already exists", run git remote remove origin and then try the git remote add command again.

Help me push my project to GitHub

Create the GitHub Actions workflow

GitHub Actions runs automated workflows in response to events on your repository. You define a workflow in a YAML file inside a special .github/workflows/ directory.

  • Create the workflow directory by running this command:
mkdir -p .github/workflows
  • Create the workflow file by running:
touch .github/workflows/ci.yml
  • Confirm the file exists by running:
ls .github/workflows
  • You should see ci.yml in the output.
  • Open .github/workflows/ci.yml in VS Code by clicking it in the Explorer sidebar.
  • Add the workflow configuration by pasting the following code:
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test-and-build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6

      - name: Use Node.js 20.x
        uses: actions/setup-node@v4
        with:
          node-version: "20.x"

      - run: npm ci

      - run: npm test

      - name: Build Docker image
        run: docker build -t token-balance-api .

What does this workflow do?

  • on: defines when the workflow triggers. It runs on every push to main and on every pull request targeting main.
  • runs-on: ubuntu-latest tells GitHub to spin up a fresh Ubuntu virtual machine for each run.
  • actions/checkout@v6 pulls your repository code into the runner.
  • actions/setup-node@v4 installs Node.js 20.x on the runner.
  • npm ci installs your exact dependencies from the lock file.
  • npm test runs your Jest health check test. If it fails, the pipeline stops here.
  • docker build verifies your Dockerfile still builds successfully after code changes.

Why run Docker build in CI?

Building the Docker image in CI catches Dockerfile errors before deployment. If a dependency is missing or a file copy fails, you find out immediately instead of during a live deploy.

  • Save .github/workflows/ci.yml.
  • Push the workflow file to GitHub by running these commands:
git add .
git commit -m "Add CI workflow"
git push
  • Open your GitHub repository in your browser.
  • Click the Actions tab at the top of the page.
  • You should see a workflow run in progress (or recently completed) called Add CI workflow. Click it to see the details.

Don't see any workflow runs?

  • Make sure you pushed to the main branch. Run git branch to check your current branch name.
  • Verify the file path is exactly .github/workflows/ci.yml. A typo in the directory name (like .github/workflow/ without the 's') means GitHub won't detect it.
  • Check that the YAML file has no indentation errors. YAML is whitespace-sensitive.

Help me fix my GitHub Actions workflow

Verify the pipeline triggers on new pushes

The first workflow run was triggered by pushing the workflow file itself. To prove CI triggers on every push, you need to make another change and watch it run again.

  • Open src/index.js and add a code comment at the very top of the file, above the first require statement. Something like // Token Balance API works fine.
  • Save the file.
  • Commit and push this change by running:
git add .
git commit -m "Test CI pipeline"
git push
  • Go back to the Actions tab on your GitHub repository.
  • You should see a new workflow run called Test CI pipeline triggered automatically.
  • Click on it and watch the test-and-build job. Each step should show a green checkmark as it passes.

Once all steps pass with green checkmarks, your CI/CD pipeline is fully operational.

What happens if a step fails?

If your tests fail or the Docker build breaks, GitHub Actions marks the run with a red X. You get an email notification so you know immediately. The pipeline stops at the failed step, protecting downstream environments from broken code.

Workflow run shows a red X?

  • Click the failed step to see the error log. The most common cause is a test failure or a YAML syntax error.
  • If npm test fails, make sure your test passes locally before pushing. Run npm test in your terminal to check.
  • If the Docker build step fails, verify your Dockerfile builds locally with docker build -t token-balance-api . first.

Help me debug my failing CI pipeline

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Here is the complete .github/workflows/ci.yml file for comparison:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test-and-build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6

      - name: Use Node.js 20.x
        uses: actions/setup-node@v4
        with:
          node-version: "20.x"

      - run: npm ci

      - run: npm test

      - name: Build Docker image
        run: docker build -t token-balance-api .

Your code is on GitHub, and every push triggers automated tests and a Docker build. Next up, you will deploy your containerized API to the internet so anyone can call your endpoints.

Deploy to Render

Your API works locally, your Docker image builds successfully, and your CI pipeline catches errors before they reach production. The missing piece is getting your API live on the internet where other applications can actually call it.

In this step, you will deploy your containerized API to Render, a cloud platform that builds directly from your Dockerfile and auto-redeploys every time you push to GitHub. No container registry needed.

In this step, get ready to:
  • Create a Render account and connect your GitHub repos.
  • Deploy your API as a Docker-based web service on Render's free tier.
  • Test your live API endpoints with a real ERC-20 token address.
Create a Render account and connect GitHub

Render can link directly to your GitHub account, which means it can detect your Dockerfile and rebuild your image automatically whenever you push new code.

  • Open your browser and go to https://render.com.
  • Click Get Started in the top right corner.
  • Select GitHub as your sign-up method.
  • Authorize Render to access your GitHub account when prompted.

Why sign up with GitHub?

Signing up with GitHub automatically links your repositories to Render. This lets Render pull your code and Dockerfile directly from your repo without any extra configuration.

No credit card is required for Render's free tier. You get 750 free instance hours per month.

Once signed in, you should see the Render Dashboard with options to create new services.

Not seeing your repositories?

If Render cannot find your repos, you may need to grant it access. Go to your GitHub Settings, then Applications, find Render in the list, and click Configure to grant access to your repositories.

Help me connect my GitHub repos to Render

Deploy your API as a web service

Render needs to know which repo to build, what language/runtime to use, and which environment variables your app requires. You will configure all of this in the service creation flow.

  • In the Render Dashboard, click New in the top navigation.
  • Select Web Service from the dropdown.
  • Find your token-balance-api repository in the list and click Connect.

Render now shows the service configuration form. Fill in the following settings.

  • Set Language to Docker. This tells Render to build your image from the Dockerfile in the repo.
  • Set Instance Type to Free.
  • Scroll down to the Environment Variables section.
  • Click Add Environment Variable.
  • Enter ETHEREUM_RPC_URL as the key.
  • Enter https://eth.llamarpc.com as the value.
  • Click Deploy Web Service.

Why set the environment variable here?

Your .env file is in .gitignore and .dockerignore, so it never reaches Render. Environment variables set in the Render dashboard are injected into the container at runtime.

Render will now clone your repo, build your Docker image using BuildKit, and start the container. This takes 2-5 minutes the first time.

What is happening during the build?

Render runs the same Dockerfile you tested locally. It pulls the node:20-alpine base image, installs your production dependencies with npm ci, copies your source code, and starts the Express server.

  • Watch the build logs in the Render dashboard. Wait until you see a green Live status badge at the top of the page.

Build failed?

Check the build logs for the specific error. Common causes include a missing package-lock.json (required by npm ci) or the Language not being set to Docker.

Make sure your package-lock.json is committed and pushed to GitHub. If you only have package.json in your repo, run npm install locally, commit the generated lock file, and push again.

Help me debug my Render deployment

Test your live API

Your API is now running on the internet. Render provides a public URL that anyone can call. The free tier spins down after 15 minutes of inactivity, so the first request after idle takes about a minute to cold-start.

  • Find your service URL at the top of the Render dashboard page. It looks like https://token-balance-api-xxxx.onrender.com.
  • Open a new browser tab and navigate to your URL followed by /health.

You should see the JSON response {"status":"ok"} in your browser. Your container is running.

  • Now test the token endpoint. Navigate to your URL followed by /api/token/0xdAC17F958D2ee523a2206206994597C13D831ec7.

This queries the USDT token contract on Ethereum mainnet. You should see a JSON response containing the token name, symbol, and decimals.

What should I see?

The response looks something like: {"address":"0xdAC17F958D2ee523a2206206994597C13D831ec7","name":"Tether USD","symbol":"USDT","decimals":6}. This is live data read directly from the Ethereum blockchain by your deployed container.

Getting a timeout or 502 error?

If this is the first request after the service spun down, wait up to 60 seconds for the cold start. Refresh the page after a minute.

If you see a 500 error with a message about the RPC call, check that your ETHEREUM_RPC_URL environment variable is set correctly in the Render dashboard. Go to your service, click Environment in the left sidebar, and verify the value.

My Render deployment is returning errors

Why Render over Railway or Fly.io?

Render has a true free tier with no credit card required and 750 free instance hours per month. Railway only gives $1/month in credit, and Fly.io no longer offers a free tier for new users.

Render also builds directly from your Dockerfile, so you do not need to push images to a container registry like Docker Hub. The deployment flow stays simple.

Your API is now fully deployed. Every time you push to the main branch, GitHub Actions runs your tests, builds the Docker image, and then Render automatically picks up the change and redeploys your container.

You have gone from a local Node.js project to a fully containerized, CI-tested, and cloud-deployed blockchain API. Next up, you can push the boundaries even further with a Secret Mission that adds write operations to the Ethereum blockchain.

Secret mission

Deploy Your Own Contract and Add a Write Endpoint

You've been reading data from existing contracts on Ethereum mainnet. Now it's time to go full-stack. Deploy your own smart contract to Sepolia testnet and add write operations to your API.

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 uses Docker Desktop (free), GitHub (free), and Render's free tier, so there are no ongoing costs.

Resources you used:

  • Local project folder token-balance-api with Node.js source code.
  • Docker Desktop with locally built Docker images.
  • GitHub repository with your project code and Actions workflow.
  • Render web service running your containerized API.

Keep everything running

No action needed. Choose this if you are still actively building or want your live API endpoint to stay accessible.

  • Your Render service stays live and redeploys automatically when you push to GitHub.
  • Render's free tier spins down your service after 15 minutes of inactivity. It restarts automatically on the next request (takes about 1 minute).
  • Docker Desktop continues running in the background. You can quit it from the system tray if you want to free up memory.

Pause - I'll come back to this later

Shut down local processes and suspend cloud services to free up memory, while keeping all your code and configuration intact.

  • Quit Docker Desktop from the system tray to free up RAM and CPU.
  • In the Render Dashboard, navigate to your web service and click Suspend to stop it from running. You can resume it any time.
  • Your GitHub repository and Actions workflow remain intact. No action needed there.

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • In the Render Dashboard, go to your web service, click Settings, scroll to the bottom, and click Delete Web Service.
  • On GitHub, go to your repository, click Settings, scroll to the Danger Zone, and click Delete this repository.
  • Remove local Docker images by running docker rmi token-balance-api in Git Bash.
  • Delete the token-balance-api folder from your Desktop.
  • If you no longer need Docker Desktop, uninstall it from Settings, then Apps & features.

That's a Wrap!

That's a Wrap!

Nice work! You just shipped a blockchain API from local development to a live deployment, wrapped in Docker with automated CI/CD. Not bad for an hour.

You've learned how to:

  • Built a REST API with Express.js that reads ERC-20 token data from Ethereum using ethers.js.
  • Wrote a Dockerfile to containerize a Node.js application with a lightweight Alpine Linux base image.
  • Created a GitHub Actions pipeline that runs tests and builds Docker images on every push, then deployed to Render with automatic redeployment from GitHub.
  • Secret Mission: Deployed your own smart contract to Sepolia testnet and added read/write endpoints to your API.

Ready to quiz yourself?