Hardhat Tutorial: Ignition vs Classic Deploy Scripts
Your deployment passed on Sepolia yesterday, then stalled halfway through today. The proxy went out, the implementation did not, your verification step ran against the wrong address, and now the…

Your deployment passed on Sepolia yesterday, then stalled halfway through today. The proxy went out, the implementation did not, your verification step ran against the wrong address, and now the frontend team is asking which contract address belongs in the staging config. Nobody did anything "wrong." This is the familiar friction of smart contract deployment: the chain is stateful, transactions are probabilistic until confirmed, and our scripts are often pretending they are just regular build tasks.
That is the real reason Hardhat Ignition matters. Not because classic deploy scripts are bad, and not because every project needs a declarative deployment framework from day one. The interesting question is more practical: when does a Hardhat deployment need managed state, retries, and idempotency — and when is a straightforward ethers script still the least painful tool?
This hardhat tutorial compares Hardhat Ignition with classic deployment scripts from the perspective that usually hurts most: production workflow. We will look at what changes when deployment becomes declarative, how Ignition's Futures model works, where imperative scripts still win, and how to choose a setup that does not punish the next person who has to redeploy at 2 a.m.
The shift from imperative to declarative deployment
Classic Hardhat deployment scripts usually read like a set of instructions:
1. Get the deployer account.
2. Get a contract factory.
3. Deploy the contract.
4. Wait for deployment.
5. Print the address.
6. Maybe call an initializer.
7. Maybe verify.
8. Maybe write something to disk.
That model feels natural because it is just JavaScript or TypeScript. We are frontend and infrastructure people; we like having the full language available. If we need a conditional, a loop, an API call, or a weird migration shim for an old contract, we can write it.
The trouble starts when that script becomes responsible for remembering what already happened.
A classic script might deploy Token, then Vault, then call setVault() on Token. If the transaction that deploys Vault succeeds but your process crashes before writing the address to a JSON file, your script may not know that Vault already exists. Run it again carelessly and you may deploy a second Vault. Maybe that is harmless on a testnet. Maybe it is a governance incident waiting quietly in your deployment folder.
Hardhat Ignition changes the posture. Instead of writing an imperative script that says "do these steps," we define the desired deployment graph. Ignition turns that graph into executable work, tracks deployment state, handles retries, and can resume interrupted deployments without re-running completed transactions.
The key difference is not syntax. It is who owns deployment memory: your script, or the deployment system.
This is especially relevant once a project graduates from one contract and a constructor argument. Multi-contract deployments introduce dependencies, library links, post-deploy calls, proxy patterns, network-specific parameters, and verification flows. The harder part is not deploying once. It is deploying safely again after something halfway failed.
Here is the practical split:
| Question | Hardhat Ignition | Classic deploy scripts |
|---|---|---|
| Deployment style | Declarative desired state | Imperative step-by-step execution |
| State tracking | Built into the deployment system | Usually manual, file-based, plugin-based, or ad hoc |
| Interrupted deployment | Can resume without re-executing completed transactions | Must be handled by the script or operator |
| Transaction retries | Managed by Ignition | Usually manual logic around provider errors and confirmations |
| Complex custom logic | Possible, but constrained by the module model | Full JavaScript/TypeScript control |
| Best fit | Repeatable multi-contract deployments | One-off scripts, unusual flows, migrations, admin operations |
This is the core of hardhat ignition vs deploy scripts. It is not a moral choice. It is a tradeoff between constrained reliability and unconstrained control.
Anatomy of Hardhat Ignition: managing state and Futures
Ignition modules are written in JavaScript or TypeScript. Inside a module, we describe what should exist after deployment. Those descriptions are represented as Future objects.
A Future is not the deployed contract itself. It is a promise-shaped node in a deployment graph: "deploy this contract," "link this library," "call this function," "use this value as an argument." Ignition uses those Futures to understand dependencies and execution order.
A small Ignition module might express something like this:
- Deploy
RewardTokenwith a constructor argument. - Deploy
StakingVault, passing the token address. - Call
setMinteron the token with the vault address.
In a classic script, we would write those as sequential operations. In Ignition, we define the relationship between them. The vault depends on the token. The setMinter call depends on both. Ignition can then build the plan.
That dependency graph is where the model starts to pay rent. When deployment state is tracked by the framework, an interrupted run becomes less scary. If RewardToken was already deployed, Ignition does not need to deploy it again just because the local process died before the vault call. The deployment has memory.
For teams with frontend release pressure, this matters more than it sounds. Contract deployment is rarely isolated from the user journey. A failed deployment can block indexers, SDK configuration, analytics tagging, feature flags, and test wallets. If the deployment pipeline cannot tell us what already happened, state sync leaks into Slack messages and spreadsheets. Nobody enjoys that version of DevOps.
What an Ignition module changes in our mental model
With classic scripts, we tend to ask:
- What command runs next?
- Did this line finish?
- Where did we save the address?
- Can I safely run this again?
With Ignition, we ask:
- What final deployment state are we declaring?
- What are the dependencies between contracts and calls?
- Which Futures are already resolved on this network?
- What does the system need to execute to converge?
That is a healthier set of questions for production deployments.
There is still a learning curve. Ignition asks us to stop thinking of deployment as a loose script and start modeling it as infrastructure state. For a DApp team shipping weekly, that can feel like ceremony at first. But it is the kind of ceremony that prevents a half-deployed testnet from becoming a half-understood production launch.
A useful pattern is to keep Ignition modules boring. Let them describe deployment topology. Put strange data fetching, environment assembly, and release orchestration around them rather than inside them whenever possible. The less clever the module, the easier it is to reason about what Ignition will do.
Classic deployment scripts: when manual control wins
Classic scripts are not obsolete. They remain a valid and supported way to interact with the Hardhat Runtime Environment, and there are plenty of times when I would still reach for them first.
A raw ethers.js deployment script gives us maximum control. We can branch on network conditions, read from existing contracts, call an internal API, transform data, run a migration in batches, or perform an admin operation that does not map cleanly to "declare this final deployment graph."
That flexibility is not a footnote. Real systems are messy. Maybe we are migrating balances from a legacy staking contract. Maybe we need to inspect an on-chain registry before choosing which implementation to deploy. Maybe the deploy is a one-off emergency patch with human confirmation between steps. In those cases, the imperative model is not a weakness; it is the job.
A classic Hardhat deployment tutorial often starts with something like this in TypeScript: load ethers from hre, call getContractFactory, deploy, await confirmation, print addresses. That is still an excellent entry point because it shows what is happening under the hood. It also maps well to other developer workflows: scripts for granting roles, seeding test state, updating allowlists, or checking protocol parameters.
Where classic scripts get dangerous is when we accidentally promote them from "small tool" to "deployment platform" without adding the platform parts.
If a script is responsible for a production release, it probably needs answers to these questions:
1. How does it know a previous transaction succeeded?
Waiting for confirmations inside one process is not enough if the process dies afterward. We need durable state somewhere.
2. What happens if the provider rate-limits us?
Public RPCs, shared node providers, and congested networks can throw errors that are not fatal to the deployment itself. Retrying safely is different from blindly running the whole script again.
3. Can it be run twice without changing the final state?
Idempotency is the difference between "rerun the deployment" and "please nobody touch anything until we reconstruct what happened."
4. Where do addresses go after deployment?
A console log is fine for a local test. Production needs structured output that flows into frontend config, indexer setup, monitoring, and documentation.
5. Who can understand it under pressure?
A script that depends on hidden environment variables, implicit network names, and local JSON files will eventually surprise someone.
There is no shame in a classic script. The friction appears when we ask it to behave like Ignition without building equivalent guardrails.
Handling transaction failures and idempotency
Most deployment bugs are not Solidity bugs. They are coordination bugs.
A transaction can be sent and mined, while our local process thinks it failed. A provider can return a timeout even though the mempool accepted the transaction. A nonce can get stuck. A deployer account can have enough gas for the first two contracts and fail on the third. A verification request can fail after deployment is already final on-chain.
In a nice clean demo, the script runs once and prints an address. In a real deployment, the system needs graceful degradation. We want failures to be contained and recoverable, not ambiguous.
Ignition's state management is designed for this. Because it tracks deployment state and uses a Future-based dependency graph, it can resume an interrupted deployment and avoid re-executing completed transactions. It also handles transaction retries as part of the deployment execution. That does not make blockchains magically reliable, but it moves a large category of retry and resume logic out of our custom scripts.
Classic scripts can be made robust too, but we have to do the work. Teams often reach for hardhat-deploy, raw ethers.js scripts with address files, or custom deployment registries. Those approaches can be fine. The important part is being honest about ownership: if Ignition is not managing the state, then we are.
A practical failure comparison
Let's walk through the sort of failure we have all seen.
We are deploying three contracts:
AccessManagerTreasuryRewardsDistributor
RewardsDistributor needs the addresses of the first two. After deployment, we call grantRole on AccessManager.
Now the deployment fails after Treasury is mined but before RewardsDistributor is deployed.
With a classic script, the outcome depends entirely on what we wrote. If the script saved Treasury to a file immediately after deployment, we can read it on rerun. If it only printed the address to the console, we are digging through explorer history. If the script does not check for existing deployments, rerunning may deploy another AccessManager and another Treasury.
With Ignition, the deployment state is part of the system. The completed Futures remain completed. On resume, Ignition can continue from the unresolved parts of the graph.
That difference is not glamorous, but it changes the operator experience. Instead of reconstructing history manually, we are asking the deployment tool to converge from known state.
Idempotency is a UX feature for developers. It turns panic into a repeatable command.
This also affects frontend integration. When contract addresses are unstable or duplicated across partial deployments, UI state becomes untrustworthy. Wallet prompts point to the wrong contract. Indexers ingest events from the wrong address. Feature environments drift. Deployment reliability is not just an ops concern; it is part of Web3 UX.
The same pattern is becoming more visible as crypto infrastructure leans further into automation. CI pipelines re-run jobs, scheduled bots retry on flaky RPCs, and internal agents execute maintenance on their own schedules. None of those actors want to babysit a half-finished deploy. They want a system that converges to the declared state and tells them what is still outstanding. That is exactly what stateful deployment discipline is supposed to deliver, and it carries back into ordinary DApp work: once anything in your pipeline can re-run a job without you, idempotency stops being a luxury and starts being a baseline expectation.
TypeScript, JavaScript, and the configuration layer
The hardhat typescript vs javascript question comes up in deployment discussions because Ignition modules can be written in either. The right answer is less ideological than people make it.
JavaScript is fast to start and easy for small teams that do not want type friction in every config file. TypeScript gives better refactoring support, safer module parameters, and clearer contracts between deployment code and the rest of the app. If the project already uses TypeScript for tests, SDK code, and frontend packages, keeping deployment modules typed usually reduces long-term friction.
What matters more is consistency. A mixed setup where tests are TypeScript, scripts are JavaScript, deployment artifacts are hand-edited JSON, and frontend addresses live in .env.local is how drift sneaks in.
A production-grade Hardhat configuration guide should connect these layers:
- Networks should be named consistently across Hardhat config, deployment outputs, frontend environments, and indexer configuration. If one layer says
baseSepoliaand another saysbase-sepolia, someone will eventually map the wrong address. - Accounts and signers should be explicit. Local defaults are useful, but production deployers need clear separation from admin wallets and upgrade executors.
- Confirmations should reflect the network and risk level. A local testnet does not need the same waiting behavior as mainnet or a busy L2.
- Environment variables should be validated early. Failing after two successful deployments because
ETHERSCAN_API_KEYis missing is the kind of tiny paper cut that becomes expensive. - Artifact flow should be deliberate. Deployed addresses need to reach the frontend, SDK, subgraph, monitoring, and docs without copy-paste archaeology.
Ignition helps with one major part of this: deployment state. It does not automatically solve the whole release system. We still need to decide how deployment outputs are consumed downstream.
For local development, the classic script may still be more convenient. Need to spin up a local Hardhat node, deploy a mock ERC-20, mint test balances, and impersonate a whale account? A script is a great fit. Need a repeatable staging deployment that several engineers can resume without stepping on each other? Ignition starts to look much more attractive.
Architecting your deployment strategy for production
The mistake is treating this as a one-time tool choice. Mature teams usually need both models.
We can use Ignition for the canonical deployment graph: core contracts, libraries, dependencies, and post-deploy calls that define the protocol's baseline state. Then we keep classic scripts for operational tasks: role changes, parameter updates, allowlist maintenance, treasury sweeps, mock seeding during local development, and one-off admin actions that should not be expressed as "the desired state of the protocol."
That split keeps each tool in its lane. Ignition becomes the system of record for what should exist on a given network. Classic scripts become the toolbox for everything that does not fit cleanly into a declarative graph.
A few patterns I have seen work well in production:
- Versioned modules per network. Each Ignition module is named and parameterised per network. Mainnet, testnet, and staging each have their own entry point, but they share parameter sources. That makes the deployment graph explicit and diffable.
- Address registry as the source of truth. The output of an Ignition deployment feeds a single registry file. The frontend, indexer, monitoring, and documentation read from that file. Nothing is hand-edited. Nothing is copy-pasted from a deployer chat message.
- Classic scripts as audited operations. When we need to perform an admin action — rotate a role, push a parameter update, sweep a fee — we run a small script that is reviewed, run, and recorded. It is not pretending to be a deployment system. It is an operation.
- A clear "who owns the next run" rule. Either Ignition owns the next run of the canonical graph, or it does not. If it does, reruns are safe. If it does not, a human decides when to deploy and how. Mixing those two models is how chaos enters the room.
There is also a human factor worth naming. The person most likely to break a deployment is rarely the person who wrote the deploy script six months ago. Treat the deployment system like a product for your future teammates. Comments in Ignition modules, sensible default parameters, and a short README inside the deployment directory pay off the first time someone else has to ship at an inconvenient hour.
That is the actual hardhat deployment tutorial takeaway, beyond syntax. Pick the model that matches the question you are asking. If the question is "what should exist on this network," reach for Ignition. If the question is "do this specific thing once, in this specific way," reach for a script. The two coexist in any serious project, and pretending otherwise usually costs a weekend.
The hardhat ignition vs deploy scripts decision stops being ideological once you frame it around ownership. Either the deployment system remembers what happened, or your team does. Choose the one that fits the scale and risk of what you are shipping, and keep the boundary clean. The reward is not elegance; it is a deployment pipeline that does not get in the way of the next person who has to use it.