Skip to content

Web3 Infrastructure

Building Production Smart-Contract Deployment Pipelines: Foundry, Hardhat, and Multi-Chain Verification

How I cut smart contract deployment time from 2 hours to 15 minutes at Upside - Foundry scripts, CI/CD patterns, multi-chain verification, and gas regression gates.

11 min
#foundry #hardhat #smart-contracts #solidity #ci-cd #deployment #ethereum #polygon

When I joined Upside, deploying a smart contract to production was a two-hour ordeal. The process was a checklist in a Notion doc: SSH into a specific machine, export the right environment variables, run forge script Deploy.s.sol --rpc-url mainnet --broadcast, watch the output, manually note the deployed address in a spreadsheet, submit for Etherscan verification separately, update the frontend config by hand. If any step failed, you started over or - worse - you partially completed the deployment and had to figure out what state you were in.

Fourteen minutes was what I got it down to after rebuilding the pipeline properly. The remaining minute is human approval time for the mainnet gate. I am not counting that in the automation number, but it belongs there.

The difference between a chaotic contract deployment process and a reliable one is not tooling sophistication - it is treating contract deployment with the same discipline you would apply to any production software release.

The Old Way: Manual Everything

The classic manual deployment pattern looks like this:

# Manual deployment - circa 2022, still common in 2026
export PRIVATE_KEY=$(cat ~/.secrets/deployer.key)
export ETHERSCAN_API_KEY=$(cat ~/.secrets/etherscan.key)

# Run deployment
forge script script/Deploy.s.sol:Deploy \
  --rpc-url https://mainnet.infura.io/v3/$INFURA_KEY \
  --broadcast \
  --verify

# Hope it worked. Check Etherscan. Update the spreadsheet.
# Repeat for Polygon. Remember to change the RPC URL.

The problems are obvious in retrospect:

  • No test gate before deployment. A developer in a hurry skips the local forge test and goes straight to broadcast.
  • No gas cost comparison. Did the refactor last week accidentally increase the gas cost of the core execute() function by 30%? Nobody knows until users complain.
  • No deployment history. The broadcast/ directory accumulates artifacts, but nobody looks at them. The canonical record is a Google Sheet.
  • Verification is manual and frequently skipped under time pressure. Unverified contracts create user trust issues and make debugging miserable.
  • Multi-chain deployment is a copy-paste exercise. Somebody will paste the wrong RPC URL at some point.

Foundry vs Hardhat: When to Use Each

Before designing the pipeline, the tooling choice matters. Both Foundry and Hardhat are production-grade, but they optimize for different things.

Foundry is the right choice when your team is Solidity-first. It is written in Rust, compiles contracts faster than Hardhat, and has a native testing framework (forge test) that runs Solidity tests in Solidity. No JavaScript required anywhere in the test path. Foundry scripts are deterministic and produce detailed broadcast artifacts. The forge snapshot command tracks gas per function and fails if you exceed a defined threshold. For pure contract work - auditable, reproducible, fast - Foundry is the better choice.

Hardhat is the right choice when your codebase mixes contract logic with TypeScript tooling. The Hardhat plugin ecosystem is more mature for things like: TypeChain (typed contract bindings), Hardhat Deploy (deployment history management with named deployments), integration with wagmi or ethers.js test suites, and custom task automation in TypeScript. If the team deploying contracts also builds the frontend and wants a unified build system, Hardhat’s TypeScript-native approach reduces context switching.

At Upside, we used Foundry for the core settlement contracts - pure Solidity, frequent audits, performance-sensitive - and Hardhat for the peripheral utility contracts that had tight coupling with the TypeScript SDK.

The Foundry Deployment Script Pattern

Foundry’s deployment script system is underutilized. Most teams run forge script Deploy.s.sol --broadcast and treat it as a one-off command. The real power is in making scripts fully parameterized, idempotent, and auditable.

A production deployment script:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Script, console2} from "forge-std/Script.sol";
import {SettlementCore} from "../src/SettlementCore.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";

contract DeploySettlementCore is Script {
    // Deployed addresses - read from environment for upgrades, zero for fresh deploys
    address constant PROXY_ADMIN_EXISTING = address(0); // set in CI env for upgrades

    function run() external returns (address proxy, address implementation) {
        // Pull chain-specific config from environment
        address initialOwner = vm.envAddress("DEPLOY_OWNER_ADDRESS");
        uint256 maxDailyVolume = vm.envUint("SETTLEMENT_MAX_DAILY_VOLUME");
        uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY");

        console2.log("Deploying to chain:", block.chainid);
        console2.log("Deployer:", vm.addr(deployerPrivateKey));
        console2.log("Owner:", initialOwner);

        vm.startBroadcast(deployerPrivateKey);

        // Deploy implementation
        SettlementCore impl = new SettlementCore();
        console2.log("Implementation deployed at:", address(impl));

        // Deploy or reuse proxy admin
        ProxyAdmin admin;
        if (PROXY_ADMIN_EXISTING != address(0)) {
            admin = ProxyAdmin(PROXY_ADMIN_EXISTING);
            console2.log("Using existing ProxyAdmin:", address(admin));
        } else {
            admin = new ProxyAdmin(initialOwner);
            console2.log("ProxyAdmin deployed at:", address(admin));
        }

        // Encode initializer
        bytes memory initData = abi.encodeCall(
            SettlementCore.initialize,
            (initialOwner, maxDailyVolume)
        );

        // Deploy proxy
        TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy(
            address(impl),
            address(admin),
            initData
        );
        console2.log("Proxy deployed at:", address(proxyContract));

        vm.stopBroadcast();

        return (address(proxyContract), address(impl));
    }
}

Key properties of this script:

  • Parameterized by environment - the same script runs on testnet, staging, and mainnet. Only the environment variables change.
  • Explicit logging - every deployed address is logged. The broadcast artifact + logs are the deployment record.
  • Upgrade-aware - the PROXY_ADMIN_EXISTING check makes the script usable for both fresh deploys and implementation upgrades. The CI pipeline sets this variable for upgrade runs.
  • Uses vm.startBroadcast(privateKey) not --unlocked - the private key comes from the environment. The alternative of using --sender with an unlocked account is less portable across environments.

The CI/CD Pipeline

The pipeline I built at Upside has seven stages. The first five run on every PR. Stages six and seven run only when merging to main.

# .github/workflows/deploy-contracts.yml
name: Smart Contract Deploy Pipeline

on:
  push:
    branches: [main]
  pull_request:
    paths: ["contracts/**"]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive  # Foundry libs are git submodules

      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1
        with:
          version: nightly  # pin to a specific nightly in production

      - name: Install dependencies
        run: forge install

      - name: Build
        run: forge build --sizes  # --sizes catches contract size limit violations early

      - name: Run tests
        run: forge test -vvv --gas-report

      - name: Gas snapshot comparison
        run: |
          forge snapshot
          # Fail if any function gas increased by more than 5%
          forge snapshot --check --tolerance 5
        # On PRs: compare against main branch snapshot
        # forge snapshot --diff .gas-snapshot

      - name: Deploy to Sepolia testnet
        if: github.event_name == 'pull_request'
        env:
          DEPLOYER_PRIVATE_KEY: ${{ secrets.TESTNET_DEPLOYER_KEY }}
          DEPLOY_OWNER_ADDRESS: ${{ secrets.TESTNET_OWNER_ADDRESS }}
          SETTLEMENT_MAX_DAILY_VOLUME: "1000000000000000000000"  # 1000 ETH, testnet
          ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }}
        run: |
          forge script script/DeploySettlementCore.s.sol:DeploySettlementCore \
            --rpc-url ${{ secrets.SEPOLIA_RPC_URL }} \
            --broadcast \
            --verify \
            --etherscan-api-key $ETHERSCAN_API_KEY

      - name: Run integration tests against testnet deployment
        if: github.event_name == 'pull_request'
        run: |
          # Read deployed address from broadcast artifact
          PROXY_ADDR=$(cat broadcast/DeploySettlementCore.s.sol/11155111/run-latest.json \
            | jq -r '.returns.proxy.value')
          echo "Testing against proxy: $PROXY_ADDR"
          SETTLEMENT_PROXY=$PROXY_ADDR forge test --match-path "test/integration/**" -vvv

  deploy-mainnet:
    needs: [build-and-test]
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: mainnet-deploy  # Requires manual approval in GitHub Environments
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Install Foundry
        uses: foundry-rs/foundry-toolchain@v1

      - name: Deploy to Ethereum mainnet
        env:
          DEPLOYER_PRIVATE_KEY: ${{ secrets.MAINNET_DEPLOYER_KEY }}
          DEPLOY_OWNER_ADDRESS: ${{ secrets.MAINNET_OWNER_ADDRESS }}
          SETTLEMENT_MAX_DAILY_VOLUME: ${{ vars.MAINNET_MAX_DAILY_VOLUME }}
          ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }}
        run: |
          forge script script/DeploySettlementCore.s.sol:DeploySettlementCore \
            --rpc-url ${{ secrets.MAINNET_RPC_URL }} \
            --broadcast \
            --verify \
            --etherscan-api-key $ETHERSCAN_API_KEY \
            --slow  # --slow adds 1-second delay between transactions, prevents nonce issues

      - name: Deploy to Polygon mainnet
        env:
          DEPLOYER_PRIVATE_KEY: ${{ secrets.MAINNET_DEPLOYER_KEY }}
          DEPLOY_OWNER_ADDRESS: ${{ secrets.MAINNET_OWNER_ADDRESS }}
          SETTLEMENT_MAX_DAILY_VOLUME: ${{ vars.POLYGON_MAX_DAILY_VOLUME }}
          POLYGONSCAN_API_KEY: ${{ secrets.POLYGONSCAN_API_KEY }}
        run: |
          forge script script/DeploySettlementCore.s.sol:DeploySettlementCore \
            --rpc-url ${{ secrets.POLYGON_RPC_URL }} \
            --broadcast \
            --verify \
            --etherscan-api-key $POLYGONSCAN_API_KEY

      - name: Archive deployment artifacts
        uses: actions/upload-artifact@v4
        with:
          name: deployment-artifacts-${{ github.sha }}
          path: broadcast/
          retention-days: 365

The mainnet-deploy GitHub Environment is the key to the human approval gate. You configure it in the repository settings to require approval from specific reviewers before any job in that environment can run. The pipeline cannot proceed to mainnet deployment without a human clicking “Approve” in GitHub Actions. This is not a workaround - it is the intended use of GitHub Environments and it is auditable.

Multi-Chain Deployment

The same script deploying to Ethereum and Polygon in the pipeline above works because the script is parameterized by environment. But there are subtleties that bite you in multi-chain setups.

Chain ID checks in scripts - for high-stakes operations, add explicit chain ID validation:

function run() external {
    uint256 expectedChainId = vm.envUint("EXPECTED_CHAIN_ID");
    require(block.chainid == expectedChainId,
        string(abi.encodePacked(
            "Chain ID mismatch: expected ",
            vm.toString(expectedChainId),
            " got ",
            vm.toString(block.chainid)
        ))
    );
    // ... rest of deployment
}

This check has saved me from accidentally deploying a mainnet configuration to a testnet and vice versa. The CI sets EXPECTED_CHAIN_ID explicitly for each deploy step.

Verification APIs differ by chain - Etherscan, Polygonscan, Arbiscan, and Basescan all have different API keys and endpoint URLs. The --verifier-url flag in forge verify-contract lets you override the default. For chains with no Etherscan-compatible API (Solana, Sui), verification is handled separately.

Constructor argument encoding - if your contract has constructor arguments, forge verify-contract needs them encoded. Forge generates this automatically from the broadcast artifact, but I have seen it fail when the contract uses complex types. Keep constructor arguments simple; move complex initialization to an initialize() function called via the deployment script.

The Gas Snapshot Gate

The gas snapshot check in the CI pipeline is underrated. Without it, your core contract functions can silently become more expensive over time as the codebase evolves. A developer adds an extra storage slot, reorders a struct, or adds an event - each individually seems fine. After six months, the function that cost 80,000 gas now costs 120,000 gas and nobody knows why.

Foundry’s snapshot system makes this explicit:

# Generate initial snapshot (commit this file)
forge snapshot

# Contents of .gas-snapshot after running:
# SettlementCoreTest:testExecute() (gas: 87423)
# SettlementCoreTest:testBatchExecute_5() (gas: 234891)
# SettlementCoreTest:testEmergencyWithdraw() (gas: 45230)
# ... etc

# In CI, check against committed snapshot:
forge snapshot --check

# With tolerance (fail only if increase > 5%):
forge snapshot --check --tolerance 5

When a PR bumps gas on a guarded function beyond tolerance, the developer is forced to justify it. Sometimes the increase is intentional - a security fix that costs gas is worth it. The snapshot makes that conversation explicit rather than letting regressions accumulate silently.

How This Breaks in Production

The pipeline I described works well. These are the ways it breaks when you stop paying attention.

Broadcast artifact contamination - the broadcast/ directory accumulates artifacts across deployments. On Foundry versions before 0.2.0, the run-latest.json symlink could point to a stale artifact if a deployment partially failed. Always validate the artifact timestamp and transaction hashes before acting on its contents in post-deployment scripts.

RPC URL rate limits - free-tier Alchemy and Infura RPC URLs have rate limits. A CI run that makes hundreds of eth_call requests during simulation can hit these limits and fail mid-deployment. Use dedicated RPC endpoints for CI with appropriate rate limits. Separate testnet and mainnet endpoints. Do not share the mainnet RPC with developers who are running local tests.

Verification timeouts - Etherscan’s verification API is occasionally slow or unavailable. The --verify flag in forge script retries, but there is a timeout. I have had deployments succeed on-chain and then fail at the verification step because Etherscan was slow. The mitigation: verify separately as a retry-able step using forge verify-contract with the deployed address from the broadcast artifact.

Nonce desynchronization on parallel deployments - if two deployment jobs run simultaneously (possible if your CI triggers on both push and PR in the same moment), they will try to use the same nonce for the deployer address. One will fail. The environment: mainnet-deploy protection in GitHub prevents this for mainnet, but testnet CI can hit this on concurrent PRs. Use a nonce management service or a dedicated deployer address per environment.

Upgradeable proxy storage collisions - the classic footgun with upgradeable proxies. If you add a storage variable in an implementation contract in a position that collides with a variable from a previous implementation (even if the new variable has a different name), you corrupt state. OpenZeppelin’s Upgrades plugin for Hardhat has storage layout validation. Foundry does not have this natively as of 2026 - run the Hardhat upgrade validation step as an additional CI job when working with upgradeable contracts.

The pipeline is not a guarantee of correctness. It is a system that makes correctness the path of least resistance. The two hours I reclaimed at Upside were not just efficiency gains - they were a forcing function to think clearly about what a correct deployment actually required. Writing that down as code turned out to be the most useful thing I did.

Continue Reading

Enjoyed this?

Get one deep infrastructure insight per week.

Free forever. Unsubscribe anytime.

You're in. Check your inbox.