I recently went through the process of deploying a custom L2 chain built on the OP Stack. This post documents the full journey — from generating wallets to a running sequencer with a batcher and proposer submitting data back to L1.

The target environment is Sepolia as the L1, but the same steps apply to any EVM-compatible L1.

What is the OP Stack?

The OP Stack is the open-source codebase that powers Optimism and the entire Superchain ecosystem. It gives you a production-grade optimistic rollup out of the box: transactions are executed off-chain, compressed into batches, and posted to L1 for data availability. Fraud proofs (via the dispute game) protect the bridge.

At a high level the components you will run are:

Component Role
op-reth Execution client (EL) — processes transactions, holds state
op-node Consensus / rollup node (CL) — drives the sequencer, derives L2 from L1
op-batcher Compresses L2 batches and submits them to the L1 inbox
op-proposer Proposes L2 output roots on L1 for the bridge to use

Prerequisites

Dependency Install
Docker + Compose Docker Desktop
Foundry (cast) curl -L https://foundry.paradigm.xyz \| bash
jq brew install jq
op-deployer see below

Installing op-deployer

op-deployer is the tool that deploys all the L1 contracts (bridge, dispute game factory, system config, etc.) and generates the genesis configuration for your L2.

Download the binary from the OP releases page, find the latest release that includes op-deployer, and grab the asset for your platform.

tar -xvzf path/to/op-deployer-archive.tar.gz
chmod +x op-deployer

# Intel Mac
sudo mv op-deployer /usr/local/bin/op-deployer

# Apple Silicon Mac
sudo mv op-deployer /opt/homebrew/bin/op-deployer

On macOS you may get a quarantine warning. Clear it with:

sudo xattr -dr com.apple.quarantine $(which op-deployer)

Verify:

op-deployer --version

Step 1: Generate wallets

The OP Stack uses separate keys for each operational role. You can generate them with cast wallet new or any key management tool you prefer.

Role Purpose
deployer Deploys L1 contracts (one-time, ~2–3 ETH on Sepolia)
admin General admin key
proxy_admin_owner Upgrades proxy contracts
system_config_owner Updates SystemConfig on L1
unsafe_block_signer Signs unsafe (pre-confirmation) blocks on the sequencer
batcher Submits compressed batches to L1
proposer Proposes L2 output roots on L1
challenger Challenges invalid proposals in the dispute game

Store private keys securely (e.g. in a secrets manager). Never commit them to version control.

Also generate a JWT secret for the authenticated Engine API between op-node and op-reth:

openssl rand -hex 32 > jwt.txt

Step 2: Fund wallets

Before deploying, top up these accounts with Sepolia ETH:

  • deployer — ~2–3 ETH to cover contract deployment gas
  • batcher — ongoing operational costs; replenish regularly
  • proposer — ongoing operational costs; replenish regularly

The other keys don’t need balance upfront.

Step 3: Initialise the deployment intent

op-deployer works with an intent file that describes what you want to deploy. Initialise a working directory for it:

op-deployer init \
  --l1-chain-id 11155111 \
  --l2-chain-ids <YOUR_CHAIN_ID> \
  --workdir .deployer \
  --intent-type standard-overrides

This creates .deployer/intent.toml. The auto-generated l1ContractsLocator, l2ContractsLocator, and opcmAddress values point to the correct on-chain contract sets for the OP Stack version — do not change them.

Step 4: Configure intent.toml

Open .deployer/intent.toml and fill in the role addresses from step 1:

[[chains]]
  id = "<YOUR_CHAIN_ID_HEX>"

  baseFeeVaultRecipient      = "<fee-recipient-address>"
  l1FeeVaultRecipient        = "<fee-recipient-address>"
  sequencerFeeVaultRecipient = "<fee-recipient-address>"
  operatorFeeVaultRecipient  = "<fee-recipient-address>"

  [chains.roles]
    l1ProxyAdminOwner  = "<proxy_admin_owner_address>"
    l2ProxyAdminOwner  = "<proxy_admin_owner_address>"
    systemConfigOwner  = "<system_config_owner_address>"
    unsafeBlockSigner  = "<unsafe_block_signer_address>"
    batcher            = "<batcher_address>"
    proposer           = "<proposer_address>"
    challenger         = "<challenger_address>"

If you want to pre-fund an address in the L2 genesis (useful for contract deployments on day one), add an alloc entry:

# 100,000 ETH in wei, hex-encoded
[chains.genesisAlloc."<ADDRESS>"]
  balance = "0x152D02C7E14AF6800000"

Step 5: Deploy L1 contracts

op-deployer apply \
  --workdir .deployer \
  --l1-rpc-url $L1_RPC_URL \
  --private-key $DEPLOYER_PRIVATE_KEY

This deploys the full L1 contract set: OptimismPortal, L1CrossDomainMessenger, L1StandardBridge, DisputeGameFactory, SystemConfig, and more. On success the deployment state lands in .deployer/state.json.

Step 6: Generate genesis and rollup config

Extract the L2 genesis block and rollup configuration from the deployment state:

op-deployer inspect genesis --workdir .deployer <YOUR_CHAIN_ID> > sequencer/genesis.json
op-deployer inspect rollup  --workdir .deployer <YOUR_CHAIN_ID> > sequencer/rollup.json

Depending on the op-node version you are running, you may need to strip fields that were added in newer protocol versions. For op-node v1.19.x:

python3 -c "
import json
with open('sequencer/rollup.json') as f:
    d = json.load(f)
d['genesis']['system_config'].pop('minBaseFee', None)
d['genesis']['system_config'].pop('daFootprintGasScalar', None)
with open('sequencer/rollup.json', 'w') as f:
    json.dump(d, f, indent=2)
"

Step 7: Start the sequencer

The sequencer runs two containers that communicate over the Engine API:

  • op-reth (execution layer) — handles EVM execution and state
  • op-node (consensus layer) — drives block production and derives from L1

A minimal docker-compose.yml for the sequencer:

services:
  op-reth:
    image: ghcr.io/paradigmxyz/op-reth:latest
    volumes:
      - ./data:/data
      - ./genesis.json:/genesis.json
      - ./jwt.txt:/jwt.txt
    command: >
      node
      --chain /genesis.json
      --datadir /data
      --authrpc.jwtsecret /jwt.txt
      --authrpc.addr 0.0.0.0
      --authrpc.port 8551
      --http
      --http.addr 0.0.0.0
      --http.port 8545
      --http.api eth,net,web3,debug,txpool
    ports:
      - "8545:8545"

  op-node:
    image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-node:latest
    depends_on: [op-reth]
    volumes:
      - ./rollup.json:/rollup.json
      - ./jwt.txt:/jwt.txt
    command: >
      op-node
      --rollup.config /rollup.json
      --l1 $L1_RPC_URL
      --l1.beacon $L1_BEACON_URL
      --l2 http://op-reth:8551
      --l2.jwt-secret /jwt.txt
      --sequencer.enabled
      --sequencer.l1-confs 4
      --p2p.sequencer.key $P2P_SEQUENCER_KEY
      --p2p.advertise.ip $P2P_ADVERTISE_IP
      --rpc.addr 0.0.0.0
      --rpc.port 8547

Start it:

docker compose up -d

Check that blocks are being produced:

cast block-number --rpc-url http://localhost:8545
# wait a few seconds
cast block-number --rpc-url http://localhost:8545
# the number should increase

Step 8: Start the batcher

The batcher reads L2 blocks from the sequencer, compresses them into frames, and posts them to the L1 batcher inbox address.

services:
  op-batcher:
    image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-batcher:latest
    command: >
      op-batcher
      --l1-eth-rpc $L1_RPC_URL
      --l2-eth-rpc http://sequencer:8545
      --rollup-rpc http://sequencer:8547
      --private-key $BATCHER_PRIVATE_KEY
      --max-channel-duration 25
      --sub-safety-margin 4

Verify it is working:

docker compose logs -f op-batcher
# Look for: "Submitted batch"

Step 9: Start the proposer

The proposer periodically reads the latest safe L2 block from the sequencer, computes an output root, and submits it to the DisputeGameFactory on L1. The bridge uses these roots to process withdrawals.

services:
  op-proposer:
    image: us-docker.pkg.dev/oplabs-tools-artifacts/images/op-proposer:latest
    command: >
      op-proposer
      --l1-eth-rpc $L1_RPC_URL
      --rollup-rpc http://sequencer:8547
      --private-key $PROPOSER_PRIVATE_KEY
      --game-factory-address $DISPUTE_GAME_FACTORY_ADDRESS
      --proposal-interval 120s

The DISPUTE_GAME_FACTORY_ADDRESS is in .deployer/state.json under disputeGameFactoryProxy.

Verify:

docker compose logs -f op-proposer
# Look for: "Proposing output root"

Putting it all together

Once all three services are running you have a fully functional L2:

  • Sequencer accepts transactions and produces blocks
  • Batcher keeps L1 data availability up to date
  • Proposer publishes output roots so withdrawals can be processed after the challenge window

The L2 is live at http://localhost:8545. You can add it to MetaMask or any EVM wallet using your chain ID and start deploying contracts.

Tips

Batcher balance matters. If the batcher wallet runs out of ETH the chain will keep producing blocks but data availability will fall behind. Set up monitoring and alerts on the batcher balance.

Genesis hash drift. If you modify genesis.json after deployment (e.g. to pre-fund accounts or add precompiles), op-reth will compute a different genesis block hash than what rollup.json records. Spin up op-reth in a temporary container, query block 0, and patch rollup.json manually:

HASH=$(cast block 0 --rpc-url http://localhost:8545 --field hash)
# update .genesis.l2.hash in rollup.json with $HASH

op-deployer is idempotent. You can re-run op-deployer apply safely — it checks what is already deployed and skips completed steps.

Local development without Sepolia ETH. Point op-deployer at a local Anvil node forking Sepolia (anvil --fork-url <sepolia-rpc>). Deployment is instant and free, which is great for iteration.