From main() to Running Node: The Geth Boot Process
Prerequisites
- ›Article 1: Architecture and Directory Map
- ›Go language basics (goroutines, channels, interfaces)
From main() to Running Node: The Geth Boot Process
Every complex system has a boot sequence — the choreography that takes an inert binary and transforms it into a running service. Geth's boot process is surprisingly readable once you know the key players. It follows a clear pipeline: parse CLI flags, create a protocol-agnostic Node container, wire up the Ethereum service inside it, open network endpoints, and start all registered lifecycles.
In this article we'll follow every step of that pipeline, from the five-line main() function to the moment the node begins processing blocks. Along the way, you'll discover the Node-as-Container pattern that decouples protocol logic from infrastructure concerns — arguably the most important architectural pattern in the entire codebase.
CLI Framework and Entry Point
Geth uses the urfave/cli/v2 framework for command-line parsing. The setup happens in init(), which runs before main():
sequenceDiagram
participant OS as Operating System
participant Init as init()
participant Main as main()
participant App as urfave/cli App
OS->>Init: Process starts
Init->>App: app.Action = geth
Init->>App: Register commands (init, import, console, ...)
Init->>App: Register flags (nodeFlags, rpcFlags, metricsFlags, ...)
Init->>App: AutoEnvVars(flags, "GETH")
OS->>Main: main()
Main->>App: app.Run(os.Args)
App->>App: Parse flags, dispatch to action
The init() function does three things: sets app.Action = geth (the default command), registers all subcommands (like init, import, console, attach), and registers an enormous list of flags spanning node configuration, RPC settings, metrics, and debugging.
A nice detail: flags.AutoEnvVars(app.Flags, "GETH") automatically generates environment variable names for every flag. The --datadir flag becomes GETH_DATADIR, for instance. No boilerplate needed.
The actual main() is five lines:
func main() {
if err := app.Run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
When no subcommand is specified, urfave/cli dispatches to app.Action, which is the geth function.
The Boot Path: geth() → makeFullNode() → startNode()
The geth() function is the heart of the boot sequence — thirteen lines that create and run a full Ethereum node:
func geth(ctx *cli.Context) error {
if args := ctx.Args().Slice(); len(args) > 0 {
return fmt.Errorf("invalid command: %q", args[0])
}
prepare(ctx)
stack := makeFullNode(ctx)
defer stack.Close()
startNode(ctx, stack, false)
stack.Wait()
return nil
}
Four steps: prepare() sets up logging and memory caches. makeFullNode() creates the Node container and wires in the Ethereum service. startNode() boots everything up. stack.Wait() blocks until the node is shut down (via signal or API call).
sequenceDiagram
participant G as geth()
participant P as prepare()
participant MFN as makeFullNode()
participant SN as startNode()
participant N as Node
G->>P: Setup logging, metrics
G->>MFN: Create Node + Ethereum service
MFN-->>G: return stack (*node.Node)
G->>SN: Start all services
SN->>N: utils.StartNode() → Node.Start()
G->>N: stack.Wait() — blocks until shutdown
Tip: If you're debugging a startup issue, the
prepare()→makeFullNode()→startNode()sequence is always where to look. These three function calls are the complete boot story.
The Node-as-Container Pattern
As we covered in Part 1, the node.Node struct is a protocol-agnostic service container. It knows nothing about Ethereum specifically — it manages P2P networking, RPC servers, databases, and service lifecycles:
type Node struct {
eventmux *event.TypeMux
config *Config
accman *accounts.Manager
log log.Logger
stop chan struct{}
server *p2p.Server
state int // initializingState → runningState → closedState
lifecycles []Lifecycle
rpcAPIs []rpc.API
http *httpServer
ws *httpServer
httpAuth *httpServer // JWT-authenticated for Engine API
wsAuth *httpServer
ipc *ipcServer
inprocHandler *rpc.Server
databases map[*closeTrackingDB]struct{}
}
The Node.New() constructor sets up the P2P server configuration, creates RPC endpoint servers (HTTP, WebSocket, IPC, and authenticated variants), initializes the account manager, and acquires a file lock on the data directory to prevent concurrent access.
Notice the authenticated HTTP and WebSocket servers (httpAuth, wsAuth) — these are specifically for the Engine API. The line node.httpAuth.disableHTTP2 = true is a pragmatic detail: the Engine API doesn't need HTTP/2, and disabling it avoids potential complications with consensus layer clients.
classDiagram
class Node {
-config *Config
-server *p2p.Server
-lifecycles []Lifecycle
-rpcAPIs []rpc.API
-http *httpServer
-httpAuth *httpServer
-ipc *ipcServer
-databases map
+Start() error
+Close() error
+Wait()
+RegisterLifecycle(Lifecycle)
+RegisterAPIs([]rpc.API)
+RegisterProtocols([]p2p.Protocol)
}
class Lifecycle {
<<interface>>
+Start() error
+Stop() error
}
class Ethereum {
+Start() error
+Stop() error
}
Node o-- Lifecycle
Ethereum ..|> Lifecycle
Constructing the Ethereum Service: eth.New()
The eth.New() constructor is the largest single function in the codebase — nearly 250 lines that wire together the entire Ethereum protocol. Here's the sequence:
flowchart TD
A["Open chaindata database"] --> B["Load chain config + genesis"]
B --> C["Create consensus engine<br/>(beacon wrapping clique or ethash)"]
C --> D["Create BlockChain"]
D --> E["Initialize FilterMaps log index"]
E --> F["Create legacy tx pool + blob pool"]
F --> G["Create TxPool aggregator"]
G --> H["Create handler<br/>(P2P coordinator)"]
H --> I["Create Miner<br/>(payload builder)"]
I --> J["Create EthAPIBackend + gas oracle"]
J --> K["Register APIs, Protocols,<br/>Lifecycle with Node"]
The function opens the chain database via stack.OpenDatabaseWithOptions("chaindata", ...), loads or initializes the chain configuration, creates a consensus engine, builds the entire blockchain object, initializes transaction pools (both legacy and blob), creates the P2P handler, sets up the miner, and wires up the API backend.
The critical registration step happens at the end of eth.New():
stack.RegisterAPIs(eth.APIs())
stack.RegisterProtocols(eth.Protocols())
stack.RegisterLifecycle(eth)
These three calls connect the Ethereum service to the Node container. The Node doesn't know or care that it's running Ethereum — it just manages the lifecycle and exposes the APIs.
Configuration Layering
Configuration in Geth flows through multiple layers, each adding specificity:
flowchart TD
CLI["CLI Flags<br/>(--syncmode, --cache, ...)"] --> ETH_CFG["ethconfig.Config<br/>Protocol-level defaults"]
CLI --> NODE_CFG["node.Config<br/>Infrastructure settings"]
ETH_CFG --> CHAIN_CFG["params.ChainConfig<br/>Fork schedule"]
ETH_CFG --> BC_CFG["core.BlockChainConfig<br/>Runtime parameters"]
CHAIN_CFG --> RULES["params.Rules<br/>Per-block fork flags"]
The ethconfig.Defaults provide sensible starting values — snap sync mode, 2GB database cache, 60-minute trie timeout, 50M gas cap for RPC calls. CLI flags override these defaults. The resulting Config struct then drives construction of all subsystems.
The params.ChainConfig is particularly interesting because it uses a dual activation scheme: earlier forks (Homestead through GrayGlacier) activate by block number, while post-Merge forks (Shanghai onward) activate by timestamp. This is visible in the struct's fields — HomesteadBlock *big.Int versus ShanghaiTime *uint64.
Pre-configured networks (Mainnet, Sepolia, Holesky, Hoodi) have their ChainConfig defined as package-level variables in params/config.go, so launching on a known network requires no manual configuration.
The Three-Phase Lifecycle and Registration
The Node has a simple state machine with three states, defined in node/node.go:
const (
initializingState = iota
runningState
closedState
)
Services can only register during initializingState. The RegisterLifecycle method enforces this:
func (n *Node) RegisterLifecycle(lifecycle Lifecycle) {
n.lock.Lock()
defer n.lock.Unlock()
if n.state != initializingState {
panic("can't register lifecycle on running/stopped node")
}
// ...
n.lifecycles = append(n.lifecycles, lifecycle)
}
When Node.Start() is called, it transitions to runningState, opens all network endpoints (P2P, RPC), and then iterates through registered lifecycles calling Start() on each. If any lifecycle fails to start, all previously started ones are stopped in reverse order — a clean rollback pattern.
Shutdown is equally orderly. Node.Close() stops lifecycles in reverse registration order, then tears down RPC endpoints, P2P networking, and finally closes all tracked databases. The closeTrackingDB wrapper ensures databases opened through the Node are automatically closed during shutdown, preventing resource leaks.
Tip: The
stack.Wait()call at the end ofgeth()blocks on a channel that's closed inNode.doClose(). To trigger graceful shutdown, send an interrupt signal (SIGINT/SIGTERM) — the signal handler callsstack.Close(), which unblocksWait()and letsgeth()return cleanly.
With the node running, the next article dives into what happens when blocks arrive: how they flow through the BlockChain manager, get validated by the consensus Engine, and are executed transaction-by-transaction through the EVM.