Learn a clean deployment workflow
in ~45 minutes.
This guide is not about writing a token. It’s about shipping contracts safely: how to move from local development to testnets and finally to mainnet — with a repeatable structure and zero chaos.
scripts/
deploy.ts
verify.ts
deployments/
sepolia.json
mainnet.json
.env (ignored)
What you’ll learn
Many beginners can write a contract… and then get stuck when it’s time to deploy. This guide fixes that by teaching a simple, real-world deployment story.
- How to think about networks: local vs testnet vs mainnet
- A clean project structure for deploy flow
- How to configure RPC + keys safely
- How to deploy with repeatable scripts
- How to verify and record addresses
- How to avoid “oops I deployed wrong” moments
We assume you already have any contract to deploy. The focus here is workflow, not Solidity basics.
- ✓ Running Hardhat projects
- ✓ Editing config + scripts
- ✓ Basic terminal usage
- • MetaMask installed
- • Sepolia RPC URL
Build the right mental model
Before touching config, you need to understand what a “network” really means in dev. Think of deployment as a ladder:
- Localhost — instant feedback, zero money, fake ETH.
- Testnet — real chain rules, fake ETH, public explorer.
- Mainnet — real money, real users, no undo button.
Because you want fast iteration first. Local lets you break things without waiting for confirmations or faucets. When local is stable, testnet validates your real-world setup.
The workflow we’ll follow:
1) Local deploy (fast sanity check)
2) Testnet deploy (real chain rules)
3) Verify + record addresses
4) Dry-run mainnet deployment
5) Mainnet deploy (with checklist)
Set up a clean deployment structure
A small but powerful rule: anything repeatable should be a script.
Here’s a beginner-friendly structure that scales:
.
├─ contracts/
├─ scripts/
│ ├─ deploy.ts
│ ├─ verify.ts
│ └─ dry-run.ts
├─ deployments/
│ ├─ localhost.json
│ ├─ sepolia.json
│ └─ mainnet.json
├─ hardhat.config.ts
├─ .env (ignored)
└─ package.json
- scripts/deploy.ts deploys contracts.
- scripts/verify.ts verifies them.
- deployments/*.json stores addresses + metadata.
So your frontend, tests, or future scripts can read the latest address without you hunting in terminal history.
- ✓ One deploy script per project
- ✓ Deployments saved per network
- ✓ Never hardcode private keys
- ✓ Verify only on public nets
This layout is enough for 90% of real projects. You can expand later with Ignition modules, but don’t start there.
Configure networks safely
In Hardhat 3, networks are typed by purpose:
- edr-simulated — local simulated networks
- http — real RPC networks
Here’s a minimal but correct config skeleton:
// hardhat.config.ts
import { defineConfig, configVariable } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox-ethers";
export default defineConfig({
solidity: {
version: "0.8.28",
settings: { optimizer: { enabled: true, runs: 200 } },
},
networks: {
// local simulated chain
hardhatMainnet: {
type: "edr-simulated",
chainType: "l1",
},
// real testnet via RPC
sepolia: {
type: "http",
chainType: "l1",
url: configVariable("SEPOLIA_RPC_URL"),
accounts: [configVariable("SEPOLIA_PRIVATE_KEY")],
},
// mainnet via RPC
mainnet: {
type: "http",
chainType: "l1",
url: configVariable("MAINNET_RPC_URL"),
accounts: [configVariable("MAINNET_PRIVATE_KEY")],
},
},
});
Hardhat 3 supports configuration variables. If you prefer classic dotenv, that’s fine too — just don’t commit secrets.
Store secrets like a grown-up
Whether you use Hardhat config variables or dotenv, the rule is the same: keys and RPC URLs must live outside Git.
Classic beginner-friendly approach:
// .env (never commit)
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/xxxx
SEPOLIA_PRIVATE_KEY=0xYOUR_TESTNET_KEY
MAINNET_RPC_URL=https://mainnet.infura.io/v3/xxxx
MAINNET_PRIVATE_KEY=0xYOUR_MAINNET_KEY
ETHERSCAN_API_KEY=XXXX
Add this to .gitignore:
.env
deployments/*.json
Use a dedicated burner key for testnets. Mainnet key should be separate and only loaded when needed.
Deploy locally first
Your deploy script should work on local and public networks without edits. Example deploy script:
// scripts/deploy.ts
import { network } from "hardhat";
import fs from "fs";
async function main() {
const { ethers, networkName } = await network.connect();
console.log("Deploying to:", networkName);
// Example: deploy any existing contract
const Contract = await ethers.getContractFactory("MyContract");
const contract = await Contract.deploy();
await contract.waitForDeployment();
const address = await contract.getAddress();
console.log("Deployed at:", address);
// Save deployment
const out = {
address,
deployedAt: new Date().toISOString(),
network: networkName,
};
fs.mkdirSync("deployments", { recursive: true });
fs.writeFileSync(`deployments/${networkName}.json`, JSON.stringify(out, null, 2));
console.log("Saved deployments/" + networkName + ".json");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Run on local simulated chain:
npx hardhat run scripts/deploy.ts --network hardhatMainnet
Because it forces you to be explicit. Beginners often deploy to the wrong network by accident. Explicit is safe.
Deploy to Sepolia testnet
Once local deploy succeeds, move to testnet. Same script, different network:
npx hardhat run scripts/deploy.ts --network sepolia
If your deploy fails here, it’s usually one of these:
- RPC URL is wrong / rate limited
- Private key has no Sepolia ETH
- Constructor args mismatch
It’s now stored in deployments/sepolia.json. This is your “source of truth” for the token or app you’re building.
Verify on a block explorer
Verification is optional but recommended. It makes your source code public and readable.
If you use toolbox-ethers, verify plugin is included. Add Etherscan API key in env, then:
npx hardhat verify --network sepolia <address> <constructorArgs...>
You can automate this in scripts/verify.ts:
// scripts/verify.ts
import { network, run } from "hardhat";
import fs from "fs";
async function main() {
const { networkName } = await network.connect();
const p = `deployments/${networkName}.json`;
if (!fs.existsSync(p)) {
throw new Error("No deployments file: " + p);
}
const { address } = JSON.parse(fs.readFileSync(p, "utf8"));
console.log("Verifying:", address);
await run("verify:verify", {
address,
constructorArguments: [],
});
console.log("Verified!");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Run it:
npx hardhat run scripts/verify.ts --network sepolia
Do a “dry-run” before mainnet
A dry-run is a final rehearsal: you deploy with mainnet config but to a forked state locally.
Add forking to your simulated net:
// hardhat.config.ts (snippet)
hardhatMainnet: {
type: "edr-simulated",
chainType: "l1",
forking: {
url: configVariable("MAINNET_RPC_URL"),
blockNumber: 20000000,
},
},
Now run deploy on fork:
npx hardhat run scripts/deploy.ts --network hardhatMainnet
Your contract runs against real mainnet state: real tokens, real liquidity pools, real addresses — but locally.
Mainnet deployment checklist
Mainnet is not scary if you follow a strict checklist. Here’s a beginner-safe one:
- ✅ Script deployed successfully on localhost
- ✅ Script deployed successfully on Sepolia
- ✅ You verified on Sepolia
- ✅ You dry-ran on mainnet fork
- ✅ You reviewed constructor args (twice)
- ✅ You checked deployer balance
When ready, deploy:
npx hardhat run scripts/deploy.ts --network mainnet
If you’re unsure — stop. Re-run fork and Sepolia again. The cost of waiting is tiny compared to the cost of mistakes.
- → Use a fresh deployer wallet
- → Keep backups of .env
- → Don’t deploy at 3AM
- → Save tx hash + address
- → Verify right after deploy
Upgradeable deployments (mini-intro)
Some real projects use upgradeable proxies (UUPS / Transparent). You don’t need them for learning — but you should know the flow exists.
Install upgrades plugin:
npm install --save-dev @openzeppelin/hardhat-upgrades
Enable it:
// hardhat.config.ts
import "@openzeppelin/hardhat-upgrades";
Deploy as a proxy:
// scripts/deploy.ts (snippet)
const Box = await ethers.getContractFactory("Box");
const proxy = await upgrades.deployProxy(Box, [42], { kind: "uups" });
await proxy.waitForDeployment();
console.log("Proxy address:", await proxy.getAddress());
After you’re confident with normal deployments. Proxies add complexity and should not be your step zero.
You now have a real workflow
You’ve learned:
- How to treat networks as a ladder
- How to structure scripts and deployments
- How to deploy locally, then on testnet, then mainnet
- How to verify and record addresses
- How to dry-run before spending real ETH
With this, you can ship almost any Solidity project in a clean way. Next, you can pick any guide (ERC-721, DeFi, Security) and your deploy story will stay stable.
- → Hardhat deep dive (networks & forking)
- → Security basics (reentrancy, access control)
- → OpenZeppelin extensions in practice
Link these to pages like hardhat.html, security.html, oz.html to build a learning path.