diff --git a/pkg/base/BUILD.bazel b/pkg/base/BUILD.bazel
index 4f7102949356..3517bbde2077 100644
--- a/pkg/base/BUILD.bazel
+++ b/pkg/base/BUILD.bazel
@@ -69,6 +69,7 @@ go_test(
         "//pkg/util/leaktest",
         "//pkg/util/log",
         "//pkg/util/uuid",
+        "@com_github_cockroachdb_datadriven//:datadriven",
         "@com_github_cockroachdb_errors//:errors",
         "@com_github_davecgh_go_spew//spew",
         "@com_github_stretchr_testify//require",
diff --git a/pkg/base/config.go b/pkg/base/config.go
index a053adcafb7a..e7af4e91380c 100644
--- a/pkg/base/config.go
+++ b/pkg/base/config.go
@@ -17,6 +17,7 @@ import (
 	"net"
 	"net/url"
 	"os"
+	"strings"
 	"time"
 
 	"github.com/cockroachdb/cockroach/pkg/roachpb"
@@ -838,8 +839,7 @@ type TempStorageConfig struct {
 	Settings *cluster.Settings
 }
 
-// WALFailoverMode configures a node's stores behavior under high write latency
-// to their write-ahead logs.
+// WALFailoverMode specifies the mode of WAL failover.
 type WALFailoverMode int8
 
 const (
@@ -858,11 +858,11 @@ const (
 	// volume, the batch commit latency is insulated from the effects of momentary
 	// disk stalls.
 	WALFailoverAmongStores
+	// WALFailoverExplicitPath enables WAL failover for a single-store node to an
+	// explicitly specified path.
+	WALFailoverExplicitPath
 )
 
-// Type implements the pflag.Value interface.
-func (m *WALFailoverMode) Type() string { return "string" }
-
 // String implements fmt.Stringer.
 func (m *WALFailoverMode) String() string {
 	return redact.StringWithoutMarkers(m)
@@ -877,25 +877,125 @@ func (m *WALFailoverMode) SafeFormat(p redact.SafePrinter, _ rune) {
 		p.SafeString("disabled")
 	case WALFailoverAmongStores:
 		p.SafeString("among-stores")
+	case WALFailoverExplicitPath:
+		p.SafeString("path")
 	default:
 		p.Printf("<unknown WALFailoverMode %d>", int8(*m))
 	}
 }
 
+// WALFailoverConfig configures a node's stores behavior under high write
+// latency to their write-ahead logs.
+type WALFailoverConfig struct {
+	Mode WALFailoverMode
+	// Path is the non-store path to which WALs should be written when failing
+	// over. It must be nonempty if and only if Mode == WALFailoverExplicitPath.
+	Path ExternalPath
+	// PrevPath is the previously used non-store path. It may be set with Mode ==
+	// WALFailoverExplicitPath (when changing the secondary path) or
+	// WALFailoverDisabled (when disabling WAL failover after it was previously
+	// enabled with WALFailoverExplicitPath). It must be empty for other modes.
+	// If Mode is WALFailoverDisabled and previously WAL failover was enabled
+	// using WALFailoverAmongStores, then PrevPath must not be set.
+	PrevPath ExternalPath
+}
+
+// ExternalPath represents a non-store path and associated encryption-at-rest
+// configuration.
+type ExternalPath struct {
+	Path string
+	// EncryptionOptions is a serialized protobuf set by Go CCL code describing
+	// the encryption-at-rest configuration. If encryption-at-rest has ever been
+	// enabled on the store, this field must be set.
+	EncryptionOptions []byte
+}
+
+// IsSet returns whether or not the external path was provided.
+func (e ExternalPath) IsSet() bool { return e.Path != "" }
+
+// Type implements the pflag.Value interface.
+func (c *WALFailoverConfig) Type() string { return "string" }
+
+// String implements fmt.Stringer.
+func (c *WALFailoverConfig) String() string {
+	return redact.StringWithoutMarkers(c)
+}
+
+// SafeFormat implements the refact.SafeFormatter interface.
+func (c *WALFailoverConfig) SafeFormat(p redact.SafePrinter, _ rune) {
+	switch c.Mode {
+	case WALFailoverDefault:
+		// Empty
+	case WALFailoverDisabled:
+		p.SafeString("disabled")
+		if c.PrevPath.IsSet() {
+			p.SafeString(",prev_path=")
+			p.SafeString(redact.SafeString(c.PrevPath.Path))
+		}
+	case WALFailoverAmongStores:
+		p.SafeString("among-stores")
+	case WALFailoverExplicitPath:
+		p.SafeString("path=")
+		p.SafeString(redact.SafeString(c.Path.Path))
+		if c.PrevPath.IsSet() {
+			p.SafeString(",prev_path=")
+			p.SafeString(redact.SafeString(c.PrevPath.Path))
+		}
+	default:
+		p.Printf("<unknown WALFailoverMode %d>", int8(c.Mode))
+	}
+}
+
 // Set implements the pflag.Value interface.
-func (m *WALFailoverMode) Set(s string) error {
-	switch s {
-	case "disabled":
-		*m = WALFailoverDisabled
-	case "among-stores":
-		*m = WALFailoverAmongStores
+func (c *WALFailoverConfig) Set(s string) error {
+	switch {
+	case strings.HasPrefix(s, "disabled"):
+		c.Mode = WALFailoverDisabled
+		var ok bool
+		c.Path.Path, c.PrevPath.Path, ok = parseWALFailoverPathFields(strings.TrimPrefix(s, "disabled"))
+		if !ok || c.Path.IsSet() {
+			return errors.Newf("invalid disabled --wal-failover setting: %s "+
+				"expect disabled[,prev_path=<prev_path>]", s)
+		}
+	case s == "among-stores":
+		c.Mode = WALFailoverAmongStores
+	case strings.HasPrefix(s, "path="):
+		c.Mode = WALFailoverExplicitPath
+		var ok bool
+		c.Path.Path, c.PrevPath.Path, ok = parseWALFailoverPathFields(s)
+		if !ok || !c.Path.IsSet() {
+			return errors.Newf("invalid path --wal-failover setting: %s "+
+				"expect path=<path>[,prev_path=<prev_path>]", s)
+		}
 	default:
 		return errors.Newf("invalid --wal-failover setting: %s "+
-			"(possible values: disabled, among-stores)", s)
+			"(possible values: disabled, among-stores, path=<path>)", s)
 	}
 	return nil
 }
 
+func parseWALFailoverPathFields(s string) (path, prevPath string, ok bool) {
+	if s == "" {
+		return "", "", true
+	}
+	if s2 := strings.TrimPrefix(s, "path="); len(s2) < len(s) {
+		s = s2
+		if i := strings.IndexByte(s, ','); i == -1 {
+			return s, "", true
+		} else {
+			path = s[:i]
+			s = s[i:]
+		}
+	}
+
+	// Any remainder must be a prev_path= field.
+	if !strings.HasPrefix(s, ",prev_path=") {
+		return "", "", false
+	}
+	prevPath = strings.TrimPrefix(s, ",prev_path=")
+	return path, prevPath, true
+}
+
 // ExternalIODirConfig describes various configuration options pertaining
 // to external storage implementations.
 // TODO(adityamaru): Rename ExternalIODirConfig to ExternalIOConfig because it
diff --git a/pkg/base/config_test.go b/pkg/base/config_test.go
index ba1b459eee8c..21c24258cdd6 100644
--- a/pkg/base/config_test.go
+++ b/pkg/base/config_test.go
@@ -11,8 +11,10 @@
 package base_test
 
 import (
+	"bytes"
 	"fmt"
 	"math"
+	"strings"
 	"testing"
 	"time"
 
@@ -20,6 +22,7 @@ import (
 	"github.com/cockroachdb/cockroach/pkg/testutils/datapathutils"
 	"github.com/cockroachdb/cockroach/pkg/testutils/echotest"
 	"github.com/cockroachdb/cockroach/pkg/util/leaktest"
+	"github.com/cockroachdb/datadriven"
 	"github.com/davecgh/go-spew/spew"
 	"github.com/stretchr/testify/require"
 )
@@ -154,3 +157,20 @@ func TestRaftMaxInflightBytes(t *testing.T) {
 		})
 	}
 }
+
+func TestWALFailoverConfigRoundtrip(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+
+	datadriven.RunTest(t, datapathutils.TestDataPath(t, "wal-failover-config"), func(t *testing.T, d *datadriven.TestData) string {
+		var buf bytes.Buffer
+		for _, l := range strings.Split(d.Input, "\n") {
+			var cfg base.WALFailoverConfig
+			if err := cfg.Set(l); err != nil {
+				fmt.Fprintf(&buf, "err: %s\n", err)
+				continue
+			}
+			fmt.Fprintln(&buf, cfg.String())
+		}
+		return buf.String()
+	})
+}
diff --git a/pkg/base/store_spec.go b/pkg/base/store_spec.go
index b425317463b4..10deec96fec6 100644
--- a/pkg/base/store_spec.go
+++ b/pkg/base/store_spec.go
@@ -43,10 +43,10 @@ import (
 // hard coded to 640MiB.
 const MinimumStoreSize = 10 * 64 << 20
 
-// GetAbsoluteStorePath takes a (possibly relative) and returns the absolute path.
+// GetAbsoluteFSPath takes a (possibly relative) and returns the absolute path.
 // Returns an error if the path begins with '~' or Abs fails.
 // 'fieldName' is used in error strings.
-func GetAbsoluteStorePath(fieldName string, p string) (string, error) {
+func GetAbsoluteFSPath(fieldName string, p string) (string, error) {
 	if p[0] == '~' {
 		return "", fmt.Errorf("%s cannot start with '~': %s", fieldName, p)
 	}
diff --git a/pkg/base/testdata/wal-failover-config b/pkg/base/testdata/wal-failover-config
new file mode 100644
index 000000000000..34480f7414ff
--- /dev/null
+++ b/pkg/base/testdata/wal-failover-config
@@ -0,0 +1,29 @@
+parse
+among-stores
+----
+among-stores
+
+parse
+disabled
+disabled,prev_path=foo
+----
+disabled
+disabled,prev_path=foo
+
+parse
+path=/foo
+path=/foo,prev_path=/bar
+----
+path=/foo
+path=/foo,prev_path=/bar
+
+parse
+disabled,path=foo
+among-stores,path=foo
+among-stores,prev_path=foo
+garbage
+----
+err: invalid disabled --wal-failover setting: disabled,path=foo expect disabled[,prev_path=<prev_path>]
+err: invalid --wal-failover setting: among-stores,path=foo (possible values: disabled, among-stores, path=<path>)
+err: invalid --wal-failover setting: among-stores,prev_path=foo (possible values: disabled, among-stores, path=<path>)
+err: invalid --wal-failover setting: garbage (possible values: disabled, among-stores, path=<path>)
diff --git a/pkg/ccl/baseccl/encryption_spec.go b/pkg/ccl/baseccl/encryption_spec.go
index 65a0468522f0..6defa2385ef4 100644
--- a/pkg/ccl/baseccl/encryption_spec.go
+++ b/pkg/ccl/baseccl/encryption_spec.go
@@ -92,7 +92,7 @@ func NewStoreEncryptionSpec(value string) (StoreEncryptionSpec, error) {
 		switch field {
 		case pathField:
 			var err error
-			es.Path, err = base.GetAbsoluteStorePath(pathField, value)
+			es.Path, err = base.GetAbsoluteFSPath(pathField, value)
 			if err != nil {
 				return StoreEncryptionSpec{}, err
 			}
@@ -101,7 +101,7 @@ func NewStoreEncryptionSpec(value string) (StoreEncryptionSpec, error) {
 				es.KeyPath = plaintextFieldValue
 			} else {
 				var err error
-				es.KeyPath, err = base.GetAbsoluteStorePath("key", value)
+				es.KeyPath, err = base.GetAbsoluteFSPath("key", value)
 				if err != nil {
 					return StoreEncryptionSpec{}, err
 				}
@@ -111,7 +111,7 @@ func NewStoreEncryptionSpec(value string) (StoreEncryptionSpec, error) {
 				es.OldKeyPath = plaintextFieldValue
 			} else {
 				var err error
-				es.OldKeyPath, err = base.GetAbsoluteStorePath("old-key", value)
+				es.OldKeyPath, err = base.GetAbsoluteFSPath("old-key", value)
 				if err != nil {
 					return StoreEncryptionSpec{}, err
 				}
@@ -141,17 +141,17 @@ func NewStoreEncryptionSpec(value string) (StoreEncryptionSpec, error) {
 	return es, nil
 }
 
-// StoreEncryptionSpecList contains a slice of StoreEncryptionSpecs that implements pflag's value
+// EncryptionSpecList contains a slice of StoreEncryptionSpecs that implements pflag's value
 // interface.
-type StoreEncryptionSpecList struct {
+type EncryptionSpecList struct {
 	Specs []StoreEncryptionSpec
 }
 
-var _ pflag.Value = &StoreEncryptionSpecList{}
+var _ pflag.Value = &EncryptionSpecList{}
 
 // String returns a string representation of all the StoreEncryptionSpecs. This is part
 // of pflag's value interface.
-func (encl StoreEncryptionSpecList) String() string {
+func (encl EncryptionSpecList) String() string {
 	var buffer bytes.Buffer
 	for _, ss := range encl.Specs {
 		fmt.Fprintf(&buffer, "--%s=%s ", cliflagsccl.EnterpriseEncryption.Name, ss)
@@ -165,13 +165,13 @@ func (encl StoreEncryptionSpecList) String() string {
 
 // Type returns the underlying type in string form. This is part of pflag's
 // value interface.
-func (encl *StoreEncryptionSpecList) Type() string {
-	return "StoreEncryptionSpec"
+func (encl *EncryptionSpecList) Type() string {
+	return "EncryptionSpec"
 }
 
 // Set adds a new value to the StoreEncryptionSpecValue. It is the important part of
 // pflag's value interface.
-func (encl *StoreEncryptionSpecList) Set(value string) error {
+func (encl *EncryptionSpecList) Set(value string) error {
 	spec, err := NewStoreEncryptionSpec(value)
 	if err != nil {
 		return err
@@ -184,12 +184,13 @@ func (encl *StoreEncryptionSpecList) Set(value string) error {
 	return nil
 }
 
-// PopulateStoreSpecWithEncryption iterates through the StoreEncryptionSpecList and looks
-// for matching paths in the StoreSpecList.
-// Any unmatched StoreEncryptionSpec causes an error.
-// Matching stores have a few encryption-related fields set.
-func PopulateStoreSpecWithEncryption(
-	storeSpecs base.StoreSpecList, encryptionSpecs StoreEncryptionSpecList,
+// PopulateWithEncryptionOpts iterates through the EncryptionSpecList and looks
+// for matching paths in the StoreSpecList and WAL failover config. Any
+// unmatched EncryptionSpec causes an error.
+func PopulateWithEncryptionOpts(
+	storeSpecs base.StoreSpecList,
+	walFailoverConfig *base.WALFailoverConfig,
+	encryptionSpecs EncryptionSpecList,
 ) error {
 	for _, es := range encryptionSpecs.Specs {
 		var found bool
@@ -212,8 +213,29 @@ func PopulateStoreSpecWithEncryption(
 			found = true
 			break
 		}
+
+		for _, externalPath := range [2]*base.ExternalPath{&walFailoverConfig.Path, &walFailoverConfig.PrevPath} {
+			if !externalPath.IsSet() || externalPath.Path != es.Path {
+				continue
+			}
+			// NB: The external paths WALFailoverConfig.Path and
+			// WALFailoverConfig.PrevPath are only ever set in single-store
+			// configurations. In multi-store with among-stores failover mode, these
+			// will be empty (so we won't encounter the same path twice).
+			if len(externalPath.EncryptionOptions) > 0 {
+				return fmt.Errorf("WAL failover path %s already has an encryption setting",
+					externalPath.Path)
+			}
+			opts, err := es.ToEncryptionOptions()
+			if err != nil {
+				return err
+			}
+			externalPath.EncryptionOptions = opts
+			found = true
+		}
+
 		if !found {
-			return fmt.Errorf("no store with path %s found for encryption setting: %v", es.Path, es)
+			return fmt.Errorf("no usage of path %s found for encryption setting: %v", es.Path, es)
 		}
 	}
 	return nil
@@ -221,9 +243,7 @@ func PopulateStoreSpecWithEncryption(
 
 // EncryptionOptionsForStore takes a store directory and returns its EncryptionOptions
 // if a matching entry if found in the StoreEncryptionSpecList.
-func EncryptionOptionsForStore(
-	dir string, encryptionSpecs StoreEncryptionSpecList,
-) ([]byte, error) {
+func EncryptionOptionsForStore(dir string, encryptionSpecs EncryptionSpecList) ([]byte, error) {
 	// We need an absolute path, but the input may have come in relative.
 	path, err := filepath.Abs(dir)
 	if err != nil {
diff --git a/pkg/ccl/cliccl/debug.go b/pkg/ccl/cliccl/debug.go
index 461a3beba3db..c17b4a94ee35 100644
--- a/pkg/ccl/cliccl/debug.go
+++ b/pkg/ccl/cliccl/debug.go
@@ -127,33 +127,33 @@ mode in the current environment.
 	// Add the encryption flag to commands that need it.
 	// For the encryption-status command.
 	f := encryptionStatusCmd.Flags()
-	cliflagcfg.VarFlag(f, &storeEncryptionSpecs, cliflagsccl.EnterpriseEncryption)
+	cliflagcfg.VarFlag(f, &encryptionSpecs, cliflagsccl.EnterpriseEncryption)
 	// And other flags.
 	f.BoolVar(&encryptionStatusOpts.activeStoreIDOnly, "active-store-key-id-only", false,
 		"print active store key ID and exit")
 	// For the encryption-decrypt command.
 	f = encryptionDecryptCmd.Flags()
-	cliflagcfg.VarFlag(f, &storeEncryptionSpecs, cliflagsccl.EnterpriseEncryption)
+	cliflagcfg.VarFlag(f, &encryptionSpecs, cliflagsccl.EnterpriseEncryption)
 	// For the encryption-registry-list command.
 	f = encryptionRegistryList.Flags()
-	cliflagcfg.VarFlag(f, &storeEncryptionSpecs, cliflagsccl.EnterpriseEncryption)
+	cliflagcfg.VarFlag(f, &encryptionSpecs, cliflagsccl.EnterpriseEncryption)
 
 	// Add encryption flag to all OSS debug commands that want it.
 	for _, cmd := range cli.DebugCommandsRequiringEncryption {
-		// storeEncryptionSpecs is in start.go.
-		cliflagcfg.VarFlag(cmd.Flags(), &storeEncryptionSpecs, cliflagsccl.EnterpriseEncryption)
+		// encryptionSpecs is in start.go.
+		cliflagcfg.VarFlag(cmd.Flags(), &encryptionSpecs, cliflagsccl.EnterpriseEncryption)
 	}
 
 	// init has already run in cli/debug.go since this package imports it, so
 	// DebugPebbleCmd already has all its subcommands. We could traverse those
 	// here. But we don't need to by using PersistentFlags.
 	cliflagcfg.VarFlag(cli.DebugPebbleCmd.PersistentFlags(),
-		&storeEncryptionSpecs, cliflagsccl.EnterpriseEncryption)
+		&encryptionSpecs, cliflagsccl.EnterpriseEncryption)
 
 	cli.PopulateEnvConfigHook = fillEncryptionOptionsForStore
 	cli.EncryptedStorePathsHook = func() []string {
 		var res []string
-		for _, spec := range storeEncryptionSpecs.Specs {
+		for _, spec := range encryptionSpecs.Specs {
 			res = append(res, spec.Path)
 		}
 		return res
@@ -163,7 +163,7 @@ mode in the current environment.
 // fillEncryptionOptionsForStore fills the EnvConfig fields
 // based on the --enterprise-encryption flag value.
 func fillEncryptionOptionsForStore(dir string, cfg *fs.EnvConfig) error {
-	opts, err := baseccl.EncryptionOptionsForStore(dir, storeEncryptionSpecs)
+	opts, err := baseccl.EncryptionOptionsForStore(dir, encryptionSpecs)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/ccl/cliccl/start.go b/pkg/ccl/cliccl/start.go
index 472e3f868932..2dd6f0e86e24 100644
--- a/pkg/ccl/cliccl/start.go
+++ b/pkg/ccl/cliccl/start.go
@@ -19,11 +19,11 @@ import (
 // This does not define a `start` command, only modifications to the existing command
 // in `pkg/cli/start.go`.
 
-var storeEncryptionSpecs baseccl.StoreEncryptionSpecList
+var encryptionSpecs baseccl.EncryptionSpecList
 
 func init() {
 	for _, cmd := range cli.StartCmds {
-		cliflagcfg.VarFlag(cmd.Flags(), &storeEncryptionSpecs, cliflagsccl.EnterpriseEncryption)
+		cliflagcfg.VarFlag(cmd.Flags(), &encryptionSpecs, cliflagsccl.EnterpriseEncryption)
 
 		// Add a new pre-run command to match encryption specs to store specs.
 		cli.AddPersistentPreRunE(cmd, func(cmd *cobra.Command, _ []string) error {
@@ -32,8 +32,13 @@ func init() {
 	}
 }
 
-// populateStoreSpecsEncryption is a PreRun hook that matches store encryption specs with the
-// parsed stores and populates some fields in the StoreSpec.
+// populateStoreSpecsEncryption is a PreRun hook that matches store encryption
+// specs with the parsed stores and populates some fields in the StoreSpec and
+// WAL failover config.
 func populateStoreSpecsEncryption() error {
-	return baseccl.PopulateStoreSpecWithEncryption(cli.GetServerCfgStores(), storeEncryptionSpecs)
+	return baseccl.PopulateWithEncryptionOpts(
+		cli.GetServerCfgStores(),
+		cli.GetWALFailoverConfig(),
+		encryptionSpecs,
+	)
 }
diff --git a/pkg/cli/cliflags/flags.go b/pkg/cli/cliflags/flags.go
index 55769680bde1..d2ca2cd83d5a 100644
--- a/pkg/cli/cliflags/flags.go
+++ b/pkg/cli/cliflags/flags.go
@@ -1005,15 +1005,34 @@ which use 'cockroach-data-tenant-X' for tenant 'X')
 		Name:   "wal-failover",
 		EnvVar: "COCKROACH_WAL_FAILOVER",
 		Description: `
-Configures the use and behavior of WAL failover. Defaults to "disabled".
-The value "among-stores" enables automatic failover to another store's
-data directory if a WAL write does not complete within the configured
-threshold. For example:
+Configures the use and behavior of WAL failover. WAL failover enables
+automatic failover to another directory if a WAL write does not complete
+within the configured threshold. Defaults to "disabled". Possible values
+depend on the number of stores a node is configured to use.
+
+If a node has multiple stores, the value "among-stores" enables automatic
+failover to another store's data directory. CockroachDB will automatically
+assign each store a secondary to serve as its WAL failover destination.
+For example:
 <PRE>
 
   --wal-failover=among-stores
 
 </PRE>
+
+If a node has a single store, the value "path=<path>" enables automatic
+failover to the provided path. After this setting is used, changing the
+configuration to a new path or disabling requires providing the previous
+path as ",prev_path=<path>". For example:
+
+<PRE>
+
+    --wal-failover=path=/mnt/data2
+    --wal-failover=path=/mnt/data3,prev_path=/mnt/data2
+    --wal-failover=disabled,prev_path=/mnt/data3
+
+</PRE>
+
 See the storage.wal_failover.unhealthy_op_threshold cluster setting.
 `,
 	}
diff --git a/pkg/cli/context.go b/pkg/cli/context.go
index f53cc863b62d..1d8ffd02f499 100644
--- a/pkg/cli/context.go
+++ b/pkg/cli/context.go
@@ -733,3 +733,13 @@ func setUserfileContextDefaults() {
 func GetServerCfgStores() base.StoreSpecList {
 	return serverCfg.Stores
 }
+
+// GetWALFailoverConfig provides direct public access to the WALFailoverConfig
+// inside serverCfg. This is used by CCL code to populate some fields.
+//
+// WARNING: consider very carefully whether you should be using this.
+// If you are not writing CCL code that performs command-line flag
+// parsing, you probably should not be using this.
+func GetWALFailoverConfig() *base.WALFailoverConfig {
+	return &serverCfg.WALFailover
+}
diff --git a/pkg/cli/flags.go b/pkg/cli/flags.go
index ff70ebdbdb16..57e6ceacd84d 100644
--- a/pkg/cli/flags.go
+++ b/pkg/cli/flags.go
@@ -1249,14 +1249,14 @@ func extraStoreFlagInit(cmd *cobra.Command) error {
 	}
 	// Convert all the store paths to absolute paths. We want this to
 	// ensure canonical directories across invocations; and also to
-	// benefit from the check in GetAbsoluteStorePath() that the user
+	// benefit from the check in GetAbsoluteFSPath() that the user
 	// didn't mistakenly assume a heading '~' would get translated by
 	// CockroachDB. (The shell should be responsible for that.)
 	for i, ss := range serverCfg.Stores.Specs {
 		if ss.InMemory {
 			continue
 		}
-		absPath, err := base.GetAbsoluteStorePath("path", ss.Path)
+		absPath, err := base.GetAbsoluteFSPath("path", ss.Path)
 		if err != nil {
 			return err
 		}
@@ -1264,6 +1264,21 @@ func extraStoreFlagInit(cmd *cobra.Command) error {
 		serverCfg.Stores.Specs[i] = ss
 	}
 
+	if serverCfg.WALFailover.Path.IsSet() {
+		absPath, err := base.GetAbsoluteFSPath("wal-failover.path", serverCfg.WALFailover.Path.Path)
+		if err != nil {
+			return err
+		}
+		serverCfg.WALFailover.Path.Path = absPath
+	}
+	if serverCfg.WALFailover.PrevPath.IsSet() {
+		absPath, err := base.GetAbsoluteFSPath("wal-failover.prev_path", serverCfg.WALFailover.PrevPath.Path)
+		if err != nil {
+			return err
+		}
+		serverCfg.WALFailover.PrevPath.Path = absPath
+	}
+
 	// Configure the external I/O directory.
 	if !fs.Changed(cliflags.ExternalIODir.Name) {
 		// Try to find a directory from the store configuration.
@@ -1278,7 +1293,7 @@ func extraStoreFlagInit(cmd *cobra.Command) error {
 	if startCtx.externalIODir != "" {
 		// Make the directory name absolute.
 		var err error
-		startCtx.externalIODir, err = base.GetAbsoluteStorePath(cliflags.ExternalIODir.Name, startCtx.externalIODir)
+		startCtx.externalIODir, err = base.GetAbsoluteFSPath(cliflags.ExternalIODir.Name, startCtx.externalIODir)
 		if err != nil {
 			return err
 		}
diff --git a/pkg/cli/flags_test.go b/pkg/cli/flags_test.go
index d3a121c4783f..de8f89a4661a 100644
--- a/pkg/cli/flags_test.go
+++ b/pkg/cli/flags_test.go
@@ -1344,7 +1344,7 @@ func TestSQLPodStorageDefaults(t *testing.T) {
 
 	defer initCLIDefaults()
 
-	expectedDefaultDir, err := base.GetAbsoluteStorePath("", "cockroach-data-tenant-9")
+	expectedDefaultDir, err := base.GetAbsoluteFSPath("", "cockroach-data-tenant-9")
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/pkg/server/config.go b/pkg/server/config.go
index 87c2690716fb..e28390da945f 100644
--- a/pkg/server/config.go
+++ b/pkg/server/config.go
@@ -230,7 +230,7 @@ type BaseConfig struct {
 
 	// WALFailover enables and configures automatic WAL failover when latency to
 	// a store's primary WAL increases.
-	WALFailover base.WALFailoverMode
+	WALFailover base.WALFailoverConfig
 
 	// SharedStorage is specified to enable disaggregated shared storage.
 	SharedStorage                    string
@@ -316,7 +316,7 @@ func (cfg *BaseConfig) SetDefaults(
 	cfg.DisableMaxOffsetCheck = false
 	cfg.DefaultZoneConfig = zonepb.DefaultZoneConfig()
 	cfg.StorageEngine = storage.DefaultStorageEngine
-	cfg.WALFailover = base.WALFailoverDefault
+	cfg.WALFailover = base.WALFailoverConfig{Mode: base.WALFailoverDefault}
 	cfg.TestingInsecureWebAccess = disableWebLogin
 	cfg.Stores = base.StoreSpecList{
 		Specs: []base.StoreSpec{storeSpec},
diff --git a/pkg/storage/fs/fs.go b/pkg/storage/fs/fs.go
index 25da97cc1b5d..38e21e2824c3 100644
--- a/pkg/storage/fs/fs.go
+++ b/pkg/storage/fs/fs.go
@@ -228,6 +228,9 @@ func (e *Env) IsReadOnly() bool {
 	return e.rw == ReadOnly
 }
 
+// RWMode returns the read-write mode of the environment.
+func (e *Env) RWMode() RWMode { return e.rw }
+
 // RegisterOnDiskSlow configures the Env to call the provided function when a
 // disk operation is slow.
 func (e *Env) RegisterOnDiskSlow(fn func(vfs.DiskSlowInfo)) {
diff --git a/pkg/storage/open.go b/pkg/storage/open.go
index e7f793909bb4..ed48cca7c10a 100644
--- a/pkg/storage/open.go
+++ b/pkg/storage/open.go
@@ -24,6 +24,7 @@ import (
 	"github.com/cockroachdb/cockroach/pkg/storage/fs"
 	"github.com/cockroachdb/errors"
 	"github.com/cockroachdb/pebble"
+	"github.com/cockroachdb/pebble/vfs"
 	"github.com/cockroachdb/pebble/wal"
 )
 
@@ -210,20 +211,129 @@ func LBaseMaxBytes(v int64) ConfigOption {
 	}
 }
 
+func noopConfigOption(*engineConfig) error {
+	return nil
+}
+
+func errConfigOption(err error) func(*engineConfig) error {
+	return func(*engineConfig) error { return err }
+}
+
+func makeExternalWALDir(engineCfg *engineConfig, externalDir base.ExternalPath) (wal.Dir, error) {
+	// If the store is encrypted, we require that all the WAL failover dirs also
+	// be encrypted so that the user doesn't accidentally leak data unencrypted
+	// onto the filesystem.
+	if engineCfg.Env.Encryption != nil && len(externalDir.EncryptionOptions) == 0 {
+		return wal.Dir{}, errors.Newf("must provide --enterprise-encryption flag for %q, used as WAL failover path for encrypted store %q",
+			externalDir.Path, engineCfg.Env.Dir)
+	}
+	if engineCfg.Env.Encryption == nil && len(externalDir.EncryptionOptions) != 0 {
+		return wal.Dir{}, errors.Newf("must provide --enterprise-encryption flag for store %q, specified WAL failover path %q is encrypted",
+			engineCfg.Env.Dir, externalDir.Path)
+	}
+	env, err := fs.InitEnv(context.Background(), vfs.Default, externalDir.Path, fs.EnvConfig{
+		RW:                engineCfg.Env.RWMode(),
+		EncryptionOptions: externalDir.EncryptionOptions,
+	})
+	if err != nil {
+		return wal.Dir{}, err
+	}
+	engineCfg.onClose = append(engineCfg.onClose, func(*Pebble) { env.Close() })
+	return wal.Dir{
+		FS:      env,
+		Dirname: externalDir.Path,
+	}, nil
+}
+
 // WALFailover configures automatic failover of the engine's write-ahead log to
 // another volume in the event the WAL becomes blocked on a write that does not
 // complete within a reasonable duration.
-func WALFailover(mode base.WALFailoverMode, storeEnvs fs.Envs) ConfigOption {
-	// If the user specified no WAL failover setting, we default to disabling WAL
-	// failover and assume that the previous process did not have WAL failover
-	// enabled (so there's no need to populate Options.WALRecoveryDirs). If an
-	// operator had WAL failover enabled and now wants to disable it, they must
-	// explicitly set --wal-failover=disabled for the next process.
-	if mode == base.WALFailoverDefault || len(storeEnvs) == 1 {
-		return func(cfg *engineConfig) error { return nil }
+func WALFailover(walCfg base.WALFailoverConfig, storeEnvs fs.Envs) ConfigOption {
+	// The set of options available in single-store versus multi-store
+	// configurations vary. This is in part due to the need to store the multiple
+	// stores' WALs separately. When WALFailoverExplicitPath is provided, we have
+	// no stable store identifier available to disambiguate the WALs of multiple
+	// stores. Note that the store ID is not known when a store is first opened.
+	if len(storeEnvs) == 1 {
+		switch walCfg.Mode {
+		case base.WALFailoverDefault, base.WALFailoverAmongStores:
+			return noopConfigOption
+		case base.WALFailoverDisabled:
+			// Check if the user provided an explicit previous path. If they did, they
+			// were previously using WALFailoverExplicitPath and are now disabling it.
+			// We need to add the explicilt path to WALRecoveryDirs.
+			if walCfg.PrevPath.IsSet() {
+				return func(cfg *engineConfig) error {
+					walDir, err := makeExternalWALDir(cfg, walCfg.PrevPath)
+					if err != nil {
+						return err
+					}
+					cfg.Opts.WALRecoveryDirs = append(cfg.Opts.WALRecoveryDirs, walDir)
+					return nil
+				}
+			}
+			// No PrevPath was provided. The user may be simply expressing their
+			// intent to not run with WAL failover, regardless of any future default
+			// values. If WAL failover was previously enabled, Open will error when it
+			// notices the OPTIONS file encodes a WAL failover secondary that was not
+			// provided to Options.WALRecoveryDirs.
+			return noopConfigOption
+		case base.WALFailoverExplicitPath:
+			// The user has provided an explicit path to which we should fail over WALs.
+			return func(cfg *engineConfig) error {
+				walDir, err := makeExternalWALDir(cfg, walCfg.Path)
+				if err != nil {
+					return err
+				}
+				cfg.Opts.WALFailover = makePebbleWALFailoverOptsForDir(cfg.Settings, walDir)
+				if walCfg.PrevPath.IsSet() {
+					walDir, err := makeExternalWALDir(cfg, walCfg.PrevPath)
+					if err != nil {
+						return err
+					}
+					cfg.Opts.WALRecoveryDirs = append(cfg.Opts.WALRecoveryDirs, walDir)
+				}
+				return nil
+			}
+		default:
+			panic("unreachable")
+		}
+	}
+
+	switch walCfg.Mode {
+	case base.WALFailoverDefault:
+		// If the user specified no WAL failover setting, we default to disabling WAL
+		// failover and assume that the previous process did not have WAL failover
+		// enabled (so there's no need to populate Options.WALRecoveryDirs). If an
+		// operator had WAL failover enabled and now wants to disable it, they must
+		// explicitly set --wal-failover=disabled for the next process.
+		return noopConfigOption
+	case base.WALFailoverDisabled:
+		// Check if the user provided an explicit previous path; that's unsupported
+		// in multi-store configurations.
+		if walCfg.PrevPath.IsSet() {
+			return errConfigOption(errors.Newf("storage: cannot use explicit prev_path --wal-failover option with multiple stores"))
+		}
+		// No PrevPath was provided, implying that the user previously was using
+		// WALFailoverAmongStores.
+
+		// Fallthrough
+	case base.WALFailoverExplicitPath:
+		// Not supported for multi-store configurations.
+		return errConfigOption(errors.Newf("storage: cannot use explicit path --wal-failover option with multiple stores"))
+	case base.WALFailoverAmongStores:
+		// Fallthrough
+	default:
+		panic("unreachable")
 	}
-	// mode == WALFailoverDisabled or WALFailoverAmongStores.
 
+	// Either
+	// 1. mode == WALFailoverAmongStores
+	//   or
+	// 2. mode == WALFailoverDisabled and the user previously was using
+	//    WALFailoverAmongStores, so we should build the deterministic store pairing
+	//    to determine which WALRecoveryDirs to pass to which engines.
+	//
 	// For each store, we need to determine which store is its secondary for the
 	// purpose of WALs. Even if failover is disabled, it's possible that it wasn't
 	// when the previous process ran, and the secondary's wal dir may have WALs
@@ -274,27 +384,8 @@ func WALFailover(mode base.WALFailoverMode, storeEnvs fs.Envs) ConfigOption {
 			// Use auxiliary/wals-among-stores within the other stores directory.
 			Dirname: secondaryEnv.PathJoin(secondaryEnv.Dir, base.AuxiliaryDir, "wals-among-stores"),
 		}
-
-		if mode == base.WALFailoverAmongStores {
-			cfg.Opts.WALFailover = &pebble.WALFailoverOptions{
-				Secondary: secondary,
-				FailoverOptions: wal.FailoverOptions{
-					// Leave most the options to their defaults, but
-					// UnhealthyOperationLatencyThreshold should be pulled from the
-					// cluster setting.
-					UnhealthyOperationLatencyThreshold: func() (time.Duration, bool) {
-						// WAL failover requires 24.1 to be finalized first. Otherwise, we might
-						// write WALs to a secondary, downgrade to a previous version's binary and
-						// blindly miss WALs. The second return value indicates whether the
-						// WAL manager is allowed to failover to the secondary.
-						//
-						// NB: We do not use settings.Version.IsActive because we do not have a
-						// guarantee that the cluster version has been initialized.
-						failoverOK := cfg.Settings.Version.ActiveVersionOrEmpty(context.TODO()).IsActive(clusterversion.V24_1Start)
-						return walFailoverUnhealthyOpThreshold.Get(&cfg.Settings.SV), failoverOK
-					},
-				},
-			}
+		if walCfg.Mode == base.WALFailoverAmongStores {
+			cfg.Opts.WALFailover = makePebbleWALFailoverOptsForDir(cfg.Settings, secondary)
 			return nil
 		}
 		// mode == WALFailoverDisabled
@@ -303,6 +394,30 @@ func WALFailover(mode base.WALFailoverMode, storeEnvs fs.Envs) ConfigOption {
 	}
 }
 
+func makePebbleWALFailoverOptsForDir(
+	settings *cluster.Settings, dir wal.Dir,
+) *pebble.WALFailoverOptions {
+	return &pebble.WALFailoverOptions{
+		Secondary: dir,
+		FailoverOptions: wal.FailoverOptions{
+			// Leave most the options to their defaults, but
+			// UnhealthyOperationLatencyThreshold should be pulled from the
+			// cluster setting.
+			UnhealthyOperationLatencyThreshold: func() (time.Duration, bool) {
+				// WAL failover requires 24.1 to be finalized first. Otherwise, we might
+				// write WALs to a secondary, downgrade to a previous version's binary and
+				// blindly miss WALs. The second return value indicates whether the
+				// WAL manager is allowed to failover to the secondary.
+				//
+				// NB: We do not use settings.Version.IsActive because we do not have a
+				// guarantee that the cluster version has been initialized.
+				failoverOK := settings.Version.ActiveVersionOrEmpty(context.TODO()).IsActive(clusterversion.V24_1Start)
+				return walFailoverUnhealthyOpThreshold.Get(&settings.SV), failoverOK
+			},
+		},
+	}
+}
+
 // PebbleOptions contains Pebble-specific options in the same format as a
 // Pebble OPTIONS file. For example:
 // [Options]