diff --git a/context.go b/context.go
index 2a6f514214a..a2506a75b2a 100644
--- a/context.go
+++ b/context.go
@@ -37,9 +37,10 @@ import (
 // not actually need to do this).
 type Context struct {
 	context.Context
-	moduleInstances map[string][]interface{}
+	moduleInstances map[string][]Module
 	cfg             *Config
 	cleanupFuncs    []func()
+	ancestry        []Module
 }
 
 // NewContext provides a new context derived from the given
@@ -51,7 +52,7 @@ type Context struct {
 // modules which are loaded will be properly unloaded.
 // See standard library context package's documentation.
 func NewContext(ctx Context) (Context, context.CancelFunc) {
-	newCtx := Context{moduleInstances: make(map[string][]interface{}), cfg: ctx.cfg}
+	newCtx := Context{moduleInstances: make(map[string][]Module), cfg: ctx.cfg}
 	c, cancel := context.WithCancel(ctx.Context)
 	wrappedCancel := func() {
 		cancel()
@@ -90,15 +91,15 @@ func (ctx *Context) OnCancel(f func()) {
 // ModuleMap may be used in place of map[string]json.RawMessage. The return value's
 // underlying type mirrors the input field's type:
 //
-//    json.RawMessage              => interface{}
-//    []json.RawMessage            => []interface{}
-//    [][]json.RawMessage          => [][]interface{}
-//    map[string]json.RawMessage   => map[string]interface{}
-//    []map[string]json.RawMessage => []map[string]interface{}
+//	json.RawMessage              => interface{}
+//	[]json.RawMessage            => []interface{}
+//	[][]json.RawMessage          => [][]interface{}
+//	map[string]json.RawMessage   => map[string]interface{}
+//	[]map[string]json.RawMessage => []map[string]interface{}
 //
 // The field must have a "caddy" struct tag in this format:
 //
-//    caddy:"key1=val1 key2=val2"
+//	caddy:"key1=val1 key2=val2"
 //
 // To load modules, a "namespace" key is required. For example, to load modules
 // in the "http.handlers" namespace, you'd put: `namespace=http.handlers` in the
@@ -115,7 +116,7 @@ func (ctx *Context) OnCancel(f func()) {
 // meaning the key containing the module's name that is defined inline with the module
 // itself. You must specify the inline key in a struct tag, along with the namespace:
 //
-//    caddy:"namespace=http.handlers inline_key=handler"
+//	caddy:"namespace=http.handlers inline_key=handler"
 //
 // This will look for a key/value pair like `"handler": "..."` in the json.RawMessage
 // in order to know the module name.
@@ -301,17 +302,17 @@ func (ctx Context) loadModuleMap(namespace string, val reflect.Value) (map[strin
 // like from embedded scripts, etc.
 func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (interface{}, error) {
 	modulesMu.RLock()
-	mod, ok := modules[id]
+	modInfo, ok := modules[id]
 	modulesMu.RUnlock()
 	if !ok {
 		return nil, fmt.Errorf("unknown module: %s", id)
 	}
 
-	if mod.New == nil {
-		return nil, fmt.Errorf("module '%s' has no constructor", mod.ID)
+	if modInfo.New == nil {
+		return nil, fmt.Errorf("module '%s' has no constructor", modInfo.ID)
 	}
 
-	val := mod.New().(interface{})
+	val := modInfo.New()
 
 	// value must be a pointer for unmarshaling into concrete type, even if
 	// the module's concrete type is a slice or map; New() *should* return
@@ -327,7 +328,7 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (interface{
 	if len(rawMsg) > 0 {
 		err := strictUnmarshalJSON(rawMsg, &val)
 		if err != nil {
-			return nil, fmt.Errorf("decoding module config: %s: %v", mod, err)
+			return nil, fmt.Errorf("decoding module config: %s: %v", modInfo, err)
 		}
 	}
 
@@ -340,6 +341,8 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (interface{
 		return nil, fmt.Errorf("module value cannot be null")
 	}
 
+	ctx.ancestry = append(ctx.ancestry, val)
+
 	if prov, ok := val.(Provisioner); ok {
 		err := prov.Provision(ctx)
 		if err != nil {
@@ -351,7 +354,7 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (interface{
 					err = fmt.Errorf("%v; additionally, cleanup: %v", err, err2)
 				}
 			}
-			return nil, fmt.Errorf("provision %s: %v", mod, err)
+			return nil, fmt.Errorf("provision %s: %v", modInfo, err)
 		}
 	}
 
@@ -365,7 +368,7 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (interface{
 					err = fmt.Errorf("%v; additionally, cleanup: %v", err, err2)
 				}
 			}
-			return nil, fmt.Errorf("%s: invalid configuration: %v", mod, err)
+			return nil, fmt.Errorf("%s: invalid configuration: %v", modInfo, err)
 		}
 	}
 
@@ -439,8 +442,10 @@ func (ctx Context) Storage() certmagic.Storage {
 	return ctx.cfg.storage
 }
 
+// TODO: aw man, can I please change this?
 // Logger returns a logger that can be used by mod.
 func (ctx Context) Logger(mod Module) *zap.Logger {
+	// TODO: if mod is nil, use ctx.Module() instead...
 	if ctx.cfg == nil {
 		// often the case in tests; just use a dev logger
 		l, err := zap.NewDevelopment()
@@ -451,3 +456,34 @@ func (ctx Context) Logger(mod Module) *zap.Logger {
 	}
 	return ctx.cfg.Logging.Logger(mod)
 }
+
+// TODO: use this
+// // Logger returns a logger that can be used by the current module.
+// func (ctx Context) Log() *zap.Logger {
+// 	if ctx.cfg == nil {
+// 		// often the case in tests; just use a dev logger
+// 		l, err := zap.NewDevelopment()
+// 		if err != nil {
+// 			panic("config missing, unable to create dev logger: " + err.Error())
+// 		}
+// 		return l
+// 	}
+// 	return ctx.cfg.Logging.Logger(ctx.Module())
+// }
+
+// Modules returns the lineage of modules that this context provisioned,
+// with the most recent/current module being last in the list.
+func (ctx Context) Modules() []Module {
+	mods := make([]Module, len(ctx.ancestry))
+	copy(mods, ctx.ancestry)
+	return mods
+}
+
+// Module returns the current module, or the most recent one
+// provisioned by the context.
+func (ctx Context) Module() Module {
+	if len(ctx.ancestry) == 0 {
+		return nil
+	}
+	return ctx.ancestry[len(ctx.ancestry)-1]
+}
diff --git a/modules/caddyevent/app.go b/modules/caddyevent/app.go
deleted file mode 100644
index 59e4c18f251..00000000000
--- a/modules/caddyevent/app.go
+++ /dev/null
@@ -1,202 +0,0 @@
-// Copyright 2015 Matthew Holt and The Caddy Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package caddyevent
-
-import (
-	"encoding/json"
-	"fmt"
-	"sort"
-
-	"github.com/caddyserver/caddy/v2"
-	"go.uber.org/zap"
-)
-
-func init() {
-	caddy.RegisterModule(EventApp{})
-}
-
-// EventApp is a global event system.
-type EventApp struct {
-	// Registers each of these event subscribers
-	SubscribersRaw []json.RawMessage `json:"subscribers,omitempty" caddy:"namespace=event.subscribers inline_key=subscriber"`
-
-	listeners map[caddy.ModuleID]map[Priority][]ListenerFunc
-	optimized map[caddy.ModuleID][]ListenerFunc
-	ready     bool
-	logger    *zap.Logger
-}
-
-// CaddyModule returns the Caddy module information.
-func (EventApp) CaddyModule() caddy.ModuleInfo {
-	return caddy.ModuleInfo{
-		ID:  "event",
-		New: func() caddy.Module { return new(EventApp) },
-	}
-}
-
-// Provision sets up the app.
-func (app *EventApp) Provision(ctx caddy.Context) error {
-	app.listeners = make(map[caddy.ModuleID]map[Priority][]ListenerFunc)
-	app.logger = ctx.Logger(app)
-
-	// register all the configured subscribers
-	if app.SubscribersRaw != nil {
-		subscribersIface, err := ctx.LoadModule(app, "SubscribersRaw")
-		if err != nil {
-			return fmt.Errorf("loading event subscriber modules: %v", err)
-		}
-		for _, subscriber := range subscribersIface.([]Subscriber) {
-			app.RegisterSubscriber(subscriber)
-		}
-	}
-
-	return nil
-}
-
-// Validate ensures the app's configuration is valid.
-func (app *EventApp) Validate() error {
-	return nil
-}
-
-// Start runs the app.
-func (app *EventApp) Start() error {
-	// optimize the event listeners to order them by priority.
-	app.optimizeListeners()
-
-	// stop new listeners from being added,
-	// and allow events to be dispatched.
-	app.ready = true
-
-	return nil
-}
-
-// Stop gracefully shuts down the app.
-func (app *EventApp) Stop() error {
-	return nil
-}
-
-// RegisterSubscriber registers all the listeners from a subscriber.
-// Modules may register themselves as subscribers during their Provision
-// phase. Subscribers cannot be registered after the config is running.
-func (app *EventApp) RegisterSubscriber(subscriber Subscriber) {
-	for eventID, entry := range subscriber.SubscribedEvents() {
-		app.RegisterListener(eventID, entry)
-	}
-}
-
-// RegisterListener registers a single event listener.
-// Modules may register listeners during their Provision phase.
-// Listeners cannot be registered after the config is running.
-func (app *EventApp) RegisterListener(eventID caddy.ModuleID, entry ListenerEntry) {
-	// if the app is already running, we don't allow adding new listeners.
-	if app.ready {
-		// TODO: Panic or something?
-		return
-	}
-
-	if app.listeners[eventID] == nil {
-		app.listeners[eventID] = make(map[Priority][]ListenerFunc)
-	}
-
-	// There may be more than one listener with the same priority,
-	// for the same event, so we have an array of listeners for
-	// each priority level. Listeners at the same priority level
-	// will be in the order they are registered, which will not
-	// have a guaranteed order because Caddy modules may be loaded
-	// in an arbitrary order.
-	app.listeners[eventID][entry.Priority] = append(
-		app.listeners[eventID][entry.Priority],
-		entry.Listener,
-	)
-
-	app.logger.Debug("registered listener",
-		zap.String("event", eventID.Name()),
-		zap.Int("priority", int(entry.Priority)),
-	)
-}
-
-// Dispatch passes the event through the configured listeners synchronously.
-func (app *EventApp) Dispatch(event Event) error {
-	// if the app is not running, we don't allow dispatching events.
-	if !app.ready {
-		return fmt.Errorf("Cannot dispatch events until after the app is running")
-	}
-
-	// find the listeners for this event
-	listeners, ok := app.optimized[event.ID()]
-	if !ok {
-		return nil
-	}
-
-	app.logger.Debug("dispatching", zap.String("event", event.ID().Name()))
-
-	for _, listener := range listeners {
-		// listeners may mark the event to stop subsequent
-		// listeners from running on this event.
-		if event.IsPropagationStopped() {
-			app.logger.Debug("propagation stopped", zap.String("event", event.ID().Name()))
-			break
-		}
-
-		// run the listener.
-		err := listener(event)
-		if err != nil {
-			app.logger.Error("listener error",
-				zap.String("event", event.ID().Name()),
-				zap.Error(err),
-			)
-			return err
-		}
-	}
-
-	return nil
-}
-
-// AsyncDispatch passes the event through the configured listeners asynchronously.
-func (app *EventApp) AsyncDispatch(event Event) {
-	go func(event Event) {
-		_ = app.Dispatch(event)
-	}(event)
-}
-
-// optimizeListeners orders the listeners by priority.
-func (app *EventApp) optimizeListeners() {
-	app.optimized = make(map[caddy.ModuleID][]ListenerFunc)
-	for eventID, priorities := range app.listeners {
-		app.optimized[eventID] = []ListenerFunc{}
-
-		// sort the priorities (highest priority value first)
-		keys := make([]int, 0)
-		for k := range priorities {
-			keys = append(keys, int(k))
-		}
-		sort.Sort(sort.Reverse(sort.IntSlice(keys)))
-
-		// add all the listeners in order of priority
-		for _, k := range keys {
-			app.optimized[eventID] = append(app.optimized[eventID], priorities[Priority(k)]...)
-		}
-	}
-
-	// we no longer need this map once we're running
-	app.listeners = nil
-}
-
-// Interface guards
-var (
-	_ caddy.App         = (*EventApp)(nil)
-	_ caddy.Provisioner = (*EventApp)(nil)
-	_ caddy.Validator   = (*EventApp)(nil)
-)
diff --git a/modules/caddyevent/event.go b/modules/caddyevent/event.go
deleted file mode 100644
index 6255e5e6b66..00000000000
--- a/modules/caddyevent/event.go
+++ /dev/null
@@ -1,140 +0,0 @@
-// Copyright 2015 Matthew Holt and The Caddy Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package caddyevent
-
-import (
-	"github.com/caddyserver/caddy/v2"
-)
-
-// DataMap is a key-value pair map.
-type DataMap map[string]interface{}
-
-// Event interface
-type Event interface {
-	// Get the module ID of the event.
-	ID() caddy.ModuleID
-
-	// Get an item from the data map by key.
-	Get(key string) interface{}
-
-	// Set an item in the data map by key.
-	Set(key string, val interface{})
-
-	// Get the entire data map.
-	Data() DataMap
-
-	// Replace the entire data map.
-	SetData(DataMap)
-
-	// StopPropagation sets the event to no longer propagate
-	// to subsequent event listeners.
-	StopPropagation(bool)
-
-	// IsPropagationStopped returns whether the event is set
-	// to no longer propagate. In other words, this is
-	// whether the event dispatcher should stop handling
-	// this event and skip passing it to any subsequent
-	// event listeners.
-	IsPropagationStopped() bool
-}
-
-// GenericEvent a generic event, which can be used
-// with composition to provide baseline functionality
-// for custom events.
-type GenericEvent struct {
-	// The module ID of the event.
-	id caddy.ModuleID
-
-	// Key-value data pairs of contextual information.
-	data DataMap
-
-	// Whether the event handling should be aborted,
-	// and no event handlers should be called after
-	// this point. Allows for "middleware chain" type
-	// of functionality.
-	propagationStopped bool
-}
-
-// NewGeneric creates a generic event instance.
-func NewGeneric(id caddy.ModuleID, data DataMap) *GenericEvent {
-	if data == nil {
-		data = make(DataMap)
-	}
-
-	return &GenericEvent{
-		id:   id,
-		data: data,
-	}
-}
-
-// SetID sets the module ID of the event.
-func (e *GenericEvent) SetID(id caddy.ModuleID) {
-	e.id = id
-}
-
-// Get the module ID of the event.
-func (e *GenericEvent) ID() caddy.ModuleID {
-	return e.id
-}
-
-// Get an item from the data map by key.
-func (e *GenericEvent) Get(key string) interface{} {
-	if v, ok := e.data[key]; ok {
-		return v
-	}
-
-	return nil
-}
-
-// Set an item in the data map by key.
-func (e *GenericEvent) Set(key string, val interface{}) {
-	if e.data == nil {
-		e.data = make(DataMap)
-	}
-
-	e.data[key] = val
-}
-
-// Data returns the entire data map.
-func (e *GenericEvent) Data() DataMap {
-	return e.data
-}
-
-// SetData overwrites the entire data map.
-func (e *GenericEvent) SetData(data DataMap) {
-	if data != nil {
-		e.data = data
-	}
-}
-
-// StopPropagation sets the event to no longer propagate
-// to subsequent event listeners.
-func (e *GenericEvent) StopPropagation(abort bool) {
-	e.propagationStopped = abort
-}
-
-// IsPropagationStopped returns whether the event is set
-// to no longer propagate. In other words, this is
-// whether the event dispatcher should stop handling
-// this event and skip passing it to any subsequent
-// event listeners.
-func (e *GenericEvent) IsPropagationStopped() bool {
-	return e.propagationStopped
-}
-
-// Interface guards
-var (
-	_ Event = (*GenericEvent)(nil)
-)
diff --git a/modules/caddyevent/listener.go b/modules/caddyevent/listener.go
deleted file mode 100644
index 8b29e897608..00000000000
--- a/modules/caddyevent/listener.go
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright 2015 Matthew Holt and The Caddy Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package caddyevent
-
-import (
-	"github.com/caddyserver/caddy/v2"
-)
-
-// ListenerFunc is a function that can handle a dispatched event.
-type ListenerFunc func(e Event) error
-
-// Handle runs the event listener on the given event.
-func (fn ListenerFunc) Handle(e Event) error {
-	return fn(e)
-}
-
-// Priority is a factor by which event listeners can be sorted.
-type Priority int
-
-// ListenerEntry is a wrapper to allow associating a listener function
-// to a priority factor when registering event subscribers and listeners.
-type ListenerEntry struct {
-	Listener ListenerFunc
-	Priority Priority
-}
-
-// Subscriber defines an interface for modules that wish
-// to subscribe to dispatched events.
-type Subscriber interface {
-	// SubscribedEvents returns a map of event IDs that
-	// this subscriber can handle, to the function that
-	// handles the event.
-	SubscribedEvents() map[caddy.ModuleID]ListenerEntry
-}
diff --git a/modules/caddyevents/app.go b/modules/caddyevents/app.go
new file mode 100644
index 00000000000..761ea221894
--- /dev/null
+++ b/modules/caddyevents/app.go
@@ -0,0 +1,358 @@
+// Copyright 2015 Matthew Holt and The Caddy Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package caddyevents
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/caddyserver/caddy/v2"
+	"github.com/google/uuid"
+	"go.uber.org/zap"
+)
+
+func init() {
+	caddy.RegisterModule(App{})
+}
+
+// App implements a global eventing system within Caddy.
+// Modules can emit and subscribe to events, providing
+// hooks into deep parts of the code base that aren't
+// otherwise accessible. Events provide information about
+// what and when things are happening, and this facility
+// allows handlers to take action when events occur,
+// add information to the event's metadata, and even
+// control program flow in some cases.
+//
+// Events are propagated in a DOM-like fashion. An event
+// event emitted from module `a.b.c` (the "origin") will
+// first invoke handlers listening to `a.b.c`, then `a.b`,
+// then `a`, then those listening regardless of origin.
+// If a handler returns the special error Aborted, then
+// propagation immediately stops and the event is marked
+// as aborted. Emitters may optionally choose to adjust
+// program flow based on an abort.
+//
+// Modules can subscribe to events by origin and/or name.
+// A handler is invoked only if it is subscribed to the
+// event by name and origin. Subscriptions should be
+// registered during the provisioning phase, before apps
+// are started.
+//
+// Event handlers are fired synchronously as part of the
+// regular flow of the program. This allows event handlers
+// to control the flow of the program if the origin permits
+// it and also allows handlers to convey new information
+// back into the origin module before it continues.
+// In essence, event handlers are similar to HTTP
+// middleware handlers.
+//
+// Event bindings/subscribers are unordered; i.e.
+// event handlers are invoked in an arbitrary order.
+// Event handlers should not rely on the logic of other
+// handlers to succeed.
+type App struct {
+	// Subscriptions bind handlers to one or more events
+	// either globally or scoped to specific modules or module
+	// namespaces.
+	Subscriptions []*Subscription `json:"subscriptions,omitempty"`
+
+	// Map of event name to map of module ID/namespace to handlers
+	subscriptions map[string]map[caddy.ModuleID][]Handler
+
+	logger  *zap.Logger
+	started bool
+}
+
+// Subscription represents binding of one or more handlers to
+// one or more events.
+type Subscription struct {
+	// The name(s) of the event(s) to bind to. Default: all events.
+	Events []string `json:"events,omitempty"`
+
+	// The ID or namespace of the module(s) from which events
+	// originate to listen to for events. Default: all modules.
+	// Events propagate up, so events emitted by module "a.b.c"
+	// will also trigger the event for "a.b" and "a". Thus, to
+	// receive all events from "a.b.c" and "a.b.d", for example,
+	// one can subscribe to either "a.b" or all of "a" entirely.
+	Modules []caddy.ModuleID `json:"modules,omitempty"`
+
+	// The event handler modules. These implement the actual
+	// behavior to invoke when an event occurs. At least one
+	// handler is required.
+	HandlersRaw []json.RawMessage `json:"handlers,omitempty" caddy:"namespace=events.handlers inline_key=handler"`
+
+	// The decoded handlers; Go code that is subscribing to
+	// an event should set this field directly; HandlersRaw
+	// is meant for JSON configuration to fill out this field.
+	Handlers []Handler `json:"-"`
+}
+
+// CaddyModule returns the Caddy module information.
+func (App) CaddyModule() caddy.ModuleInfo {
+	return caddy.ModuleInfo{
+		ID:  "events",
+		New: func() caddy.Module { return new(App) },
+	}
+}
+
+// Provision sets up the app.
+func (app *App) Provision(ctx caddy.Context) error {
+	app.logger = ctx.Logger(app)
+	app.subscriptions = make(map[string]map[caddy.ModuleID][]Handler)
+
+	for _, sub := range app.Subscriptions {
+		if sub.HandlersRaw != nil {
+			handlersIface, err := ctx.LoadModule(sub, "HandlersRaw")
+			if err != nil {
+				return fmt.Errorf("loading event subscriber modules: %v", err)
+			}
+			for _, h := range handlersIface.([]interface{}) {
+				sub.Handlers = append(sub.Handlers, h.(Handler))
+			}
+			if len(sub.Handlers) == 0 {
+				// pointless to bind without any handlers
+				return fmt.Errorf("no handlers defined")
+			}
+		}
+	}
+
+	return nil
+}
+
+// Start runs the app.
+func (app *App) Start() error {
+	for _, sub := range app.Subscriptions {
+		if err := app.Subscribe(sub); err != nil {
+			return err
+		}
+	}
+
+	app.started = true
+
+	return nil
+}
+
+// Stop gracefully shuts down the app.
+func (app *App) Stop() error {
+	return nil
+}
+
+// Subscribe binds one or more event handlers to one or more events
+// according to the subscription s. For now, subscriptions can only
+// be created during the provision phase; new bindings cannot be
+// created after the events app has started.
+func (app *App) Subscribe(s *Subscription) error {
+	if app.started {
+		return fmt.Errorf("events already started; new subscriptions closed")
+	}
+
+	// handle special case of catch-alls (omission of event name or module space implies all)
+	if len(s.Events) == 0 {
+		s.Events = []string{""}
+	}
+	if len(s.Modules) == 0 {
+		s.Modules = []caddy.ModuleID{""}
+	}
+
+	for _, eventName := range s.Events {
+		if app.subscriptions[eventName] == nil {
+			app.subscriptions[eventName] = make(map[caddy.ModuleID][]Handler)
+		}
+		for _, originModule := range s.Modules {
+			app.subscriptions[eventName][originModule] = append(app.subscriptions[eventName][originModule], s.Handlers...)
+		}
+	}
+
+	return nil
+}
+
+// On is syntactic sugar for Subscribe() that binds a single handler
+// to a single event from any module. If the eventName is empty string,
+// it counts for all events.
+func (app *App) On(eventName string, handler Handler) error {
+	return app.Subscribe(&Subscription{
+		Events:   []string{eventName},
+		Handlers: []Handler{handler},
+	})
+}
+
+// Emit creates and dispatches an event named eventName to all relevant handlers with
+// the metadata data. Events are emitted and propagated synchronously. The returned Event
+// value will have any additional information from the invoked handlers.
+func (app *App) Emit(ctx caddy.Context, eventName string, data map[string]interface{}) Event {
+	id, err := uuid.NewRandom()
+	if err != nil {
+		app.logger.Error("failed generating new event ID", zap.Error(err))
+	}
+
+	eventName = strings.ToLower(eventName)
+
+	// TODO: make pointer?
+	e := Event{
+		id:     id,
+		ts:     time.Now(),
+		name:   eventName,
+		origin: ctx.Module(),
+		data:   data,
+	}
+
+	// add event info to replacer, make sure it's in the context
+	repl, ok := ctx.Context.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
+	if !ok {
+		repl = caddy.NewReplacer()
+		ctx.Context = context.WithValue(ctx.Context, caddy.ReplacerCtxKey, repl)
+	}
+	repl.Set("event", e)
+	repl.Set("event.id", e.id)
+	repl.Set("event.name", e.name)
+	repl.Set("event.module", e.origin.CaddyModule().ID)
+	repl.Set("event.data", e.data)
+	repl.Map(func(key string) (interface{}, bool) {
+		switch key {
+		case "event.time":
+			return e.ts, true
+		case "event.time_unix":
+			return e.ts.UnixMilli(), true
+		}
+
+		if strings.HasPrefix(key, "event.data.") {
+			key = strings.TrimPrefix(key, "event.data.")
+			if val, ok := data[key]; ok {
+				return val, true
+			}
+		}
+
+		return nil, false
+	})
+
+	app.logger.Debug("event",
+		zap.String("name", e.name),
+		zap.String("id", e.id.String()),
+		zap.String("origin", e.origin.CaddyModule().String()),
+		zap.Any("data", e.data),
+	)
+
+	// invoke handlers bound to the event by name and also all events; this for loop
+	// iterates twice at most: once for the event name, once for "" (all events)
+	for {
+		moduleID := e.origin.CaddyModule().ID
+
+		// implement propagation up the module tree (i.e. start with "a.b.c" then "a.b" then "a" then "")
+		for {
+			if app.subscriptions[eventName] == nil {
+				break // shortcut if event not bound at all
+			}
+
+			for _, handler := range app.subscriptions[eventName][moduleID] {
+				// TODO: maybe log handler invocations instead of all event emissions?
+				err := handler.Handle(ctx, e)
+				if errors.Is(err, ErrAborted) {
+					// TODO: Figure out proper way to implement this
+					e.Aborted = err
+					return e
+				}
+				if err != nil {
+					app.logger.Error("handler error", zap.Error(err))
+				}
+			}
+
+			if moduleID == "" {
+				break
+			}
+			lastDot := strings.LastIndex(string(moduleID), ".")
+			if lastDot < 0 {
+				moduleID = "" // include handlers bound to events regardless of module
+			} else {
+				moduleID = moduleID[:lastDot]
+			}
+		}
+
+		// include handlers listening to all events
+		if eventName == "" {
+			break
+		}
+		eventName = ""
+	}
+
+	return e
+}
+
+// Event represents something that has happened or is happening.
+type Event struct {
+	id     uuid.UUID
+	ts     time.Time
+	name   string
+	origin caddy.Module
+	data   map[string]interface{}
+
+	// If non-nil, the event has been aborted, meaning
+	// propagation has stopped to other handlers and
+	// the code should stop what it was doing. Emitters
+	// may choose to use this as a signal to adjust their
+	// code path appropriately.
+	Aborted error
+}
+
+// CloudEvent exports event e as a structure that, when
+// serialized as JSON, is compatible with the
+// CloudEvents spec.
+func (e Event) CloudEvent() CloudEvent {
+	dataJSON, _ := json.Marshal(e.data)
+	return CloudEvent{
+		ID:              e.id.String(),
+		Source:          e.origin.CaddyModule().String(),
+		SpecVersion:     "1.0",
+		Type:            e.name,
+		Time:            e.ts,
+		DataContentType: "application/json",
+		Data:            dataJSON,
+	}
+}
+
+// CloudEvent is a JSON-serializable structure that
+// is compatible with the CloudEvents specification.
+// See https://cloudevents.io.
+type CloudEvent struct {
+	ID              string          `json:"id"`
+	Source          string          `json:"source"`
+	SpecVersion     string          `json:"specversion"`
+	Type            string          `json:"type"`
+	Time            time.Time       `json:"time"`
+	DataContentType string          `json:"datacontenttype,omitempty"`
+	Data            json.RawMessage `json:"data,omitempty"`
+}
+
+// ErrAborted cancels an event.
+var ErrAborted = errors.New("event aborted")
+
+// Handler is a type that can handle events.
+type Handler interface {
+	Handle(context.Context, Event) error
+}
+
+// // HandlerFunc
+// type HandlerFunc func(context.Context, Event) error
+
+// Interface guards
+var (
+	_ caddy.App         = (*App)(nil)
+	_ caddy.Provisioner = (*App)(nil)
+)
diff --git a/modules/caddyevents/eventsconfig/caddyfile.go b/modules/caddyevents/eventsconfig/caddyfile.go
new file mode 100644
index 00000000000..6b1347d6cba
--- /dev/null
+++ b/modules/caddyevents/eventsconfig/caddyfile.go
@@ -0,0 +1,88 @@
+// Copyright 2015 Matthew Holt and The Caddy Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package eventsconfig is for configuring caddyevents.App with the
+// Caddyfile. This code can't be in the caddyevents package because
+// the httpcaddyfile package imports caddyhttp, which imports
+// caddyevents: hence, it creates an import cycle.
+package eventsconfig
+
+import (
+	"encoding/json"
+
+	"github.com/caddyserver/caddy/v2/caddyconfig"
+	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
+	"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
+	"github.com/caddyserver/caddy/v2/modules/caddyevents"
+)
+
+func init() {
+	httpcaddyfile.RegisterGlobalOption("events", parseApp)
+}
+
+// parseApp configures the "events" global option from Caddyfile to set up the events app.
+// Syntax:
+//
+//	events {
+//		on <event> <handler_module...>
+//	}
+//
+// If <event> is *, then it will bind to all events.
+func parseApp(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
+	app := new(caddyevents.App)
+
+	// consume the option name
+	if !d.Next() {
+		return nil, d.ArgErr()
+	}
+
+	// handle the block
+	for d.NextBlock(0) {
+		switch d.Val() {
+		case "on":
+			if !d.NextArg() {
+				return nil, d.ArgErr()
+			}
+			eventName := d.Val()
+			if eventName == "*" {
+				eventName = ""
+			}
+
+			if !d.NextArg() {
+				return nil, d.ArgErr()
+			}
+			handlerName := d.Val()
+			modID := "events.handlers." + handlerName
+			unm, err := caddyfile.UnmarshalModule(d, modID)
+			if err != nil {
+				return nil, err
+			}
+
+			app.Subscriptions = append(app.Subscriptions, &caddyevents.Subscription{
+				Events: []string{eventName},
+				HandlersRaw: []json.RawMessage{
+					caddyconfig.JSONModuleObject(unm, "handler", handlerName, nil),
+				},
+			})
+
+		default:
+			return nil, d.ArgErr()
+		}
+	}
+
+	return httpcaddyfile.App{
+		Name:  "events",
+		Value: caddyconfig.JSON(app, nil),
+	}, nil
+}
diff --git a/modules/caddyevents/exec.go b/modules/caddyevents/exec.go
new file mode 100644
index 00000000000..d5962e73d5b
--- /dev/null
+++ b/modules/caddyevents/exec.go
@@ -0,0 +1,88 @@
+// Copyright 2015 Matthew Holt and The Caddy Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package caddyevents
+
+import (
+	"context"
+	"os"
+	"os/exec"
+
+	"github.com/caddyserver/caddy/v2"
+	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
+	"go.uber.org/zap"
+)
+
+func init() {
+	caddy.RegisterModule(ExecHandler{})
+}
+
+// ExecHandler implements an event handler that runs a command/program.
+type ExecHandler struct {
+	Command string         `json:"command,omitempty"`
+	Args    []string       `json:"args,omitempty"`
+	Dir     string         `json:"dir,omitempty"`
+	Timeout caddy.Duration `json:"timeout,omitempty"`
+
+	logger *zap.Logger
+}
+
+// CaddyModule returns the Caddy module information.
+func (ExecHandler) CaddyModule() caddy.ModuleInfo {
+	return caddy.ModuleInfo{
+		ID:  "events.handlers.exec",
+		New: func() caddy.Module { return new(ExecHandler) },
+	}
+}
+
+// Provision sets up the module.
+func (eh *ExecHandler) Provision(ctx caddy.Context) error {
+	eh.logger = ctx.Logger(eh)
+	return nil
+}
+
+func (eh *ExecHandler) Handle(ctx context.Context, e Event) error {
+	repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
+
+	// expand placeholders in command args;
+	// notably, WE DO NOT EXPAND PLACEHOLDERS
+	// IN THE COMMAND ITSELF for safety reasons
+	expandedArgs := make([]string, len(eh.Args))
+	for i := range eh.Args {
+		expandedArgs[i] = repl.ReplaceAll(eh.Args[i], "")
+	}
+
+	cmd := exec.Command(eh.Command, expandedArgs...)
+	cmd.Dir = eh.Dir
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	return cmd.Run()
+}
+
+func (eh *ExecHandler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
+	for d.Next() {
+		if !d.NextArg() {
+			return d.ArgErr()
+		}
+		eh.Command = d.Val()
+		eh.Args = d.RemainingArgs()
+	}
+	return nil
+}
+
+// Interface guards
+var (
+	_ caddyfile.Unmarshaler = (*ExecHandler)(nil)
+	_ caddy.Provisioner     = (*ExecHandler)(nil)
+)
diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go
index 1894a975ce4..603108aa62f 100644
--- a/modules/caddyhttp/app.go
+++ b/modules/caddyhttp/app.go
@@ -23,6 +23,7 @@ import (
 	"time"
 
 	"github.com/caddyserver/caddy/v2"
+	"github.com/caddyserver/caddy/v2/modules/caddyevents"
 	"github.com/caddyserver/caddy/v2/modules/caddytls"
 	"github.com/lucas-clemente/quic-go/http3"
 	"go.uber.org/zap"
@@ -146,6 +147,11 @@ func (app *App) Provision(ctx caddy.Context) error {
 	app.ctx = ctx
 	app.logger = ctx.Logger(app)
 
+	eventsAppIface, err := ctx.App("events")
+	if err != nil {
+		return fmt.Errorf("getting events app: %v", err)
+	}
+
 	repl := caddy.NewReplacer()
 
 	// this provisions the matchers for each route,
@@ -160,6 +166,8 @@ func (app *App) Provision(ctx caddy.Context) error {
 	for srvName, srv := range app.Servers {
 		srv.name = srvName
 		srv.tlsApp = app.tlsApp
+		srv.events = eventsAppIface.(*caddyevents.App)
+		srv.ctx = ctx
 		srv.logger = app.logger.Named("log")
 		srv.errorLogger = app.logger.Named("log.error")
 
diff --git a/modules/caddyhttp/reverseproxy/event.go b/modules/caddyhttp/reverseproxy/event.go
deleted file mode 100644
index 2ab39fdb308..00000000000
--- a/modules/caddyhttp/reverseproxy/event.go
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright 2015 Matthew Holt and The Caddy Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package reverseproxy
-
-import (
-	"github.com/caddyserver/caddy/v2"
-	"github.com/caddyserver/caddy/v2/modules/caddyevent"
-)
-
-// ActiveUnhealthyEvent is dispatched when an upstream
-// became unhealthy via active health checks, when it was
-// previously healthy.
-type ActiveUnhealthyEvent struct {
-	caddyevent.GenericEvent
-}
-
-func NewActiveUnhealthyEvent(hostAddr string) *ActiveUnhealthyEvent {
-	event := new(ActiveUnhealthyEvent)
-	event.SetID(caddy.ModuleID("http.handlers.reverse_proxy.event.active_unhealthy"))
-	event.Set("host", hostAddr)
-	return event
-}
-
-func (e ActiveUnhealthyEvent) GetHost() string {
-	return e.Get("host").(string)
-}
-
-// ActiveUnhealthyEvent is dispatched when an upstream
-// became healthy via active health checks, when it was
-// previously unhealthy.
-type ActiveHealthyEvent struct {
-	caddyevent.GenericEvent
-}
-
-func NewActiveHealthyEvent(hostAddr string) *ActiveHealthyEvent {
-	event := new(ActiveHealthyEvent)
-	event.SetID(caddy.ModuleID("http.handlers.reverse_proxy.event.active_healthy"))
-	event.Set("host", hostAddr)
-	return event
-}
-
-func (e ActiveHealthyEvent) GetHost() string {
-	return e.Get("host").(string)
-}
diff --git a/modules/caddyhttp/reverseproxy/healthchecks.go b/modules/caddyhttp/reverseproxy/healthchecks.go
index c47b5bd6552..bbd81ccacda 100644
--- a/modules/caddyhttp/reverseproxy/healthchecks.go
+++ b/modules/caddyhttp/reverseproxy/healthchecks.go
@@ -287,7 +287,7 @@ func (h *Handler) doActiveHealthCheck(dialInfo DialInfo, hostAddr string, upstre
 	markUnhealthy := func() {
 		// dispatch an event that the host newly became unhealthy
 		if upstream.setHealthy(false) {
-			h.event.AsyncDispatch(NewActiveUnhealthyEvent(hostAddr))
+			h.events.Emit(h.ctx, "unhealthy", map[string]interface{}{"host": hostAddr})
 		}
 	}
 
@@ -353,7 +353,7 @@ func (h *Handler) doActiveHealthCheck(dialInfo DialInfo, hostAddr string, upstre
 	// passed health check parameters, so mark as healthy
 	if upstream.setHealthy(true) {
 		h.HealthChecks.Active.logger.Info("host is up", zap.String("host", hostAddr))
-		h.event.AsyncDispatch(NewActiveHealthyEvent(hostAddr))
+		h.events.Emit(h.ctx, "healthy", map[string]interface{}{"host": hostAddr})
 	}
 
 	return nil
diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go
index 07eea31507e..91d2fdd352d 100644
--- a/modules/caddyhttp/reverseproxy/reverseproxy.go
+++ b/modules/caddyhttp/reverseproxy/reverseproxy.go
@@ -33,7 +33,7 @@ import (
 
 	"github.com/caddyserver/caddy/v2"
 	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
-	"github.com/caddyserver/caddy/v2/modules/caddyevent"
+	"github.com/caddyserver/caddy/v2/modules/caddyevents"
 	"github.com/caddyserver/caddy/v2/modules/caddyhttp"
 	"github.com/caddyserver/caddy/v2/modules/caddyhttp/headers"
 	"github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite"
@@ -180,7 +180,7 @@ type Handler struct {
 
 	ctx    caddy.Context
 	logger *zap.Logger
-	event  *caddyevent.EventApp
+	events *caddyevents.App
 }
 
 // CaddyModule returns the Caddy module information.
@@ -193,14 +193,13 @@ func (Handler) CaddyModule() caddy.ModuleInfo {
 
 // Provision ensures that h is set up properly before use.
 func (h *Handler) Provision(ctx caddy.Context) error {
-	h.ctx = ctx
-	h.logger = ctx.Logger(h)
-
-	eventAppIface, err := ctx.App("event")
+	eventAppIface, err := ctx.App("events")
 	if err != nil {
-		return fmt.Errorf("getting event app: %v", err)
+		return fmt.Errorf("getting events app: %v", err)
 	}
-	h.event = eventAppIface.(*caddyevent.EventApp)
+	h.events = eventAppIface.(*caddyevents.App)
+	h.ctx = ctx
+	h.logger = ctx.Logger(h)
 
 	// verify SRV compatibility - TODO: LookupSRV deprecated; will be removed
 	for i, v := range h.Upstreams {
diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go
index a4a976f7ef7..61d9a4061a4 100644
--- a/modules/caddyhttp/server.go
+++ b/modules/caddyhttp/server.go
@@ -27,6 +27,7 @@ import (
 	"time"
 
 	"github.com/caddyserver/caddy/v2"
+	"github.com/caddyserver/caddy/v2/modules/caddyevents"
 	"github.com/caddyserver/caddy/v2/modules/caddytls"
 	"github.com/caddyserver/certmagic"
 	"github.com/lucas-clemente/quic-go/http3"
@@ -134,9 +135,11 @@ type Server struct {
 	listenerWrappers    []caddy.ListenerWrapper
 
 	tlsApp       *caddytls.TLS
+	events       *caddyevents.App
 	logger       *zap.Logger
 	accessLogger *zap.Logger
 	errorLogger  *zap.Logger
+	ctx          caddy.Context
 
 	h3server *http3.Server
 }
diff --git a/modules/caddytls/event.go b/modules/caddytls/event.go
deleted file mode 100644
index 7503cd25bcc..00000000000
--- a/modules/caddytls/event.go
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright 2015 Matthew Holt and The Caddy Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package caddytls
-
-import (
-	"github.com/caddyserver/caddy/v2"
-	"github.com/caddyserver/caddy/v2/modules/caddyevent"
-	"github.com/caddyserver/certmagic"
-)
-
-// onEvent translates certmagic events into caddy events
-// then dispatches them asynchronously.
-func (t *TLS) onEvent(event string, data interface{}) {
-	switch event {
-	// case "cached_managed_cert", "cached_unmanaged_cert":
-	// 	subjectNames, ok := data.([]string)
-	// 	if !ok {
-	// 		return
-	// 	}
-	// 	t.event.AsyncDispatch(caddyevent.NewGeneric(
-	// 		caddy.ModuleID("tls.event."+event),
-	// 		caddyevent.DataMap{"subjectNames": subjectNames},
-	// 	))
-
-	// case "tls_handshake_started", "tls_handshake_completed":
-	// 	clientHello, ok := data.(*tls.ClientHelloInfo)
-	// 	if !ok {
-	// 		return
-	// 	}
-	// 	t.event.AsyncDispatch(NewHandshakeEvent(event, clientHello))
-
-	case "cert_obtained", "cert_renewed", "cert_revoked":
-		eventData, ok := data.(certmagic.CertificateEventData)
-		if !ok {
-			return
-		}
-		t.event.AsyncDispatch(NewCertEvent(event, eventData))
-	}
-}
-
-// CertEvent is dispatched when a certificate is obtained, renewed, or revoked.
-type CertEvent struct {
-	caddyevent.GenericEvent
-}
-
-func NewCertEvent(event string, data certmagic.CertificateEventData) *CertEvent {
-	certEvent := new(CertEvent)
-	certEvent.SetID(caddy.ModuleID("tls.event." + event))
-	certEvent.Set("name", data.Name)
-	certEvent.Set("issuerKey", data.IssuerKey)
-	certEvent.Set("storageKey", data.StorageKey)
-	return certEvent
-}
-
-func (e CertEvent) GetName() string {
-	return e.Get("name").(string)
-}
-
-func (e CertEvent) GetIssuerKey() string {
-	return e.Get("issuerKey").(string)
-}
-
-func (e CertEvent) GetStorageKey() string {
-	return e.Get("storageKey").(string)
-}
-
-// HandshakeEvent is dispatched when a TLS handshake is started, or is completed.
-// type HandshakeEvent struct {
-// 	caddyevent.GenericEvent
-// }
-
-// func NewHandshakeEvent(event string, clientHello *tls.ClientHelloInfo) *HandshakeEvent {
-// 	handshakeEvent := new(HandshakeEvent)
-// 	handshakeEvent.SetID(caddy.ModuleID("tls.event." + event))
-// 	handshakeEvent.Set("clientHello", clientHello)
-// 	return handshakeEvent
-// }
-
-// func (e CertEvent) GetClientHello() *tls.ClientHelloInfo {
-// 	return e.Get("clientHello").(*tls.ClientHelloInfo)
-// }
diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go
index a310d194c5d..b66a9857746 100644
--- a/modules/caddytls/tls.go
+++ b/modules/caddytls/tls.go
@@ -25,7 +25,7 @@ import (
 	"time"
 
 	"github.com/caddyserver/caddy/v2"
-	"github.com/caddyserver/caddy/v2/modules/caddyevent"
+	"github.com/caddyserver/caddy/v2/modules/caddyevents"
 	"github.com/caddyserver/certmagic"
 	"go.uber.org/zap"
 )
@@ -74,7 +74,7 @@ type TLS struct {
 	storageCleanTicker *time.Ticker
 	storageCleanStop   chan struct{}
 	logger             *zap.Logger
-	event              *caddyevent.EventApp
+	events             *caddyevents.App
 }
 
 // CaddyModule returns the Caddy module information.
@@ -87,16 +87,15 @@ func (TLS) CaddyModule() caddy.ModuleInfo {
 
 // Provision sets up the configuration for the TLS app.
 func (t *TLS) Provision(ctx caddy.Context) error {
+	eventsAppIface, err := ctx.App("events")
+	if err != nil {
+		return fmt.Errorf("getting events app: %v", err)
+	}
+	t.events = eventsAppIface.(*caddyevents.App)
 	t.ctx = ctx
 	t.logger = ctx.Logger(t)
 	repl := caddy.NewReplacer()
 
-	eventAppIface, err := ctx.App("event")
-	if err != nil {
-		return fmt.Errorf("getting event app: %v", err)
-	}
-	t.event = eventAppIface.(*caddyevent.EventApp)
-
 	// set up a new certificate cache; this (re)loads all certificates
 	cacheOpts := certmagic.CacheOptions{
 		GetConfigForCert: func(cert certmagic.Certificate) (*certmagic.Config, error) {
@@ -523,6 +522,23 @@ func (t *TLS) storageCleanInterval() time.Duration {
 	return defaultStorageCleanInterval
 }
 
+// onEvent translates CertMagic events into Caddy events then dispatches them.
+// TODO: enhance CertMagic's event features to better accommodate our needs
+func (t *TLS) onEvent(eventName string, data interface{}) {
+	evtData := make(map[string]interface{})
+	switch d := data.(type) {
+	case certmagic.CertificateEventData:
+		evtData["name"] = d.Name
+		evtData["issuer_key"] = d.IssuerKey
+		evtData["storage_key"] = d.StorageKey
+	case *tls.ClientHelloInfo:
+		evtData["client_hello"] = d
+	case []string:
+		evtData["subject_names"] = d
+	}
+	t.events.Emit(t.ctx, eventName, evtData)
+}
+
 // CertificateLoader is a type that can load certificates.
 // Certificates can optionally be associated with tags.
 type CertificateLoader interface {
diff --git a/modules/standard/imports.go b/modules/standard/imports.go
index 010742b597a..a9d0b396825 100644
--- a/modules/standard/imports.go
+++ b/modules/standard/imports.go
@@ -3,7 +3,8 @@ package standard
 import (
 	// standard Caddy modules
 	_ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
-	_ "github.com/caddyserver/caddy/v2/modules/caddyevent"
+	_ "github.com/caddyserver/caddy/v2/modules/caddyevents"
+	_ "github.com/caddyserver/caddy/v2/modules/caddyevents/eventsconfig"
 	_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/standard"
 	_ "github.com/caddyserver/caddy/v2/modules/caddypki"
 	_ "github.com/caddyserver/caddy/v2/modules/caddypki/acmeserver"