diff --git a/layerindex/views.py b/layerindex/views.py
index 42722bf..592311f 100644
--- a/layerindex/views.py
+++ b/layerindex/views.py
@@ -6,6 +6,7 @@
 #
 # SPDX-License-Identifier: MIT
 
+import logging
 import os
 import sys
 import re
@@ -63,6 +64,8 @@ from layerindex.models import (BBAppend, BBClass, BBClassGlobal, BBClassRecipe,
 
 from . import tasks, utils
 
+logger = logging.getLogger(__name__)
+
 def edit_layernote_view(request, template_name, slug, pk=None):
     layeritem = get_object_or_404(LayerItem, name=slug)
     if layeritem.comparison:
@@ -347,12 +350,11 @@ def bulk_change_patch_view(request, pk):
         return HttpResponse('No patch data generated', content_type='text/plain')
     except Exception as e:
         output = getattr(e, 'output', None)
-        if output:
-            if 'timeout' in output:
-                return HttpResponse('Failed to generate patches: timed out waiting for lock. Please try again shortly.', content_type='text/plain')
-            else:
-                return HttpResponse('Failed to generate patches: %s' % output, content_type='text/plain')
-        return HttpResponse('Failed to generate patches: %s' % e, content_type='text/plain')
+        if output and 'timeout' in output:
+            return HttpResponse('Failed to generate patches: timed out waiting for lock. Please try again shortly.', content_type='text/plain')
+        # Details can include paths and bitbake output, so keep them in the server log
+        logger.exception('Failed to generate patches for bulk change %s: %s', pk, output)
+        return HttpResponse('Failed to generate patches, please contact the site administrator.', content_type='text/plain', status=500)
     # FIXME better error handling
 
 
diff --git a/tests/test_bulkchange_access.py b/tests/test_bulkchange_access.py
index e85451d..914493c 100644
--- a/tests/test_bulkchange_access.py
+++ b/tests/test_bulkchange_access.py
@@ -51,3 +51,14 @@ class TestBulkChangeAccess(TestCase):
         for name in ('bulk_change_edit', 'bulk_change_review'):
             response = self.client.get(self._url(name))
             self.assertEqual(response.status_code, 200, name)
+
+    def test_patch_error_details_not_exposed(self):
+        import subprocess
+        from unittest import mock
+        self.client.force_login(self.owner)
+        err = subprocess.CalledProcessError(1, 'bulkchange.py', output='Traceback: /opt/secret/path')
+        with mock.patch('layerindex.views.utils.runcmd', side_effect=err), \
+                self.assertLogs('layerindex.views', 'ERROR'):
+            response = self.client.get(self._url('bulk_change_patches'))
+        self.assertEqual(response.status_code, 500)
+        self.assertNotIn(b'/opt/secret/path', response.content)
