diff --git a/src/box/lua/ctl.c b/src/box/lua/ctl.c
index 7010be1387d0b4707d9a0afd643041113628a164..85ed30c505ff59424c76870abe97ecbf463782a3 100644
--- a/src/box/lua/ctl.c
+++ b/src/box/lua/ctl.c
@@ -40,6 +40,7 @@
 #include "lua/trigger.h"
 
 #include "box/box.h"
+#include "box/schema.h"
 
 static int
 lbox_ctl_wait_ro(struct lua_State *L)
@@ -71,10 +72,17 @@ lbox_ctl_on_shutdown(struct lua_State *L)
 	return lbox_trigger_reset(L, 2, &box_on_shutdown, NULL, NULL);
 }
 
+static int
+lbox_ctl_on_schema_init(struct lua_State *L)
+{
+	return lbox_trigger_reset(L, 2, &on_schema_init, NULL, NULL);
+}
+
 static const struct luaL_Reg lbox_ctl_lib[] = {
 	{"wait_ro", lbox_ctl_wait_ro},
 	{"wait_rw", lbox_ctl_wait_rw},
 	{"on_shutdown", lbox_ctl_on_shutdown},
+	{"on_schema_init", lbox_ctl_on_schema_init},
 	{NULL, NULL}
 };
 
diff --git a/src/box/schema.cc b/src/box/schema.cc
index 8625d92eac82f7778700e814c438810eba3c601b..74d70d8d607550cce9aa8facab5da8465071a7d1 100644
--- a/src/box/schema.cc
+++ b/src/box/schema.cc
@@ -69,6 +69,7 @@ uint32_t schema_version = 0;
  */
 uint32_t space_cache_version = 0;
 
+struct rlist on_schema_init = RLIST_HEAD_INITIALIZER(on_schema_init);
 struct rlist on_alter_space = RLIST_HEAD_INITIALIZER(on_alter_space);
 struct rlist on_alter_sequence = RLIST_HEAD_INITIALIZER(on_alter_sequence);
 
@@ -500,6 +501,12 @@ schema_init()
 		init_system_space(space);
 		trigger_run_xc(&on_alter_space, space);
 	}
+
+	/*
+	 * Run the triggers right after creating all the system
+	 * space stubs.
+	 */
+	trigger_run(&on_schema_init, NULL);
 }
 
 void
diff --git a/src/box/schema.h b/src/box/schema.h
index f3df08b48bc2fee9529497c8f483cc79e0fb44da..6f9a96117e1baf31dd2a5c70f76e08e017c48bba 100644
--- a/src/box/schema.h
+++ b/src/box/schema.h
@@ -44,6 +44,9 @@ extern "C" {
 extern uint32_t schema_version;
 extern uint32_t space_cache_version;
 
+/** Triggers invoked after schema initialization. */
+extern struct rlist on_schema_init;
+
 /**
  * Lock of schema modification
  */
diff --git a/test/box-tap/on_schema_init.test.lua b/test/box-tap/on_schema_init.test.lua
new file mode 100755
index 0000000000000000000000000000000000000000..51b28ea083bb2c759d6dba51402d1501fad76fde
--- /dev/null
+++ b/test/box-tap/on_schema_init.test.lua
@@ -0,0 +1,31 @@
+#!/usr/bin/env tarantool
+
+--
+-- gh-3159: test on_schema_init trigger
+--
+local tap = require('tap')
+local test = tap.test('on_schema_init')
+local str = ''
+test:plan(7)
+
+function testing_trig()
+    test:istable(box.space._space, 'system spaces are accessible')
+    test:is(type(box.space._space.before_replace), 'function', 'before_replace triggers')
+    test:is(type(box.space._space.on_replace), 'function', 'on_replace triggers')
+    test:is(type(box.space._space:on_replace(function() str = str.."_space:on_replace" end)),
+            'function', 'set on_replace trigger')
+    str = str..'on_schema_init'
+end
+
+trig = box.ctl.on_schema_init(testing_trig)
+test:is(type(trig), 'function', 'on_schema_init trigger set')
+
+box.cfg{log = 'tarantool.log'}
+test:like(str, 'on_schema_init', 'on_schema_init trigger works')
+str = ''
+box.schema.space.create("test")
+-- test that _space.on_replace trigger may be set in on_schema_init
+test:like(str, '_space:on_replace', 'can set on_replace')
+test:check()
+box.space.test:drop()
+os.exit(0)
diff --git a/test/replication/on_schema_init.result b/test/replication/on_schema_init.result
new file mode 100644
index 0000000000000000000000000000000000000000..3f7ee0bd0de77ee94ab91cb42d72fe7ece52ff38
--- /dev/null
+++ b/test/replication/on_schema_init.result
@@ -0,0 +1,119 @@
+env = require('test_run')
+---
+...
+test_run = env.new()
+---
+...
+-- gh-3159: on_schema_init triggers
+-- the replica has set an on_schema_init trigger, which will set
+-- _space:before_replace triggers to change 'test_engine' space engine
+-- and 'test_local' space is_local flag when replication starts.
+test_run:cmd('create server replica with rpl_master=default, script="replication/replica_on_schema_init.lua"')
+---
+- true
+...
+test_engine = box.schema.space.create('test_engine', {engine='memtx'})
+---
+...
+test_local =  box.schema.space.create('test_local', {is_local=false})
+---
+...
+test_engine.engine
+---
+- memtx
+...
+test_local.is_local
+---
+- false
+...
+_ = test_engine:create_index("pk")
+---
+...
+_ = test_local:create_index("pk")
+---
+...
+test_engine:insert{1}
+---
+- [1]
+...
+test_local:insert{2}
+---
+- [2]
+...
+box.schema.user.grant('guest', 'replication')
+---
+...
+test_run:cmd('start server replica')
+---
+- true
+...
+test_run:cmd('switch replica')
+---
+- true
+...
+box.space.test_engine.engine
+---
+- vinyl
+...
+box.space.test_local.is_local
+---
+- true
+...
+box.space.test_engine:insert{3}
+---
+- [3]
+...
+box.space.test_local:insert{4}
+---
+- [4]
+...
+box.space.test_engine:select{}
+---
+- - [1]
+  - [3]
+...
+box.space.test_local:select{}
+---
+- - [2]
+  - [4]
+...
+test_run:cmd('switch default')
+---
+- true
+...
+test_run:cmd('set variable replica_port to "replica.listen"')
+---
+- true
+...
+box.cfg{replication=replica_port}
+---
+...
+test_engine:select{}
+---
+- - [1]
+  - [3]
+...
+-- the space truly became local on replica
+test_local:select{}
+---
+- - [2]
+...
+box.cfg{replication=nil}
+---
+...
+test_run:cmd('stop server replica with cleanup=1')
+---
+- true
+...
+test_run:cleanup_cluster()
+---
+...
+box.schema.user.revoke('guest', 'replication')
+---
+...
+test_engine:drop()
+---
+...
+test_local:drop()
+---
+...
diff --git a/test/replication/on_schema_init.test.lua b/test/replication/on_schema_init.test.lua
new file mode 100644
index 0000000000000000000000000000000000000000..9bb9e477539efeaafa9826a43dc0243169a55579
--- /dev/null
+++ b/test/replication/on_schema_init.test.lua
@@ -0,0 +1,49 @@
+env = require('test_run')
+test_run = env.new()
+
+-- gh-3159: on_schema_init triggers
+
+-- the replica has set an on_schema_init trigger, which will set
+-- _space:before_replace triggers to change 'test_engine' space engine
+-- and 'test_local' space is_local flag when replication starts.
+test_run:cmd('create server replica with rpl_master=default, script="replication/replica_on_schema_init.lua"')
+
+test_engine = box.schema.space.create('test_engine', {engine='memtx'})
+test_local =  box.schema.space.create('test_local', {is_local=false})
+test_engine.engine
+test_local.is_local
+
+_ = test_engine:create_index("pk")
+_ = test_local:create_index("pk")
+
+test_engine:insert{1}
+test_local:insert{2}
+
+box.schema.user.grant('guest', 'replication')
+
+test_run:cmd('start server replica')
+test_run:cmd('switch replica')
+
+box.space.test_engine.engine
+box.space.test_local.is_local
+
+box.space.test_engine:insert{3}
+box.space.test_local:insert{4}
+
+box.space.test_engine:select{}
+box.space.test_local:select{}
+
+test_run:cmd('switch default')
+
+test_run:cmd('set variable replica_port to "replica.listen"')
+box.cfg{replication=replica_port}
+test_engine:select{}
+-- the space truly became local on replica
+test_local:select{}
+
+box.cfg{replication=nil}
+test_run:cmd('stop server replica with cleanup=1')
+test_run:cleanup_cluster()
+box.schema.user.revoke('guest', 'replication')
+test_engine:drop()
+test_local:drop()
diff --git a/test/replication/replica_on_schema_init.lua b/test/replication/replica_on_schema_init.lua
new file mode 100644
index 0000000000000000000000000000000000000000..8a221681bda4ae58e11211e81d7ca7c4cfdac761
--- /dev/null
+++ b/test/replication/replica_on_schema_init.lua
@@ -0,0 +1,25 @@
+#!/usr/bin/env tarantool
+
+function trig_local(old, new)
+    if new and new[3] == 'test_local' and new[6]['group_id'] ~= 1 then
+        return new:update{{'=', 6, {group_id = 1}}}
+    end
+end
+
+function trig_engine(old, new)
+    if new and new[3] == 'test_engine' and new[4] ~= 'vinyl' then
+        return new:update{{'=', 4, 'vinyl'}}
+    end
+end
+
+box.ctl.on_schema_init(function()
+    box.space._space:before_replace(trig_local)
+    box.space._space:before_replace(trig_engine)
+end)
+
+box.cfg({
+    listen              = os.getenv("LISTEN"),
+    replication         = os.getenv("MASTER"),
+})
+
+require('console').listen(os.getenv('ADMIN'))
diff --git a/test/replication/suite.cfg b/test/replication/suite.cfg
index fc7c0c4646fdbcac71e8b1d7c4f3d882f604b8e4..5e880973111a35b27bf5c87804539f1c9fdeec5c 100644
--- a/test/replication/suite.cfg
+++ b/test/replication/suite.cfg
@@ -8,6 +8,7 @@
     "rebootstrap.test.lua": {},
     "wal_rw_stress.test.lua": {},
     "force_recovery.test.lua": {},
+    "on_schema_init.test.lua": {},
     "*": {
         "memtx": {"engine": "memtx"},
         "vinyl": {"engine": "vinyl"}