What We’re Building
This is a full walkthrough, understanding errors, explaining different steps and strategies, on how we’re going to build a Docker image that holds and runs a Hardhat Solidity contract on localhost.
I go in depth as much possible on this article, so that you fully understand the different steps, but I also don’t skip out on error scenarios to better understand how to handle them.
TL;DR
If you want to see the final code, take a look at the bottom for Final Code.
Why Do This?
Docker is great for making things work easier on multiple platforms and making things consistent for all developers. It’s also a great way to get a piece of a full application running locally without having to spend time configuring or setting things up.
The main inspiration for this was a Developer DAO Git Issue some of the developers were facing in dealing with Etherscan’s API limits and potentially having automated tests fail because of it. A locally deployed contract that could be run in a Docker container without having to have this entire code in the main repository seem like the most logical step.
Project Setup
We’ll be using the default Hardhat Sample Project to bootstrap the process a bit and focus more on creating the Docker image versus writing the contract.
#!/bin/bashnpx hardhat;# ✔ What do you want to do? · Create a basic sample project
# ✔ Hardhat project root: · /path/to/hardhat-docker
# ✔ Do you want to add a .gitignore? (Y/n) · y
# ✔ Do you want to install this sample project's dependencies with npm (hardhat @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers)? (Y/n) · y
Make sure we initiate git for your IDE.
#!/bin/bashgit init;
Next we’re going to make a copy of our hardhat.config.js
file so that we can have multiple configurations based on if we want to deploy to mainnet directly or just for our localhost network. This also makes things a bit cleaner for when we want to use the file just for testing later and not have functions in our production config compared to our local configration.
#!/bin/bashcp hardhat.config.js hardhat.config.local.js;
Next, we’re going to want to modify of Hardhat network because of a MetaMask chainId issue:
File: hardhat.config.local.js
require("@nomiclabs/hardhat-waffle");// This is a sample Hardhat task. To learn how to create your own go to
// https://hardhat.org/guides/create-task.html
task("accounts", "Prints the list of accounts", async (taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();for (const account of accounts) {
console.log(account.address);
}
});// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: "0.8.4",
networks: {
hardhat: {
chainId: 1337
},
},
};
Now that we have our configuration files setup, let’s run a local network to test if things are working using our new configuration file.
#!/bin/bash
# Terminal 1./node_modules/.bin/hardhat --config hardhat.config.local.js node;# Expected output
# Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/# Accounts
# ========
# Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
# Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# ...
While keeping this terminal alive, open a new one and deploy the contract.
#!/bin/bash
# Terminal 2./node_modules/.bin/hardhat --config hardhat.config.local.js --network localhost run scripts/sample-script.js# Expected output
# Compiling 2 files with 0.8.4
# Compilation finished successfully
# Greeter deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
On our first terminal we should also see the following result to show that it has been deployed successfully.
#!/bin/bash
# Back On Terminal 1# Expected output
# eth_sendTransaction
# Contract deployment: Greeter
# Contract address: 0x5fbdb2315678afecb367f032d93f642f64180aa3
# ...
Just want to pause here and quickly dissect why I used the the flag --network localhost
and why this is important. What sometimes happens is that you might encounter the following type of hardhat.config.js
file:
File (Example) hardhat.config.js
require("@nomiclabs/hardhat-waffle");const pk = process.env.dapk;module.exports = {
solidity: "0.8.4",
networks: {
hardhat: {
chainId: 1337
},
ropsten: {
url: "https://ropsten.infura.io/v3/abcd123",
account: [`0x${pk}`]
},
mainnet: {
url: `https://mainnet.infura.io/v3/abcd123`,
account: [`0x${pk}`]
}
}
}
This might be a typical production file and with it gives you the option to use various networks. So if you want to be able to specify which network you want to deploy to, we ideally want to use --network {networkname}
. If we don’t specify that Hardhat assumes that it’s going to default to mainnet
, which we don’t want. So in the case of our hardhat.condig.local.js
we really do want to make sure it’s set to --network localhost
.
Great, we have everything we need to start the process of Dockerizing our contract.
To make the commands a bit simpler, we’re going to add these to our package.json
scripts.
File: package.json
{
"name": "hardhat-docker",
"scripts": {
"start:local": "./node_modules/.bin/hardhat --config hardhat.config.local.js node",
"deploy:local": "./node_modules/.bin/hardhat --config hardhat.config.local.js --network localhost run scripts/sample-script.js",
"test:local": "./node_modules/.bin/hardhat --config hardhat.config.local.js test"
},
"devDependencies": {
"@nomiclabs/hardhat-ethers": "^2.0.2",
"@nomiclabs/hardhat-waffle": "^2.0.1",
"chai": "^4.3.4",
"ethereum-waffle": "^3.4.0",
"ethers": "^5.4.7",
"hardhat": "^2.6.4"
}
}
Creating Docker File & Configurations
Our first iteration is going to take into account using an alpine version of node and getting all the dependencies to install.
NOTE: I use /usr/src/app
as directory that I just made up, you can technically put this almost anywhere you want.
File: Dockerfile
FROM node:14-alpineCOPY . /usr/src/appWORKDIR /usr/src/appRUN yarn install --non-interactive --frozen-lockfile
Let’s give this a test, but just before that let’s make sure we don’t copy over any unwanted files with a .dockerignore
File: .dockerignore
artifacts
cache
node_modules
test
*.log
With that, let’s try and to build our app, we’ll notice it’ll fail with a few errors.
#!/bin/shdocker build . -t hhdocker;# Expected output
# ...
# error Couldn't find the binary git
# ...
This is because our alpine base image doesn’t come with git
so we need to install it ourselves.
File: Dockerfile
FROM node:14-alpineCOPY . /usr/src/appWORKDIR /usr/src/appRUN apk add git;RUN yarn install --non-interactive --frozen-lockfile
Great, so when we build it again, we’ll see that there’s no errors.
#!/bin/shdocker build . -t hhdocker;
However, the moment we run it interactively, we’ll notice that our Docker container doesn’t have anything to run specifically so it defaults to the node prompt.
#!/bin/shdocker run -it --name myhd hhdocker;# Expected output
# Welcome to Node.js v14.17.6.
# Type ".help" for more information.
# ># Expected output after pressing ctrl + c
# >
# (To exit, press Ctrl+C again or Ctrl+D or type .exit)
# >
Our main goal is try and get yarn start:local
to run, but also keep our node environment alive if it’s run in the background and non-interactively.
To do this, we need to add an entrypoint.sh
file which will be run at start of the Docker instance running. I like to keep things clean, so I’m going to create a new docker
folder in the root to keep things separate from our main code.
File: docker/entrypoint.sh
#!/bin/sh# Change to the correct directory
cd /usr/src/app;# Run hardhat
yarn start:local;# Keep node alive
set -e
if [ "${1#-}" != "${1}" ] || [ -z "$(command -v "${1}")" ]; then
set -- node "$@"
fi
exec "$@"
We then need to tie in this new shell script in our Dockerfile
.
File: Dockerfile
FROM node:14-alpineCOPY . /usr/src/appWORKDIR /usr/src/appRUN apk add git;RUN yarn install --non-interactive --frozen-lockfileCOPY $PWD/docker/entrypoint.sh /usr/local/binENTRYPOINT ["/bin/sh", "/usr/local/bin/entrypoint.sh"]
Let’s try building and running our container again.
#!/bin/sh
# Terminal 1# make sure the docker container is removed with
# docker rm -f myhd;docker build . -t hhdocker;
# ...docker run -it --name myhd hhdocker;# Expected output
# ./node_modules/.bin/hardhat --config hardhat.config.local.js node
# Started HTTP and WebSocket JSON-RPC server at http://0.0.0.0:8545/# Accounts
# ========
# Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
# Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# ...
Looks like things are working, so let’s open up a second terminal and run our deploy script but this time from outside of the container.
#!/bin/sh
# Terminal 2docker exec -it myhd /bin/sh -c "cd /usr/src/app; yarn deploy:local";# Expected output
# docker exec -it myhd /bin/sh -c "cd /usr/src/app; yarn deploy:local";
# $ ./node_modules/.bin/hardhat --config hardhat.config.local.js --network localhost run scripts/sample-script.js
# Downloading compiler 0.8.4
# Compiling 2 files with 0.8.4
# Compilation finished successfully
# Greeter deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Nice, we can now run our node and do the deploy all within the Docker container.
Testing Localhost
Next I want to create a script that tests agains that Docker container as a client to make sure things are working correctly. You might ask, what about the existing default Hardhat greeter test?
Well if run those in Docker container, you should see that the container doesn’t have the tests because we didn’t include them with the .dockerignore
.
#!/bin/shdocker exec -it myhd /bin/sh -c "cd /usr/src/app; yarn test:local";# Expected output
# $ ./node_modules/.bin/hardhat --config hardhat.config.local.js test# 0 passing (2ms)# Done in 1.52s.
But if we run things outside the Docker container, we’ll see that everything passes.
#!/bin/shyarn test:local# Greeter
# Deploying a Greeter with greeting: Hello, world!
# Changing greeting from 'Hello, world!' to 'Hola, mundo!'
# ✓ Should return the new greeting once it's changed (618ms)# 1 passing (623ms)# ✨ Done in 1.84s.
But that isn’t a fair test, because you’ll see that in the test it deploys the contract by itself and uses that address to test against, and not the one we deployed within the container. For that reasons we’ll need to create a script that communicates with our container as a client.
Before we continue we just need to make sure we’re run a new container instance with the ports 8545
exposed.
#!/bin/shdocker rm -f myhd;# Run non interactive with -d and exposing the port with -pdocker run -it -d -p 8545:8545 --name myhd hhdocker;# Verify up and runningdocker logs myhd;# Expected output (if you don't see this run above again)
# Started HTTP and WebSocket JSON-RPC server at http://0.0.0.0:8545/# Accounts
# ========
# Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
# Private Key: # 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80# Deploy contactdocker exec -it myhd /bin/sh -c "cd /usr/src/app; yarn deploy:local";# Expected output
# Downloading compiler 0.8.4
# Compiling 2 files with 0.8.4
# Compilation finished successfully
# Greeter deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
# Done in 12.17s.
Creating Our Client
For our client, considering we’re using node, let’s create a folder called client
and add node.js
to it. The goal of this is to have our client, as a backend client, interact with our contract.
File: client/node.js
// Imports
const ethers = require('ethers');
const abi = require('../artifacts/contracts/Greeter.sol/Greeter.json').abi;// Constants - take note of the contract address we deployed above
const CONTRACT_ADDRESS = '0x5FbDB2315678afecb367f032d93F642f64180aa3';// Config
const provider = ethers.getDefaultProvider('http://localhost:8545');
const contract = new ethers.ethers.Contract(CONTRACT_ADDRESS, abi, provider);// Init - async func because we need to await the contract functions
const init = async () => {
try {
const result = await contract.greet();
console.log({ result })
} catch (error) {
console.log({ error });
}
}init();
Now with our new Docker container having its ports exposed, let’s try to connect to it.
#!/bin/shnode client/node.js;# Expected output
# { result: 'Hello, Hardhat!' }
Let’s modify our script to update the message and output the latest.
File: client/node.js
// Imports
const ethers = require('ethers');
const abi = require('../artifacts/contracts/Greeter.sol/Greeter.json').abi;// Constants - take note of the contract address we deployed above
const CONTRACT_ADDRESS = '0x5FbDB2315678afecb367f032d93F642f64180aa3';// Config
const provider = ethers.getDefaultProvider('http://localhost:8545');
const contract = new ethers.ethers.Contract(CONTRACT_ADDRESS, abi, provider);// Init - async func because we need to await the contract functions
const init = async () => {
try {
const result = await contract.greet();
console.log({ result }); const transaction = await contract.setGreeting('Hello from docker!');
console.log({ transaction }); // Wait for transaction to be complete
transaction.wait(); // Output result
console.log({ result: await contract.greet() });
} catch (error) {
console.log({ error });
}
}init();
Let’s try running it and see the results.
#!/bin/sh
node client/node.js;# Expected output
# { result: 'Hello, Hardhat!' }
# {
# error: Error: sending a transaction requires a signer (operation="sendTransaction", code=UNSUPPORTED_OPERATION, version=contracts/5.4.1)
# ...
We get this error because we need a wallet that is our user who is confirming the mining of the transaction by signing it with their address. If you noticed when first started our node, it outputted a bunch of wallet addresses and private keys. We’re going to use one of those to effectively sign the transaction, but in order to do this, we’ll need to modify our code allow for signing.
File: client/node.js
// Imports
const ethers = require('ethers');
const abi = require('../artifacts/contracts/Greeter.sol/Greeter.json').abi;// Constants
const CONTRACT_ADDRESS = '0x5FbDB2315678afecb367f032d93F642f64180aa3';// Config
# Notice how we no longer use http://localhost:8545
# That is because JsonRpcProvider defaults to it
const provider = new ethers.ethers.providers.JsonRpcProvider();# Our signer becomes the provider
# with the address produce from out node launch
const signer = provider.getSigner('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266')const contract = new ethers.ethers.Contract(CONTRACT_ADDRESS, abi, signer);// Init
const init = async () => {
try {
const result = await contract.greet();
console.log({ result });const transaction = await contract.setGreeting('Hello from docker!');
console.log({ transaction }); // Wait for transaction to be complete
transaction.wait(); // Output result
console.log({ result: await contract.greet() });
} catch (error) {
console.log({ error });
}
}init();
Now, with our updated code, let’s try it again.
#!/bin/sh
node client/node.js;# Expected output
# { result: 'Hello, Hardhat!' }
# {
# transaction: {
# hash: # '0x21af2b84b832d9913e30ccbcd2b9cfb4a11f897d9f0a9ef6e51d646e4411edd2'
# ...
# { result: 'Hello from docker!' }
Great we got our Docker working, we got our script working with a provider, what’s next?
Automating Contract & Wallet Addresses
Typing out all these addresses are fine if you’re testing things manually, but we want to completely automated this process, no copying and pasting.
To take advantage of this, we’ll need to install dotenv
so that we can use environment variables in our files.
yarn add -D dotenv;
Let’s create a template for our environment variables. This is just so others can have a reference as to what might be needed for environment variables.
File: .env.example
CONTRACT_ADDRESS=
WALLET_ADDRESS=
That way when we copy that file as .env
we have a base to work with.
Let’s also make sure our .dockerignore
includes .env
, we won’t need it in the Docker container.
Make a copy of your .env.example
as .env
and replace it the values we have in our node.js
client file.
File: .env
CONTRACT_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3
WALLET_ADDRESS=0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Then we’ll need to modify our client with the environment variables.
File: client/node.js
// Imports
const ethers = require('ethers');
const abi = require('../artifacts/contracts/Greeter.sol/Greeter.json').abi;
const config = require('dotenv').config;// Config
const provider = new ethers.ethers.providers.JsonRpcProvider();
const signer = provider.getSigner(process.env.WALLET_ADDRESS || 'UNKNOWN_WALLET_ADDRESS');
const contract = new ethers.ethers.Contract(process.env.CONTRACT_ADDRESS || 'UNKNOWN_CONTRACT_ADDRESS', abi, signer);// Init
const init = async () => {
try {
const result = await contract.greet();
console.log({ result }); const transaction = await contract.setGreeting('Hello from docker!');
console.log({ transaction }); // Wait for transaction to be complete
transaction.wait(); // Output result
console.log({ result: await contract.greet() });
} catch (error) {
console.log({ error });
}
}init();
Now that we have the script working with the .env
lets automate the process of filling out that .env
file. To do that we’ll need to output the wallet address at node run time and as well as the deployed contract. The only unfortunate part is that we can’t output the secret key (or at least I haven’t found a way using Hardhat). There might be possibility to output the secret using some sed
commands, but I’ll try and keep this a bit easier for this time.
To get a contract and a wallet address, we’re going to use Hardhat tasks, and create a new script
in our package.json
.
We’re going to open up out config file and modify the existing task in there.
File: hardhat.config.local.js
require("@nomiclabs/hardhat-waffle");
const fs = require('fs');// This is a sample Hardhat task. To learn how to create your own go to
// https://hardhat.org/guides/create-task.html
task("my-deploy", "Deploys contract, get wallets, and outputs files", async (taskArgs, hre) => {
// We get the contract to deploy
const Greeter = await hre.ethers.getContractFactory("Greeter");
const greeter = await Greeter.deploy("Hello, Hardhat!"); // Await deployment
await greeter.deployed(); // Get address
const contractAddress = greeter.address; // Write file
fs.writeFileSync('./.contract', contractAddress); // Get generated signer wallets
const accounts = await hre.ethers.getSigners(); // Get the first wallet address
const walletAddress = accounts[0].address; // Write file
fs.writeFileSync('./.wallet', walletAddress);
});// You need to export an object to set up your config
// Go to https://hardhat.org/config/ to learn more/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: "0.8.4",
networks: {
hardhat: {
chainId: 1337
},
},
};
Let’s add the task in our main package.json
to use it as a run command and make it easier for us to use in yarn.
File: package.json
{
"scripts": {
...
"deploy:local": "./node_modules/.bin/hardhat --config hardhat.config.local.js --network localhost my-deploy",
...
}
}
Now if we run yarn deploy:local
, we’ll see two files generated .contract
and .wallet
. We’ll also make sure to add these to the .gitignore
and .dockerignore
because we don’t need to track these.
File: .gitignore
node_modules
.env
.contract
.wallet#Hardhat files
cache
artifacts
File: .dockerignore
artifacts
cache
node_modules
test
*.log
.env
.contract
.wallet
So why are we doing this again? The reason is because we need a way to capture those values so that we retrieve them from within the Docker container. Docker will prompt those values within itself but there’s no way for us to take advantage of it outside without storing it somewhere that could be accessible later. We’ll test this out by creating a new Docker build and running it.
#!/bin/bashdocker build . -t hhdocker;# Run non interactive with -d and exposing the port with -pdocker run -it -d -p 8545:8545 --name myhd hhdocker;# Verify up and runningdocker logs myhd;# Expected output (if you don't see this run above again)
# Started HTTP and WebSocket JSON-RPC server at http://0.0.0.0:8545/# Accounts
# ========
# Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
# Private Key: # 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80# Deploy contact# We'll replace our previous
# docker exec -it myhd /bin/sh -c "cd /usr/src/app; yarn deploy:local";
# with a short hand because we set our WORKDIR to /usr/src/app
# todocker exec -it myhd yarn deploy:local;# Expected output
# Error HH700: Artifact for contract "Greeter" not found.
This is because when added our to .dockerignore
the folder /artifacts
it doesn’t get transferred over, so we’ll need to add a new script in our package.json
which just compiles our contract.
File: package.json
{
"scripts": {
...
"compile:local": "./node_modules/.bin/hardhat --config hardhat.config.local.js compile"
}
...
}
Now let’s start the build process again.
#!/bin/bash# Remove our previous running containerdocker rm -f myhd;# Build the new containerdocker build . -t hhdocker;# Run non interactive with -d and exposing the port with -pdocker run -it -d -p 8545:8545 --name myhd hhdocker;# Verify up and runningdocker logs myhd;# Expected output (if you don't see this run above again)
# Started HTTP and WebSocket JSON-RPC server at http://0.0.0.0:8545/# Accounts
# ========
# Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
# Private Key: # 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80# Compile contractdocker exec -it myhd yarn compile:local;# Expected output
# Downloading compiler 0.8.4
# Compiling 2 files with 0.8.4
# Compilation finished successfully
# Done in 12.17s.# Deploy contractdocker exec -it myhd yarn deploy:local;# Expected output
# Done in 1.25s.
Great, but what about the files we generated? We can use the exec
command to our advantage to output those results.
#!/bin/bashdocker exec -it myhd cat .contract;# Expected output
# 0x5FbDB2315678afecb367f032d93F642f64180aa3docker exec -it myhd cat .wallet;# Expected output
# 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
We’ll use a dirty and prettier looking format to output a generated .env
file.
#!/bin/bash# Ugly I know
echo "CONTRACT_ADDRESS=$(docker exec -it myhd cat .contract)\nWALLET_ADDRESS=$(docker exec -it myhd cat .wallet;)" > .env;# OR you could use the local ENV
export CONTRACT_ADDRESS="$(docker exec -it myhd cat .contract)";
export WALLET_ADDRESS="$(docker exec -it myhd cat .wallet)";echo "CONTRACT_ADDRESS=$CONTRACT_ADDRESS\nWALLET_ADDRESS=$WALLET_ADDRESS" > .env;# Then clean up those values, don't want those living in the server
unset CONTRACT_ADDRESS;
unset WALLET_ADDRESS;
With that we should have a newly generated .env
file, with our addresses, that we can use for our node.js
client file.
#!/bin/bashnode client/node.js;# Expected result
# { result: 'Hello, Hardhat!' }
# {
# transaction: {
# hash: # '0xf59fd2a6d66dba2b24fa7b1007a6e38e4a7e42c46c15db1ade4c869792f0baee'
# ...
# { result: 'Hello from docker!' }
Final Code
There we have it. Our semi-automated process for creating a Dockerized contract that also retrieves the keys and runs a client that interacts with our contract within the Docker container.
Special Thanks
Thanks to Greg Syme (fellow Developer DAO member) for his help editing this article and listening to my crazy talks about Docker and Solidity contracts.
What’s Next
The next step for this would be to add this to a CI process with GitHub actions, where you could automate the tests and perhaps even use Cypress to have a full E2E test with a frontend.
I’m working on that very specific tutorial, so look out for it soon.
If you got value from this, please also follow me on twitter: @codingwithmanny and instagram at @codingwithmanny.
Further reading: