diff --git a/CHANGELOG.md b/CHANGELOG.md
index bfbde2f26884ba9cf924308e7e73331233a20794..c769165666e47b6c6b98545d0c8d103713b492b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ## [Unreleased]
 
+### Added
+
+* Added support for structured logging (JSON).
+
 ### Changed
 
 * Turned color-coded logs off by default. This can be changed in the config.
diff --git a/cmd/export_synapse_for_import/main.go b/cmd/export_synapse_for_import/main.go
index 6c8f5ff01689cd236d766c580b3a1730e2246f10..84a86203909150498700dc9bf8df06e893144b54 100644
--- a/cmd/export_synapse_for_import/main.go
+++ b/cmd/export_synapse_for_import/main.go
@@ -58,7 +58,7 @@ func main() {
 		realPsqlPassword = *postgresPassword
 	}
 
-	err := logging.Setup(config.Get().General.LogDirectory)
+	err := logging.Setup(config.Get().General.LogDirectory, config.Get().General.LogColors, config.Get().General.JsonLogs)
 	if err != nil {
 		panic(err)
 	}
diff --git a/cmd/gdpr_export/main.go b/cmd/gdpr_export/main.go
index c3213de6d364142254523a47af3a9dad20ddf39c..06d3122225582d3b1bf08e7db1e533bbb6254403 100644
--- a/cmd/gdpr_export/main.go
+++ b/cmd/gdpr_export/main.go
@@ -45,7 +45,7 @@ func main() {
 	assets.SetupTemplates(*templatesPath)
 
 	var err error
-	err = logging.Setup(config.Get().General.LogDirectory)
+	err = logging.Setup(config.Get().General.LogDirectory, config.Get().General.LogColors, config.Get().General.JsonLogs)
 	if err != nil {
 		panic(err)
 	}
diff --git a/cmd/gdpr_import/main.go b/cmd/gdpr_import/main.go
index 76f6adc006395227750843c51e842b1bf94e38fb..c69035a011e777c48255984497276c395690c134 100644
--- a/cmd/gdpr_import/main.go
+++ b/cmd/gdpr_import/main.go
@@ -35,7 +35,7 @@ func main() {
 	assets.SetupMigrations(*migrationsPath)
 
 	var err error
-	err = logging.Setup(config.Get().General.LogDirectory)
+	err = logging.Setup(config.Get().General.LogDirectory, config.Get().General.LogColors, config.Get().General.JsonLogs)
 	if err != nil {
 		panic(err)
 	}
diff --git a/cmd/import_synapse/main.go b/cmd/import_synapse/main.go
index 958c8b9a69ab9af6e01eddafc26c39ddb3fa3643..db184c78ee78b4ab373dcd93f7921d5fb2fbf055 100644
--- a/cmd/import_synapse/main.go
+++ b/cmd/import_synapse/main.go
@@ -72,7 +72,7 @@ func main() {
 		realPsqlPassword = *postgresPassword
 	}
 
-	err := logging.Setup(config.Get().General.LogDirectory)
+	err := logging.Setup(config.Get().General.LogDirectory, config.Get().General.LogColors, config.Get().General.JsonLogs)
 	if err != nil {
 		panic(err)
 	}
diff --git a/cmd/media_repo/main.go b/cmd/media_repo/main.go
index 2433e3c325536ac40f2a5ec8982c7f78f3b4f427..061799929339e990baa86f84fadd559999b7de28 100644
--- a/cmd/media_repo/main.go
+++ b/cmd/media_repo/main.go
@@ -59,7 +59,7 @@ func main() {
 	assets.SetupTemplates(*templatesPath)
 	assets.SetupAssets(*assetsPath)
 
-	err := logging.Setup(config.Get().General.LogDirectory, config.Get().General.LogColors)
+	err := logging.Setup(config.Get().General.LogDirectory, config.Get().General.LogColors, config.Get().General.JsonLogs)
 	if err != nil {
 		panic(err)
 	}
diff --git a/common/config/conf_main.go b/common/config/conf_main.go
index e5537bc0c757c1f1e92b07fddfdc1081de6dcd39..d6b5dc4fceb055bfd642712a4063cac011d3f6fd 100644
--- a/common/config/conf_main.go
+++ b/common/config/conf_main.go
@@ -25,6 +25,7 @@ func NewDefaultMainConfig() MainRepoConfig {
 			Port:             8000,
 			LogDirectory:     "logs",
 			LogColors:        false,
+			JsonLogs:         false,
 			TrustAnyForward:  false,
 			UseForwardedHost: true,
 		},
diff --git a/common/config/models_main.go b/common/config/models_main.go
index 0690d1aa5718fb1251853c6c79cd308f3bb791a8..a331754ba4dbf50271be95ab6677dfbae8820d0a 100644
--- a/common/config/models_main.go
+++ b/common/config/models_main.go
@@ -5,6 +5,7 @@ type GeneralConfig struct {
 	Port             int    `yaml:"port"`
 	LogDirectory     string `yaml:"logDirectory"`
 	LogColors        bool   `yaml:"logColors"`
+	JsonLogs         bool   `yaml:"jsonLogs"`
 	TrustAnyForward  bool   `yaml:"trustAnyForwardedAddress"`
 	UseForwardedHost bool   `yaml:"useForwardedHost"`
 }
diff --git a/common/logging/logger.go b/common/logging/logger.go
index bbd9533444df2e6ea5a1880cb59234445d7d7f98..86cba36396cbf58722ad066540e61697dd621d2a 100644
--- a/common/logging/logger.go
+++ b/common/logging/logger.go
@@ -19,17 +19,24 @@ func (f utcFormatter) Format(entry *logrus.Entry) ([]byte, error) {
 	return f.Formatter.Format(entry)
 }
 
-func Setup(dir string, colors bool) error {
-	formatter := &utcFormatter{
-		&logrus.TextFormatter{
+func Setup(dir string, colors bool, json bool) error {
+	var lineFormatter logrus.Formatter
+	if json {
+		lineFormatter = &logrus.JSONFormatter{
+			TimestampFormat:  "2006-01-02 15:04:05.000 Z07:00",
+			DisableTimestamp: false,
+		}
+	} else {
+		lineFormatter = &logrus.TextFormatter{
 			TimestampFormat:  "2006-01-02 15:04:05.000 Z07:00",
 			FullTimestamp:    true,
 			ForceColors:      colors,
 			DisableColors:    !colors,
 			DisableTimestamp: false,
 			QuoteEmptyFields: true,
-		},
+		}
 	}
+	formatter := &utcFormatter{lineFormatter}
 	logrus.SetFormatter(formatter)
 	logrus.SetOutput(os.Stdout)
 
diff --git a/config.sample.yaml b/config.sample.yaml
index 69d5bb21e14ba261a23c1d4259c2639a39674f48..7c9150c13735521652ec166bf9eaad1b5ae407ea 100644
--- a/config.sample.yaml
+++ b/config.sample.yaml
@@ -15,6 +15,10 @@ repo:
   # appear in logs which render them unreadable, which is why colors are disabled by default.
   logColors: false
 
+  # Set to true to enable JSON logging for consumption by things like logstash. Note that this is
+  # incompatible with the log color option and will always render without colors.
+  jsonLogs: false
+
   # If true, the media repo will accept any X-Forwarded-For header without validation. In most cases
   # this option should be left as "false". Note that the media repo already expects an X-Forwarded-For
   # header, but validates it to ensure the IP being given makes sense.