diff --git a/gitlab/base.sh b/gitlab/base.sh
index a07f026d5b..9317ce825b 100755
--- a/gitlab/base.sh
+++ b/gitlab/base.sh
@@ -33,7 +33,7 @@ fi
 
 # Generate a dbpasswords file
 # Note that this does not use ${DATABASE_NAME} since Symfony adds the _test postfix itself
-echo "unused:sqlserver:domjudge:domjudge:domjudge:3306" > etc/dbpasswords.secret
+echo 'unused:sqlserver:domjudge:domjudge:domjudge_+% #$*)@(!/;,.:3306' > etc/dbpasswords.secret
 
 # Generate APP_SECRET for symfony
 # shellcheck disable=SC2164
diff --git a/sql/dj_setup_database.in b/sql/dj_setup_database.in
index 03742fc161..7f719e802f 100755
--- a/sql/dj_setup_database.in
+++ b/sql/dj_setup_database.in
@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/bin/bash
 # @configure_input@
 
 # This script allows one to perform DOMjudge database setup actions.
@@ -29,6 +29,7 @@ Commands:
   status           check database installation status
   genpass          generate DB,API,Symfony,admin password files
   create-db-users  create (empty) database and users
+  update-password  update DB user database to that in 'etc/dbpasswords.secret'
   install          create database, example contest and users if not existing
   bare-install     create database, setup defaults if not existing
   uninstall        remove database users and database, INCLUDING ALL DATA!
@@ -51,32 +52,47 @@ not have to pass any of the options above.
 EOF
 }
 
+urlencode()
+{
+	php -r "echo rawurlencode('$1');"
+}
+
+# This is global variable to be able to return the output from
+# mysql_options() below as an array, which is not possible otherwise.
+declare -a _mysql_options
+
 mysql_options()
 {
 	local user pass
+	_mysql_options=()
 
 	# shellcheck disable=SC2153
 	if [ -n "$DBUSER" ]; then
-		user="-u $DBUSER"
-	else
-		user="${DBA_USER:+-u ${DBA_USER}}"
+		_mysql_options+=('-u' "$DBUSER")
+	elif [ -n "$DBA_USER" ]; then
+		_mysql_options+=('-u' "$DBA_USER")
 	fi
 	# shellcheck disable=SC2153
 	if [ -n "$PASSWD" ]; then
-		pass="-p$PASSWD"
-	else
-		[ -n "$PROMPT_PASSWD" ] && pass="-p"
-		[ -n "$DBA_PASSWD" ]    && pass="-p$DBA_PASSWD"
+		_mysql_options+=("-p$PASSWD")
+	elif [ -n "$DBA_PASSWD" ]; then
+		_mysql_options+=("-p$DBA_PASSWD")
+	elif [ -n "$PROMPT_PASSWD" ]; then
+		_mysql_options+=('-p')
 	fi
 
-	[ -z "$USE_SOCKET" ] && port="-P$DBPORT"
-	echo $user ${pass:+"$pass"} -h "$DBHOST" ${port:+"$port"}
+	_mysql_options+=('-h' "$DBHOST")
+
+	if [ -z "$USE_SOCKET" ]; then
+		_mysql_options+=("-P$DBPORT")
+	fi
 }
 
 # Wrapper around mysql command to allow setting options, user, etc.
 mysql()
 {
-	command mysql $(mysql_options) --silent --skip-column-names "$@"
+	mysql_options
+	command mysql "${_mysql_options[@]}" --silent --skip-column-names "$@"
 }
 
 # Quick shell hack to get a key from an INI file.
@@ -127,10 +143,13 @@ symfony_console()
 		fi
 
 		if [ -n "$DBA_USER" ]; then
+			user=$(urlencode "${DBA_USER}")
+			host=$(urlencode "${domjudge_DBHOST}")
+			db=$(urlencode "${domjudge_DBNAME}")
 			if [ -n "$DBA_PASSWD" ]; then
-				DATABASE_URL=mysql://${DBA_USER}:${DBA_PASSWD}@${domjudge_DBHOST}:${domjudge_DBPORT}/${domjudge_DBNAME}
+				DATABASE_URL="mysql://$user:$(urlencode "${DBA_PASSWD}")@$host:${domjudge_DBPORT}/$db"
 			else
-				DATABASE_URL=mysql://${DBA_USER}@${domjudge_DBHOST}:${domjudge_DBPORT}/${domjudge_DBNAME}
+				DATABASE_URL="mysql://$user@$host:${domjudge_DBPORT}/$db"
 			fi
 		fi
 	fi
@@ -235,6 +254,17 @@ remove_db_users()
 	verbose "DOMjudge database and user(s) removed."
 }
 
+update_password()
+{
+	read_dbpasswords
+	(
+    echo "ALTER USER '$domjudge_DBUSER'@'localhost' IDENTIFIED BY '$domjudge_PASSWD';"
+	echo "FLUSH PRIVILEGES;"
+	) | mysql
+    verbose "ALTER USER '$domjudge_DBUSER'@'localhost' IDENTIFIED BY '$domjudge_PASSWD';"
+	verbose "Database user password updated from credentials file."
+}
+
 install_examples()
 {
 	DBUSER=$domjudge_DBUSER PASSWD=$domjudge_PASSWD symfony_console domjudge:load-example-data
@@ -337,6 +367,10 @@ create-db-users)
 	create_db_users_helper
 	;;
 
+update-password)
+	update_password
+	;;
+
 bare-install|install)
 	read_dbpasswords
 	create_db_users
diff --git a/webapp/config/load_db_secrets.php b/webapp/config/load_db_secrets.php
index ea4ca11b83..c3a8b3dfb5 100644
--- a/webapp/config/load_db_secrets.php
+++ b/webapp/config/load_db_secrets.php
@@ -36,7 +36,11 @@ function get_db_url(): string
         break;
     }
 
-    return sprintf('mysql://%s:%s@%s:%d/%s?serverVersion=5.7.0', $user, $pass, $host, $port ?? 3306, $db);
+    return sprintf(
+        'mysql://%s:%s@%s:%d/%s?serverVersion=5.7.0',
+        rawurlencode($user), rawurlencode($pass), rawurlencode($host),
+        $port ?? 3306, rawurlencode($db)
+    );
 }
 
 function get_app_secret(): string
diff --git a/webapp/config/packages/doctrine.yaml b/webapp/config/packages/doctrine.yaml
index d04de47c52..d4ba6543f6 100644
--- a/webapp/config/packages/doctrine.yaml
+++ b/webapp/config/packages/doctrine.yaml
@@ -7,7 +7,7 @@ doctrine:
             charset: utf8mb4
             collate: utf8mb4_unicode_ci
 
-        url: '%env(resolve:DATABASE_URL)%'
+        url: '%env(DATABASE_URL)%'
         profiling_collect_backtrace: '%kernel.debug%'
         types:
             tinyint: App\Doctrine\DBAL\Types\TinyIntType