diff --git a/.gitignore b/.gitignore
index 01aa6797310ec64282f8ce03d916388ee164394f..122a9b2373066d40b4dcf6809e9d72b5b28bc51a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,6 +74,7 @@ src/libknot/libknot.h
 /src/knotd
 /src/knsec3hash
 /src/knsupdate
+/src/ksignzone
 /src/kzonecheck
 /src/kxdpgun
 
diff --git a/Knot.files b/Knot.files
index 2d36c61b0a315ceda2c875416535b997801de181..49406af3813f6100265249d0d2d911214b867eef 100644
--- a/Knot.files
+++ b/Knot.files
@@ -491,6 +491,7 @@ src/utils/knsupdate/knsupdate_exec.h
 src/utils/knsupdate/knsupdate_main.c
 src/utils/knsupdate/knsupdate_params.c
 src/utils/knsupdate/knsupdate_params.h
+src/utils/ksignzone/main.c
 src/utils/kzonecheck/main.c
 src/utils/kzonecheck/zone_check.c
 src/utils/kzonecheck/zone_check.h
diff --git a/distro/deb/knot.install b/distro/deb/knot.install
index b49ae1c58e0295f91918dcd0a901fa6946a31038..151f63aaf8d0cb9a536c9c52a4e89a738dbd9014 100644
--- a/distro/deb/knot.install
+++ b/distro/deb/knot.install
@@ -7,6 +7,7 @@ usr/sbin/keymgr
 usr/sbin/kjournalprint
 usr/sbin/knotc
 usr/sbin/knotd
+usr/sbin/ksignzone
 usr/share/man/man1/knsec3hash.1
 usr/share/man/man1/kzonecheck.1
 usr/share/man/man5/knot.conf.5
@@ -15,3 +16,4 @@ usr/share/man/man8/keymgr.8
 usr/share/man/man8/kjournalprint.8
 usr/share/man/man8/knotc.8
 usr/share/man/man8/knotd.8
+usr/share/man/man8/ksignzone.8
diff --git a/doc/.gitignore b/doc/.gitignore
index 849671a79db2f233d1ccf19e5d18df6450dceb0a..cbd3bad569807dc4ee7035ff81ef92c48d7eda22 100644
--- a/doc/.gitignore
+++ b/doc/.gitignore
@@ -15,5 +15,6 @@
 /man/knot1to2.1
 /man/knsec3hash.1
 /man/knsupdate.1
+/man/ksignzone.8
 /man/kzonecheck.1
 /man/kxdpgun.8
diff --git a/doc/Makefile.am b/doc/Makefile.am
index f66fa553b7b5cbdb1a2cd70df44bb24440736c8d..bdd7eee3bb6d317896739d5fffe75fadb65dc941 100644
--- a/doc/Makefile.am
+++ b/doc/Makefile.am
@@ -9,6 +9,7 @@ MANPAGES_IN = \
 	man/khost.1in		\
 	man/knsupdate.1in	\
 	man/knsec3hash.1in	\
+	man/ksignzone.8in	\
 	man/kzonecheck.1in	\
 	man/kxdpgun.8in
 
@@ -22,6 +23,7 @@ MANPAGES_RST = \
 	man_khost.rst		\
 	man_knsupdate.rst	\
 	man_knsec3hash.rst	\
+	man_ksignzone.rst	\
 	man_kzonecheck.rst	\
 	man_kxdpgun.rst
 
@@ -97,6 +99,7 @@ man_MANS += \
 	man/kcatalogprint.8	\
 	man/keymgr.8		\
 	man/kjournalprint.8	\
+	man/ksignzone.8		\
 	man/kzonecheck.1
 endif # HAVE_DAEMON
 
@@ -121,6 +124,7 @@ man/kdig.1:		man/kdig.1in
 man/khost.1:		man/khost.1in
 man/knsupdate.1:	man/knsupdate.1in
 man/knsec3hash.1:	man/knsec3hash.1in
+man/ksignzone.8:	man/ksignzone.8in
 man/kzonecheck.1:	man/kzonecheck.1in
 man/kxdpgun.8:		man/kxdpgun.8in
 
diff --git a/doc/conf.py b/doc/conf.py
index e0c2d9d216c036082139be48c9fa2ea78c0de2ce..b83be0a222abfe884123e068f884909d2ee34f7e 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -233,6 +233,7 @@ man_pages = [
     ('man_khost',         'khost',         'Simple DNS lookup utility',                 author, 1),
     ('man_knsec3hash',    'knsec3hash',    'Simple utility to compute NSEC3 hash',      author, 1),
     ('man_knsupdate',     'knsupdate',     'Dynamic DNS update utility',                author, 1),
+    ('man_ksignzone',     'ksignzone',     'DNSSEC signing utility',                    author, 8),
     ('man_kzonecheck',    'kzonecheck',    'Knot DNS zone check tool',                  author, 1),
     ('man_kxdpgun',       'kxdpgun',       'XDP-powered DNS benchmarking tool',         author, 8),
 ]
diff --git a/doc/man/ksignzone.8in b/doc/man/ksignzone.8in
new file mode 100644
index 0000000000000000000000000000000000000000..d7a80c628f1984836f194e7b25317afdc296bcb1
--- /dev/null
+++ b/doc/man/ksignzone.8in
@@ -0,0 +1,77 @@
+.\" Man page generated from reStructuredText.
+.
+.TH "KSIGNZONE" "8" "@RELEASE_DATE@" "@VERSION@" "Knot DNS"
+.SH NAME
+ksignzone \- DNSSEC signing utility
+.
+.nr rst2man-indent-level 0
+.
+.de1 rstReportMargin
+\\$1 \\n[an-margin]
+level \\n[rst2man-indent-level]
+level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
+-
+\\n[rst2man-indent0]
+\\n[rst2man-indent1]
+\\n[rst2man-indent2]
+..
+.de1 INDENT
+.\" .rstReportMargin pre:
+. RS \\$1
+. nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin]
+. nr rst2man-indent-level +1
+.\" .rstReportMargin post:
+..
+.de UNINDENT
+. RE
+.\" indent \\n[an-margin]
+.\" old: \\n[rst2man-indent\\n[rst2man-indent-level]]
+.nr rst2man-indent-level -1
+.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
+.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
+..
+.SH SYNOPSIS
+.sp
+\fBksignzone\fP \fIoptions\fP \fIzone_name\fP
+.SH DESCRIPTION
+.sp
+This utility reads the zone\(aqs zone file, signs the zone according to given
+configuration file, and writes the signed zone file back.
+.SS Options
+.INDENT 0.0
+.TP
+\fB\-c\fP \fIconf\-file\fP
+Knot DNS configuration file (same as for knotd).
+\fIThis option is obligatory.\fP
+.TP
+\fB\-o\fP \fIoutdir\fP
+Write the output zone file to different directory than configured.
+.TP
+\fB\-R\fP
+Allow key roll\-overs nad NSEC3 re\-salt.
+.TP
+\fB\-h\fP, \fB\-\-help\fP
+Print the program help.
+.TP
+\fB\-V\fP, \fB\-\-version\fP
+Print the program version.
+.UNINDENT
+.SS Parameters
+.INDENT 0.0
+.TP
+\fIzone_name\fP
+A name of the zone to be signed.
+.UNINDENT
+.SH EXIT VALUES
+.sp
+Exit status of 0 means successful operation. Any other exit status indicates
+an error.
+.SH SEE ALSO
+.sp
+\fBknotd(8)\fP, \fBknot.conf(5)\fP\&.
+.SH AUTHOR
+CZ.NIC Labs <https://www.knot-dns.cz>
+.SH COPYRIGHT
+Copyright 2010–2020, CZ.NIC, z.s.p.o.
+.\" Generated by docutils manpage writer.
+.
diff --git a/doc/man_ksignzone.rst b/doc/man_ksignzone.rst
new file mode 100644
index 0000000000000000000000000000000000000000..a4635cea165a5d037c5862e61d70ef0404ec30cf
--- /dev/null
+++ b/doc/man_ksignzone.rst
@@ -0,0 +1,51 @@
+.. highlight:: console
+
+ksignzone – DNSSEC signing utility
+==================================
+
+Synopsis
+--------
+
+:program:`ksignzone` *options* *zone_name*
+
+Description
+-----------
+
+This utility reads the zone's zone file, signs the zone according to given
+configuration file, and writes the signed zone file back.
+
+Options
+.......
+
+**-c** *conf-file*
+  Knot DNS configuration file (same as for knotd).
+  *This option is obligatory.*
+
+**-o** *outdir*
+  Write the output zone file to different directory than configured.
+
+**-R**
+  Allow key roll-overs nad NSEC3 re-salt.
+
+**-h**, **--help**
+  Print the program help.
+
+**-V**, **--version**
+  Print the program version.
+
+Parameters
+..........
+
+*zone_name*
+  A name of the zone to be signed.
+
+Exit values
+-----------
+
+Exit status of 0 means successful operation. Any other exit status indicates
+an error.
+
+See Also
+--------
+
+:manpage:`knotd(8)`, :manpage:`knot.conf(5)`.
diff --git a/doc/utilities.rst b/doc/utilities.rst
index b797c19244942231e49076d36faab425e9f27261..4a104059b8b302973690bb812dd23b0cad7cda66 100644
--- a/doc/utilities.rst
+++ b/doc/utilities.rst
@@ -20,4 +20,5 @@ the server. This section collects manual pages for all provided binaries:
    man_khost
    man_knsec3hash
    man_knsupdate
+   man_ksignzone
    man_kxdpgun
diff --git a/src/utils/Makefile.inc b/src/utils/Makefile.inc
index eff5563ecaf1b298407cf9265a88dc206d62b51c..d632a2f546d5ed1e668f2f1423942a7a95842317 100644
--- a/src/utils/Makefile.inc
+++ b/src/utils/Makefile.inc
@@ -130,13 +130,16 @@ knotd_LDFLAGS          = $(AM_LDFLAGS) -rdynamic
 
 if HAVE_UTILS
 bin_PROGRAMS += kzonecheck
-sbin_PROGRAMS += keymgr kjournalprint kcatalogprint
+sbin_PROGRAMS += keymgr kjournalprint kcatalogprint ksignzone
 
 kzonecheck_SOURCES = \
 	utils/kzonecheck/main.c			\
 	utils/kzonecheck/zone_check.c		\
 	utils/kzonecheck/zone_check.h
 
+ksignzone_SOURCES = \
+	utils/ksignzone/main.c
+
 keymgr_SOURCES = \
 	utils/keymgr/bind_privkey.c		\
 	utils/keymgr/bind_privkey.h		\
@@ -154,6 +157,9 @@ kcatalogprint_SOURCES = \
 
 kzonecheck_CPPFLAGS    = $(AM_CPPFLAGS) $(lmdb_CFLAGS)
 kzonecheck_LDADD       = libcontrib.la libknotd.la
+ksignzone_CPPFLAGS     = $(AM_CPPFLAGS) $(gnutls_CFLAGS) $(lmdb_CFLAGS)
+ksignzone_LDADD        = libcontrib.la libknotd.la libknotus.la libdnssec.la \
+                         libzscanner.la
 keymgr_CPPFLAGS        = $(AM_CPPFLAGS) $(gnutls_CFLAGS) $(lmdb_CFLAGS)
 keymgr_LDADD           = libcontrib.la libknotd.la libknotus.la libdnssec.la \
                          libzscanner.la
diff --git a/src/utils/ksignzone/main.c b/src/utils/ksignzone/main.c
new file mode 100644
index 0000000000000000000000000000000000000000..b80039995fcde0718a33da0b54fab16c0a18d454
--- /dev/null
+++ b/src/utils/ksignzone/main.c
@@ -0,0 +1,198 @@
+/*  Copyright (C) 2020 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <getopt.h>
+#include <stdlib.h>
+
+#include "knot/conf/conf.h"
+#include "knot/updates/zone-update.h"
+#include "knot/zone/zone-load.h"
+#include "knot/zone/zonefile.h"
+#include "utils/common/params.h"
+
+#define PROGRAM_NAME "ksignzone"
+
+static const char *global_outdir = NULL;
+
+// copy-pasted from keymgr
+static bool init_conf(const char *confdb)
+{
+	size_t max_conf_size = (size_t)CONF_MAPSIZE * 1024 * 1024;
+
+	conf_flag_t flags = CONF_FNOHOSTNAME | CONF_FOPTMODULES;
+	if (confdb != NULL) {
+		flags |= CONF_FREADONLY;
+	}
+
+	conf_t *new_conf = NULL;
+	int ret = conf_new(&new_conf, conf_schema, confdb, max_conf_size, flags);
+	if (ret != KNOT_EOK) {
+		printf("Failed opening configuration database %s (%s)\n",
+		       (confdb == NULL ? "" : confdb), knot_strerror(ret));
+		return false;
+	}
+	conf_update(new_conf, CONF_UPD_FNONE);
+	return true;
+}
+
+static void print_help(void)
+{
+	printf("Usage: %s -c <knot_conf> [-R] [-o <outdir>] zone_name\n", PROGRAM_NAME);
+}
+
+int main(int argc, char *argv[])
+{
+	const char *confile = NULL, *zone_str = NULL;
+	knot_dname_t *zone_name = NULL;
+	zone_contents_t *unsigned_conts = NULL;
+	zone_t *zone_struct = NULL;
+	zone_update_t up = { 0 };
+	knot_lmdb_db_t kasp_db = { 0 };
+	zone_sign_roll_flags_t rollover = 0;
+	zone_sign_reschedule_t next_sign = { 0 };
+
+	int opt;
+	struct option opts[] = {
+			{ "help",     no_argument,       NULL, 'h' },
+			{ "version",  no_argument,       NULL, 'V' },
+	};
+	while ((opt = getopt_long(argc, argv, "Vhc:Ro:", opts, NULL)) != -1) {
+		switch (opt) {
+		case 'V':
+			print_version(PROGRAM_NAME);
+			return EXIT_SUCCESS;
+		case 'h':
+			print_help();
+			return EXIT_SUCCESS;
+		case 'c':
+			confile = optarg;
+			break;
+		case 'R':
+			rollover = KEY_ROLL_ALLOW_ALL;
+			break;
+		case 'o':
+			global_outdir = optarg;
+			break;
+		default:
+			print_help();
+			return EXIT_FAILURE;
+		}
+	}
+	if (confile == NULL || argc - optind != 1) {
+		print_help();
+		return EXIT_FAILURE;
+	}
+
+	zone_str = argv[optind];
+	zone_name = knot_dname_from_str_alloc(zone_str);
+	if (zone_name == NULL) {
+		printf("inproper zone name\n");
+		return EXIT_FAILURE;
+	}
+
+	if (!init_conf(NULL)) {
+		free(zone_name);
+		return EXIT_FAILURE;
+	}
+
+	int ret = conf_import(conf(), confile, true, false);
+	if (ret != KNOT_EOK) {
+		printf("Failed opening configuration file %s (%s)\n",
+		       confile, knot_strerror(ret));
+		goto fail;
+	}
+
+	conf_val_t val = conf_zone_get(conf(), C_DOMAIN, zone_name);
+	if (val.code != KNOT_EOK) {
+		printf("Zone not found in configuation (%s)\n", knot_strerror(val.code));
+		ret = val.code;
+		goto fail;
+	}
+	val = conf_zone_get(conf(), C_DNSSEC_POLICY, zone_name);
+	if (val.code != KNOT_EOK) {
+		printf("Waring: DNSSEC policy not configured for zone %s, taking defaults.\n", zone_str);
+	}
+
+	zone_struct = zone_new(zone_name);
+	if (zone_struct == NULL) {
+		printf("out of memory\n");
+		ret = KNOT_ENOMEM;
+		goto fail;
+	}
+
+	ret = zone_load_contents(conf(), zone_name, &unsigned_conts, false);
+	if (ret != KNOT_EOK) {
+		printf("Failed to load zone contents (%s)\n", knot_strerror(ret));
+		goto fail;
+	}
+
+	ret = zone_update_from_contents(&up, zone_struct, unsigned_conts, UPDATE_FULL);
+	if (ret != KNOT_EOK) {
+		printf("Failed to initialize zone update (%s)\n", knot_strerror(ret));
+		zone_contents_deep_free(unsigned_conts);
+		goto fail;
+	}
+
+	kasp_db_ensure_init(&kasp_db, conf());
+	zone_struct->kaspdb = &kasp_db;
+
+	ret = knot_dnssec_zone_sign(&up, 0, rollover, &next_sign);
+	if (ret == KNOT_DNSSEC_ENOKEY) { // exception: allow generating initial keys
+		rollover = KEY_ROLL_ALLOW_ALL;
+		ret = knot_dnssec_zone_sign(&up, 0, rollover, &next_sign);
+	}
+	if (ret != KNOT_EOK) {
+		printf("Failed to sign the zone (%s)\n", knot_strerror(ret));
+		zone_update_clear(&up);
+		goto fail;
+	}
+
+	if (global_outdir == NULL) {
+		char *zonefile = conf_zonefile(conf(), zone_name);
+		ret = zonefile_write(zonefile, up.new_cont);
+		free(zonefile);
+	} else {
+		zone_contents_t *temp = zone_struct->contents;
+		zone_struct->contents = up.new_cont;
+		ret = zone_dump_to_dir(conf(), zone_struct, global_outdir);
+		zone_struct->contents = temp;
+	}
+	zone_update_clear(&up);
+	if (ret != KNOT_EOK) {
+		printf("Failed to flush signed zone file (%s)\n", knot_strerror(ret));
+		goto fail;
+	}
+
+	printf("Next sign: %lu\n", next_sign.next_sign);
+	if (rollover) {
+		printf("Next roll-over: %lu\n", next_sign.next_rollover);
+		if (next_sign.next_nsec3resalt) {
+			printf("Next NSEC3 re-sign: %lu\n", next_sign.next_nsec3resalt);
+		}
+		if (next_sign.plan_ds_check) {
+			printf("Submit keys to parent zone.\n");
+		}
+	}
+
+fail:
+	if (kasp_db.path != NULL) {
+		knot_lmdb_deinit(&kasp_db);
+	}
+	zone_free(&zone_struct);
+	conf_free(conf());
+	free(zone_name);
+	return ret == KNOT_EOK ? EXIT_SUCCESS : EXIT_FAILURE;
+}