diff --git a/Makefile b/Makefile
index 898aa81232..c8681ddec7 100644
--- a/Makefile
+++ b/Makefile
@@ -121,19 +121,8 @@ $(call set-vpath, $(SRC_PATH))
 
 LIBS+=-lz $(LIBS_TOOLS)
 
-# Sphinx does not allow building manuals into the same directory as
-# the source files, so if we're doing an in-tree QEMU build we must
-# build the manuals into a subdirectory (and then install them from
-# there for 'make install'). For an out-of-tree build we can just
-# use the docs/ subdirectory in the build tree as normal.
-ifeq ($(realpath $(SRC_PATH)),$(realpath .))
-MANUAL_BUILDDIR := docs/built
-else
-MANUAL_BUILDDIR := docs
-endif
-
 ifdef BUILD_DOCS
-DOCS=qemu-doc.html qemu-doc.txt qemu.1 qemu-img.1 qemu-nbd.8 $(MANUAL_BUILDDIR)/interop/qemu-ga.8
+DOCS=qemu-doc.html qemu-doc.txt qemu.1 qemu-img.1 qemu-nbd.8
 DOCS+=docs/interop/qemu-qmp-ref.html docs/interop/qemu-qmp-ref.txt docs/interop/qemu-qmp-ref.7
 DOCS+=docs/interop/qemu-ga-ref.html docs/interop/qemu-ga-ref.txt docs/interop/qemu-ga-ref.7
 DOCS+=docs/qemu-block-drivers.7
@@ -231,11 +220,6 @@ dist: qemu-$(VERSION).tar.bz2
 qemu-%.tar.bz2:
 	$(SRC_PATH)/scripts/make-release "$(SRC_PATH)" "$(patsubst qemu-%.tar.bz2,%,$@)"
 
-define clean-manual =
-rm -rf $(MANUAL_BUILDDIR)/$1/_static
-rm -f $(MANUAL_BUILDDIR)/$1/objects.inv $(MANUAL_BUILDDIR)/$1/searchindex.js $(MANUAL_BUILDDIR)/$1/*.html
-endef
-
 distclean: clean
 	rm -f config-host.mak config-host.h* config-host.ld $(DOCS) qemu-options.texi qemu-img-cmds.texi qemu-monitor.texi qemu-monitor-info.texi
 	rm -f tests/tcg/config-*.mak
@@ -257,10 +241,6 @@ distclean: clean
 	rm -f docs/interop/qemu-qmp-ref.html docs/interop/qemu-ga-ref.html
 	rm -f docs/qemu-block-drivers.7
 	rm -f docs/qemu-cpu-models.7
-	rm -rf .doctrees
-	$(call clean-manual,devel)
-	$(call clean-manual,interop)
-	$(call clean-manual,specs)
 	rm -Rf .sdk
 	if test -f dtc/version_gen.h; then $(MAKE) $(DTC_MAKE_ARGS) clean; fi
 
@@ -300,22 +280,7 @@ BLOBS=
 DESCS=
 endif
 
-# Note that we manually filter-out the non-Sphinx documentation which
-# is currently built into the docs/interop directory in the build tree,
-# and also any sphinx-built manpages.
-define install-manual =
-for d in $$(cd $(MANUAL_BUILDDIR) && find $1 -type d); do $(INSTALL_DIR) "$(DESTDIR)$(qemu_docdir)/$$d"; done
-for f in $$(cd $(MANUAL_BUILDDIR) && find $1 -type f -a '!' '(' -name '*.[0-9]' -o -name 'qemu-*-qapi.*' -o -name 'qemu-*-ref.*' ')' ); do $(INSTALL_DATA) "$(MANUAL_BUILDDIR)/$$f" "$(DESTDIR)$(qemu_docdir)/$$f"; done
-endef
-
-# Note that we deliberately do not install the "devel" manual: it is
-# for QEMU developers, and not interesting to our users.
-.PHONY: install-sphinxdocs
-install-sphinxdocs: sphinxdocs
-	$(call install-manual,interop)
-	$(call install-manual,specs)
-
-install-doc: $(DOCS) install-sphinxdocs
+install-doc: $(DOCS)
 	$(INSTALL_DIR) "$(DESTDIR)$(qemu_docdir)"
 	$(INSTALL_DATA) qemu-doc.html "$(DESTDIR)$(qemu_docdir)"
 	$(INSTALL_DATA) qemu-doc.txt "$(DESTDIR)$(qemu_docdir)"
@@ -337,7 +302,6 @@ ifdef CONFIG_TRACE_SYSTEMTAP
 	$(INSTALL_DATA) scripts/qemu-trace-stap.1 "$(DESTDIR)$(mandir)/man1"
 endif
 ifeq ($(CONFIG_GUEST_AGENT),y)
-	$(INSTALL_DATA) $(MANUAL_BUILDDIR)/interop/qemu-ga.8 "$(DESTDIR)$(mandir)/man8"
 	$(INSTALL_DATA) docs/interop/qemu-ga-ref.html "$(DESTDIR)$(qemu_docdir)"
 	$(INSTALL_DATA) docs/interop/qemu-ga-ref.txt "$(DESTDIR)$(qemu_docdir)"
 	$(INSTALL_DATA) docs/interop/qemu-ga-ref.7 "$(DESTDIR)$(mandir)/man7"
@@ -435,33 +399,6 @@ docs/version.texi: $(SRC_PATH)/VERSION config-host.mak
 %.pdf: %.texi docs/version.texi
 	$(call quiet-command,texi2pdf $(TEXI2PDFFLAGS) $< -o $@,"GEN","$@")
 
-# Sphinx builds all its documentation at once in one invocation
-# and handles "don't rebuild things unless necessary" itself.
-# The '.doctrees' files are cached information to speed this up.
-.PHONY: sphinxdocs
-sphinxdocs: $(MANUAL_BUILDDIR)/devel/index.html $(MANUAL_BUILDDIR)/interop/index.html $(MANUAL_BUILDDIR)/specs/index.html
-
-# Canned command to build a single manual
-# Arguments: $1 = manual name, $2 = Sphinx builder ('html' or 'man')
-# Note the use of different doctree for each (manual, builder) tuple;
-# this works around Sphinx not handling parallel invocation on
-# a single doctree: https://github.com/sphinx-doc/sphinx/issues/2946
-build-manual = $(call quiet-command,CONFDIR="$(qemu_confdir)" sphinx-build $(if $(V),,-q) -W -b $2 -D version=$(VERSION) -D release="$(FULL_VERSION)" -d .doctrees/$1-$2 $(SRC_PATH)/docs/$1 $(MANUAL_BUILDDIR)/$1 ,"SPHINX","$(MANUAL_BUILDDIR)/$1")
-# We assume all RST files in the manual's directory are used in it
-manual-deps = $(wildcard $(SRC_PATH)/docs/$1/*.rst) $(SRC_PATH)/docs/$1/conf.py $(SRC_PATH)/docs/conf.py
-
-$(MANUAL_BUILDDIR)/devel/index.html: $(call manual-deps,devel)
-	$(call build-manual,devel,html)
-
-$(MANUAL_BUILDDIR)/interop/index.html: $(call manual-deps,interop)
-	$(call build-manual,interop,html)
-
-$(MANUAL_BUILDDIR)/specs/index.html: $(call manual-deps,specs)
-	$(call build-manual,specs,html)
-
-$(MANUAL_BUILDDIR)/interop/qemu-ga.8: $(call manual-deps,interop)
-	$(call build-manual,interop,man)
-
 docs/interop/qemu-qmp-qapi.texi: qapi/qapi-doc.texi
 	@cp -p $< $@
 
diff --git a/docs/meson.build b/docs/meson.build
new file mode 100644
index 0000000000..0f86c0b522
--- /dev/null
+++ b/docs/meson.build
@@ -0,0 +1,88 @@
+sphinx = find_program('sphinx-build', required: build_docs)
+
+SPHINX_ARGS = [sphinx, '-W', '-q', '-d', '@PRIVATE_DIR@',
+               '-Dversion=' + config_host['VERSION'],
+               '-Drelease=' + config_host['PKGVERSION']]
+
+if sphinx.found()
+  devel_rst = [
+    'devel/decodetree.rst',
+    'devel/index.rst',
+    'devel/kconfig.rst',
+    'devel/loads-stores.rst',
+    'devel/memory.rst',
+    'devel/migration.rst',
+    'devel/secure-coding-practices.rst',
+    'devel/stable-process.rst',
+    'devel/tcg.rst',
+    'devel/testing.rst',
+  ]
+  devel = custom_target('sphinx-devel',
+                build_by_default: build_docs,
+                input: devel_rst,
+                depend_files: [files('conf.py', 'devel/conf.py')],
+                output: 'devel',
+                command: [SPHINX_ARGS, '-b', 'html',
+                          meson.current_source_dir() / 'devel',
+                          '@OUTPUT@',
+                         ])
+
+  interop_rst = [
+    'interop/bitmaps.rst',
+    'interop/index.rst',
+    'interop/live-block-operations.rst',
+    'interop/qemu-ga.rst',
+    'interop/pr-helper.rst',
+    'interop/vhost-user-gpu.rst',
+    'interop/vhost-user.rst',
+  ]
+  interop_man_pages = []
+  if 'CONFIG_GUEST_AGENT' in config_host
+    interop_man_pages += ['qemu-ga.8']
+  endif
+  interop = custom_target('sphinx-interop',
+                build_by_default: build_docs,
+                input: interop_rst,
+                depend_files: [files('conf.py', 'interop/conf.py')],
+                output: 'interop',
+                command: [SPHINX_ARGS, '-b', 'html',
+                          meson.current_source_dir() / 'interop',
+                          '@OUTPUT@',
+                         ])
+  interop_man = custom_target('sphinx-interop-man',
+                   build_by_default: build_docs,
+                   input: interop_rst,
+                   depend_files: [files('conf.py', 'interop/conf.py')],
+                   output: interop_man_pages,
+                   install_dir: get_option('mandir') / 'man8',
+                   command: [SPHINX_ARGS, '-b', 'man',
+                             meson.current_source_dir() / 'interop',
+                             '@OUTPUT@',
+                            ])
+  if build_docs
+    install_subdir(meson.current_build_dir() / 'interop',
+                   install_dir: config_host['qemu_docdir'],
+                   exclude_directories: '.doctrees')
+  endif
+
+  specs_rst = [
+    'specs/index.rst',
+    'specs/ppc-spapr-xive.rst',
+    'specs/ppc-xive.rst',
+  ]
+  specs = custom_target('sphinx-specs',
+                input: specs_rst,
+                depend_files: [files('conf.py', 'specs/conf.py')],
+                output: 'specs',
+                command: [SPHINX_ARGS, '-b', 'html',
+                          meson.current_source_dir() / 'specs',
+                          '@OUTPUT@',
+                         ])
+  if build_docs
+    install_subdir(meson.current_build_dir() / 'specs',
+                   install_dir: config_host['qemu_docdir'],
+                   exclude_directories: '.doctrees')
+  endif
+
+  sphinxdocs = alias_target('sphinxdocs', devel, interop, interop_man, specs)
+endif
diff --git a/meson.build b/meson.build
index 0dd6d9f723..04068fd765 100644
--- a/meson.build
+++ b/meson.build
@@ -7,6 +7,7 @@ config_host = kconfig.load(meson.current_build_dir() / 'config-host.mak')
 config_all_disas = kconfig.load(meson.current_build_dir() / 'config-all-disas.mak')
 
 enable_modules = 'CONFIG_MODULES' in config_host
+build_docs = 'BUILD_DOCS' in config_host
 
 add_project_arguments(config_host['CFLAGS'].split(),
                       language: ['c', 'objc'])
@@ -949,3 +950,4 @@ if have_tools
 endif
 
 subdir('pc-bios')
+subdir('docs')
