diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts
index 0148f2e1e9..bb6d17a248 100644
--- a/e2e/src/responses.ts
+++ b/e2e/src/responses.ts
@@ -103,6 +103,7 @@ export const loginResponseDto = {
     accessToken: expect.any(String),
     name: 'Immich Admin',
     isAdmin: true,
+    isOnboarded: false,
     profileImagePath: '',
     shouldChangePassword: true,
     userEmail: 'admin@immich.cloud',
diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts
index 74bee64e0a..0fde9a6ec6 100644
--- a/e2e/src/web/specs/auth.e2e-spec.ts
+++ b/e2e/src/web/specs/auth.e2e-spec.ts
@@ -33,7 +33,9 @@ test.describe('Registration', () => {
     // onboarding
     await expect(page).toHaveURL('/auth/onboarding');
     await page.getByRole('button', { name: 'Theme' }).click();
-    await page.getByRole('button', { name: 'Privacy' }).click();
+    await page.getByRole('button', { name: 'Language' }).click();
+    await page.getByRole('button', { name: 'Server Privacy' }).click();
+    await page.getByRole('button', { name: 'User Privacy' }).click();
     await page.getByRole('button', { name: 'Storage Template' }).click();
     await page.getByRole('button', { name: 'Done' }).click();
 
@@ -77,6 +79,13 @@ test.describe('Registration', () => {
     await page.getByLabel('Password').fill('new-password');
     await page.getByRole('button', { name: 'Login' }).click();
 
+    // onboarding
+    await expect(page).toHaveURL('/auth/onboarding');
+    await page.getByRole('button', { name: 'Theme' }).click();
+    await page.getByRole('button', { name: 'Language' }).click();
+    await page.getByRole('button', { name: 'User Privacy' }).click();
+    await page.getByRole('button', { name: 'Done' }).click();
+
     // success
     await expect(page).toHaveURL(/\/photos/);
   });
diff --git a/i18n/en.json b/i18n/en.json
index 3e37b2da65..98ca467c51 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -1294,9 +1294,11 @@
   "oldest_first": "Oldest first",
   "on_this_device": "On this device",
   "onboarding": "Onboarding",
-  "onboarding_privacy_description": "The following (optional) features rely on external services, and can be disabled at any time in the administration settings.",
+  "onboarding_locale_description": "Select your preferred language. You can change this later in your settings.",
+  "onboarding_privacy_description": "The following (optional) features rely on external services, and can be disabled at any time in settings.",
+  "onboarding_server_welcome_description": "Let's get your instance set up with some common settings.",
   "onboarding_theme_description": "Choose a color theme for your instance. You can change this later in your settings.",
-  "onboarding_welcome_description": "Let's get your instance set up with some common settings.",
+  "onboarding_user_welcome_description": "Let's get you started!",
   "onboarding_welcome_user": "Welcome, {user}",
   "online": "Online",
   "only_favorites": "Only favorites",
@@ -1608,6 +1610,7 @@
   "server_info_box_server_url": "Server URL",
   "server_offline": "Server Offline",
   "server_online": "Server Online",
+  "server_privacy": "Server Privacy",
   "server_stats": "Server Stats",
   "server_version": "Server Version",
   "set": "Set",
@@ -1879,6 +1882,7 @@
   "user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}",
   "user_pin_code_settings": "PIN Code",
   "user_pin_code_settings_description": "Manage your PIN code",
+  "user_privacy": "User Privacy",
   "user_purchase_settings": "Purchase",
   "user_purchase_settings_description": "Manage your purchase",
   "user_role_set": "Set {user} as {role}",
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 73bbe7c1ff..4ff55e5db8 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -247,13 +247,16 @@ Class | Method | HTTP request | Description
 *UsersApi* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image | 
 *UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image | 
 *UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | 
+*UsersApi* | [**deleteUserOnboarding**](doc//UsersApi.md#deleteuseronboarding) | **DELETE** /users/me/onboarding | 
 *UsersApi* | [**getMyPreferences**](doc//UsersApi.md#getmypreferences) | **GET** /users/me/preferences | 
 *UsersApi* | [**getMyUser**](doc//UsersApi.md#getmyuser) | **GET** /users/me | 
 *UsersApi* | [**getProfileImage**](doc//UsersApi.md#getprofileimage) | **GET** /users/{id}/profile-image | 
 *UsersApi* | [**getUser**](doc//UsersApi.md#getuser) | **GET** /users/{id} | 
 *UsersApi* | [**getUserLicense**](doc//UsersApi.md#getuserlicense) | **GET** /users/me/license | 
+*UsersApi* | [**getUserOnboarding**](doc//UsersApi.md#getuseronboarding) | **GET** /users/me/onboarding | 
 *UsersApi* | [**searchUsers**](doc//UsersApi.md#searchusers) | **GET** /users | 
 *UsersApi* | [**setUserLicense**](doc//UsersApi.md#setuserlicense) | **PUT** /users/me/license | 
+*UsersApi* | [**setUserOnboarding**](doc//UsersApi.md#setuseronboarding) | **PUT** /users/me/onboarding | 
 *UsersApi* | [**updateMyPreferences**](doc//UsersApi.md#updatemypreferences) | **PUT** /users/me/preferences | 
 *UsersApi* | [**updateMyUser**](doc//UsersApi.md#updatemyuser) | **PUT** /users/me | 
 *UsersAdminApi* | [**createUserAdmin**](doc//UsersAdminApi.md#createuseradmin) | **POST** /admin/users | 
@@ -385,6 +388,8 @@ Class | Method | HTTP request | Description
  - [OAuthConfigDto](doc//OAuthConfigDto.md)
  - [OAuthTokenEndpointAuthMethod](doc//OAuthTokenEndpointAuthMethod.md)
  - [OnThisDayDto](doc//OnThisDayDto.md)
+ - [OnboardingDto](doc//OnboardingDto.md)
+ - [OnboardingResponseDto](doc//OnboardingResponseDto.md)
  - [PartnerDirection](doc//PartnerDirection.md)
  - [PartnerResponseDto](doc//PartnerResponseDto.md)
  - [PeopleResponse](doc//PeopleResponse.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 846db953dc..87d14248eb 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -177,6 +177,8 @@ part 'model/o_auth_callback_dto.dart';
 part 'model/o_auth_config_dto.dart';
 part 'model/o_auth_token_endpoint_auth_method.dart';
 part 'model/on_this_day_dto.dart';
+part 'model/onboarding_dto.dart';
+part 'model/onboarding_response_dto.dart';
 part 'model/partner_direction.dart';
 part 'model/partner_response_dto.dart';
 part 'model/people_response.dart';
diff --git a/mobile/openapi/lib/api/users_api.dart b/mobile/openapi/lib/api/users_api.dart
index a48ec54cfe..cd31617e74 100644
--- a/mobile/openapi/lib/api/users_api.dart
+++ b/mobile/openapi/lib/api/users_api.dart
@@ -139,6 +139,39 @@ class UsersApi {
     }
   }
 
+  /// Performs an HTTP 'DELETE /users/me/onboarding' operation and returns the [Response].
+  Future<Response> deleteUserOnboardingWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/users/me/onboarding';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<void> deleteUserOnboarding() async {
+    final response = await deleteUserOnboardingWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
   /// Performs an HTTP 'GET /users/me/preferences' operation and returns the [Response].
   Future<Response> getMyPreferencesWithHttpInfo() async {
     // ignore: prefer_const_declarations
@@ -358,6 +391,47 @@ class UsersApi {
     return null;
   }
 
+  /// Performs an HTTP 'GET /users/me/onboarding' operation and returns the [Response].
+  Future<Response> getUserOnboardingWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/users/me/onboarding';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<OnboardingResponseDto?> getUserOnboarding() async {
+    final response = await getUserOnboardingWithHttpInfo();
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'OnboardingResponseDto',) as OnboardingResponseDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /users' operation and returns the [Response].
   Future<Response> searchUsersWithHttpInfo() async {
     // ignore: prefer_const_declarations
@@ -449,6 +523,53 @@ class UsersApi {
     return null;
   }
 
+  /// Performs an HTTP 'PUT /users/me/onboarding' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [OnboardingDto] onboardingDto (required):
+  Future<Response> setUserOnboardingWithHttpInfo(OnboardingDto onboardingDto,) async {
+    // ignore: prefer_const_declarations
+    final apiPath = r'/users/me/onboarding';
+
+    // ignore: prefer_final_locals
+    Object? postBody = onboardingDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      apiPath,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [OnboardingDto] onboardingDto (required):
+  Future<OnboardingResponseDto?> setUserOnboarding(OnboardingDto onboardingDto,) async {
+    final response = await setUserOnboardingWithHttpInfo(onboardingDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'OnboardingResponseDto',) as OnboardingResponseDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'PUT /users/me/preferences' operation and returns the [Response].
   /// Parameters:
   ///
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 2657cece1c..46936fa88b 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -410,6 +410,10 @@ class ApiClient {
           return OAuthTokenEndpointAuthMethodTypeTransformer().decode(value);
         case 'OnThisDayDto':
           return OnThisDayDto.fromJson(value);
+        case 'OnboardingDto':
+          return OnboardingDto.fromJson(value);
+        case 'OnboardingResponseDto':
+          return OnboardingResponseDto.fromJson(value);
         case 'PartnerDirection':
           return PartnerDirectionTypeTransformer().decode(value);
         case 'PartnerResponseDto':
diff --git a/mobile/openapi/lib/model/login_response_dto.dart b/mobile/openapi/lib/model/login_response_dto.dart
index dbc82d07ba..82a4f9b3ed 100644
--- a/mobile/openapi/lib/model/login_response_dto.dart
+++ b/mobile/openapi/lib/model/login_response_dto.dart
@@ -15,6 +15,7 @@ class LoginResponseDto {
   LoginResponseDto({
     required this.accessToken,
     required this.isAdmin,
+    required this.isOnboarded,
     required this.name,
     required this.profileImagePath,
     required this.shouldChangePassword,
@@ -26,6 +27,8 @@ class LoginResponseDto {
 
   bool isAdmin;
 
+  bool isOnboarded;
+
   String name;
 
   String profileImagePath;
@@ -40,6 +43,7 @@ class LoginResponseDto {
   bool operator ==(Object other) => identical(this, other) || other is LoginResponseDto &&
     other.accessToken == accessToken &&
     other.isAdmin == isAdmin &&
+    other.isOnboarded == isOnboarded &&
     other.name == name &&
     other.profileImagePath == profileImagePath &&
     other.shouldChangePassword == shouldChangePassword &&
@@ -51,6 +55,7 @@ class LoginResponseDto {
     // ignore: unnecessary_parenthesis
     (accessToken.hashCode) +
     (isAdmin.hashCode) +
+    (isOnboarded.hashCode) +
     (name.hashCode) +
     (profileImagePath.hashCode) +
     (shouldChangePassword.hashCode) +
@@ -58,12 +63,13 @@ class LoginResponseDto {
     (userId.hashCode);
 
   @override
-  String toString() => 'LoginResponseDto[accessToken=$accessToken, isAdmin=$isAdmin, name=$name, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, userEmail=$userEmail, userId=$userId]';
+  String toString() => 'LoginResponseDto[accessToken=$accessToken, isAdmin=$isAdmin, isOnboarded=$isOnboarded, name=$name, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, userEmail=$userEmail, userId=$userId]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
       json[r'accessToken'] = this.accessToken;
       json[r'isAdmin'] = this.isAdmin;
+      json[r'isOnboarded'] = this.isOnboarded;
       json[r'name'] = this.name;
       json[r'profileImagePath'] = this.profileImagePath;
       json[r'shouldChangePassword'] = this.shouldChangePassword;
@@ -83,6 +89,7 @@ class LoginResponseDto {
       return LoginResponseDto(
         accessToken: mapValueOfType<String>(json, r'accessToken')!,
         isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
+        isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!,
         name: mapValueOfType<String>(json, r'name')!,
         profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
@@ -137,6 +144,7 @@ class LoginResponseDto {
   static const requiredKeys = <String>{
     'accessToken',
     'isAdmin',
+    'isOnboarded',
     'name',
     'profileImagePath',
     'shouldChangePassword',
diff --git a/mobile/openapi/lib/model/onboarding_dto.dart b/mobile/openapi/lib/model/onboarding_dto.dart
new file mode 100644
index 0000000000..670b6a5c68
--- /dev/null
+++ b/mobile/openapi/lib/model/onboarding_dto.dart
@@ -0,0 +1,99 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class OnboardingDto {
+  /// Returns a new [OnboardingDto] instance.
+  OnboardingDto({
+    required this.isOnboarded,
+  });
+
+  bool isOnboarded;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is OnboardingDto &&
+    other.isOnboarded == isOnboarded;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (isOnboarded.hashCode);
+
+  @override
+  String toString() => 'OnboardingDto[isOnboarded=$isOnboarded]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'isOnboarded'] = this.isOnboarded;
+    return json;
+  }
+
+  /// Returns a new [OnboardingDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static OnboardingDto? fromJson(dynamic value) {
+    upgradeDto(value, "OnboardingDto");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return OnboardingDto(
+        isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!,
+      );
+    }
+    return null;
+  }
+
+  static List<OnboardingDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <OnboardingDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = OnboardingDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, OnboardingDto> mapFromJson(dynamic json) {
+    final map = <String, OnboardingDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = OnboardingDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of OnboardingDto-objects as value to a dart map
+  static Map<String, List<OnboardingDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<OnboardingDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = OnboardingDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'isOnboarded',
+  };
+}
+
diff --git a/mobile/openapi/lib/model/onboarding_response_dto.dart b/mobile/openapi/lib/model/onboarding_response_dto.dart
new file mode 100644
index 0000000000..033466e96b
--- /dev/null
+++ b/mobile/openapi/lib/model/onboarding_response_dto.dart
@@ -0,0 +1,99 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class OnboardingResponseDto {
+  /// Returns a new [OnboardingResponseDto] instance.
+  OnboardingResponseDto({
+    required this.isOnboarded,
+  });
+
+  bool isOnboarded;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is OnboardingResponseDto &&
+    other.isOnboarded == isOnboarded;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (isOnboarded.hashCode);
+
+  @override
+  String toString() => 'OnboardingResponseDto[isOnboarded=$isOnboarded]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'isOnboarded'] = this.isOnboarded;
+    return json;
+  }
+
+  /// Returns a new [OnboardingResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static OnboardingResponseDto? fromJson(dynamic value) {
+    upgradeDto(value, "OnboardingResponseDto");
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return OnboardingResponseDto(
+        isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!,
+      );
+    }
+    return null;
+  }
+
+  static List<OnboardingResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <OnboardingResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = OnboardingResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, OnboardingResponseDto> mapFromJson(dynamic json) {
+    final map = <String, OnboardingResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = OnboardingResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of OnboardingResponseDto-objects as value to a dart map
+  static Map<String, List<OnboardingResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<OnboardingResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = OnboardingResponseDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'isOnboarded',
+  };
+}
+
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index f2886f59c0..286fa47c66 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -7922,6 +7922,101 @@
         ]
       }
     },
+    "/users/me/onboarding": {
+      "delete": {
+        "operationId": "deleteUserOnboarding",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Users"
+        ]
+      },
+      "get": {
+        "operationId": "getUserOnboarding",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/OnboardingResponseDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Users"
+        ]
+      },
+      "put": {
+        "operationId": "setUserOnboarding",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/OnboardingDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/OnboardingResponseDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Users"
+        ]
+      }
+    },
     "/users/me/preferences": {
       "get": {
         "operationId": "getMyPreferences",
@@ -10404,6 +10499,9 @@
           "isAdmin": {
             "type": "boolean"
           },
+          "isOnboarded": {
+            "type": "boolean"
+          },
           "name": {
             "type": "string"
           },
@@ -10423,6 +10521,7 @@
         "required": [
           "accessToken",
           "isAdmin",
+          "isOnboarded",
           "name",
           "profileImagePath",
           "shouldChangePassword",
@@ -11067,6 +11166,28 @@
         ],
         "type": "object"
       },
+      "OnboardingDto": {
+        "properties": {
+          "isOnboarded": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "isOnboarded"
+        ],
+        "type": "object"
+      },
+      "OnboardingResponseDto": {
+        "properties": {
+          "isOnboarded": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "isOnboarded"
+        ],
+        "type": "object"
+      },
       "PartnerDirection": {
         "enum": [
           "shared-by",
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index ac66080865..e3e12dc56e 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -512,6 +512,7 @@ export type LoginCredentialDto = {
 export type LoginResponseDto = {
     accessToken: string;
     isAdmin: boolean;
+    isOnboarded: boolean;
     name: string;
     profileImagePath: string;
     shouldChangePassword: boolean;
@@ -1470,6 +1471,12 @@ export type UserUpdateMeDto = {
     name?: string;
     password?: string;
 };
+export type OnboardingResponseDto = {
+    isOnboarded: boolean;
+};
+export type OnboardingDto = {
+    isOnboarded: boolean;
+};
 export type CreateProfileImageDto = {
     file: Blob;
 };
@@ -3582,6 +3589,32 @@ export function setUserLicense({ licenseKeyDto }: {
         body: licenseKeyDto
     })));
 }
+export function deleteUserOnboarding(opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchText("/users/me/onboarding", {
+        ...opts,
+        method: "DELETE"
+    }));
+}
+export function getUserOnboarding(opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: OnboardingResponseDto;
+    }>("/users/me/onboarding", {
+        ...opts
+    }));
+}
+export function setUserOnboarding({ onboardingDto }: {
+    onboardingDto: OnboardingDto;
+}, opts?: Oazapfts.RequestOpts) {
+    return oazapfts.ok(oazapfts.fetchJson<{
+        status: 200;
+        data: OnboardingResponseDto;
+    }>("/users/me/onboarding", oazapfts.json({
+        ...opts,
+        method: "PUT",
+        body: onboardingDto
+    })));
+}
 export function getMyPreferences(opts?: Oazapfts.RequestOpts) {
     return oazapfts.ok(oazapfts.fetchJson<{
         status: 200;
diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts
index f1bdf160d3..6c6eae15ff 100644
--- a/server/src/controllers/user.controller.ts
+++ b/server/src/controllers/user.controller.ts
@@ -17,6 +17,7 @@ import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
 import { NextFunction, Response } from 'express';
 import { AuthDto } from 'src/dtos/auth.dto';
 import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
+import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
 import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
 import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
 import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
@@ -87,6 +88,24 @@ export class UserController {
     await this.service.deleteLicense(auth);
   }
 
+  @Get('me/onboarding')
+  @Authenticated()
+  getUserOnboarding(@Auth() auth: AuthDto): Promise<OnboardingResponseDto> {
+    return this.service.getOnboarding(auth);
+  }
+
+  @Put('me/onboarding')
+  @Authenticated()
+  async setUserOnboarding(@Auth() auth: AuthDto, @Body() Onboarding: OnboardingDto): Promise<OnboardingResponseDto> {
+    return this.service.setOnboarding(auth, Onboarding);
+  }
+
+  @Delete('me/onboarding')
+  @Authenticated()
+  async deleteUserOnboarding(@Auth() auth: AuthDto): Promise<void> {
+    await this.service.deleteOnboarding(auth);
+  }
+
   @Get(':id')
   @Authenticated()
   getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts
index 2f3ae5c14b..e94818b2b5 100644
--- a/server/src/dtos/auth.dto.ts
+++ b/server/src/dtos/auth.dto.ts
@@ -2,7 +2,8 @@ import { ApiProperty } from '@nestjs/swagger';
 import { Transform } from 'class-transformer';
 import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
 import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
-import { ImmichCookie } from 'src/enum';
+import { ImmichCookie, UserMetadataKey } from 'src/enum';
+import { UserMetadataItem } from 'src/types';
 import { Optional, PinCode, toEmail } from 'src/validation';
 
 export type CookieResponse = {
@@ -39,9 +40,14 @@ export class LoginResponseDto {
   profileImagePath!: string;
   isAdmin!: boolean;
   shouldChangePassword!: boolean;
+  isOnboarded!: boolean;
 }
 
 export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto {
+  const onboardingMetadata = entity.metadata.find(
+    (item): item is UserMetadataItem<UserMetadataKey.ONBOARDING> => item.key === UserMetadataKey.ONBOARDING,
+  )?.value;
+
   return {
     accessToken,
     userId: entity.id,
@@ -50,6 +56,7 @@ export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginR
     isAdmin: entity.isAdmin,
     profileImagePath: entity.profileImagePath,
     shouldChangePassword: entity.shouldChangePassword,
+    isOnboarded: onboardingMetadata?.isOnboarded ?? false,
   };
 }
 
diff --git a/server/src/dtos/onboarding.dto.ts b/server/src/dtos/onboarding.dto.ts
new file mode 100644
index 0000000000..0028fca006
--- /dev/null
+++ b/server/src/dtos/onboarding.dto.ts
@@ -0,0 +1,9 @@
+import { IsBoolean, IsNotEmpty } from 'class-validator';
+
+export class OnboardingDto {
+  @IsBoolean()
+  @IsNotEmpty()
+  isOnboarded!: boolean;
+}
+
+export class OnboardingResponseDto extends OnboardingDto {}
diff --git a/server/src/enum.ts b/server/src/enum.ts
index b00b013393..e7e40eb122 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -211,6 +211,7 @@ export enum SystemMetadataKey {
 export enum UserMetadataKey {
   PREFERENCES = 'preferences',
   LICENSE = 'license',
+  ONBOARDING = 'onboarding',
 }
 
 export enum UserAvatarColor {
diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts
index 4bc5f1ce0b..a773f4a1cf 100644
--- a/server/src/services/auth.service.spec.ts
+++ b/server/src/services/auth.service.spec.ts
@@ -28,6 +28,7 @@ const oauthResponse = ({
   name,
   profileImagePath,
   isAdmin: false,
+  isOnboarded: false,
   shouldChangePassword: false,
 });
 
@@ -101,6 +102,7 @@ describe(AuthService.name, () => {
         name: user.name,
         profileImagePath: user.profileImagePath,
         isAdmin: user.isAdmin,
+        isOnboarded: false,
         shouldChangePassword: user.shouldChangePassword,
       });
 
diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts
index a0304d51ad..78f49fd7ae 100644
--- a/server/src/services/user.service.ts
+++ b/server/src/services/user.service.ts
@@ -6,6 +6,7 @@ import { StorageCore } from 'src/cores/storage.core';
 import { OnJob } from 'src/decorators';
 import { AuthDto } from 'src/dtos/auth.dto';
 import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
+import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
 import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
 import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
 import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
@@ -179,6 +180,39 @@ export class UserService extends BaseService {
     return { ...license, activatedAt };
   }
 
+  async getOnboarding(auth: AuthDto): Promise<OnboardingResponseDto> {
+    const metadata = await this.userRepository.getMetadata(auth.user.id);
+
+    const onboardingData = metadata.find(
+      (item): item is UserMetadataItem<UserMetadataKey.ONBOARDING> => item.key === UserMetadataKey.ONBOARDING,
+    )?.value;
+
+    if (!onboardingData) {
+      return { isOnboarded: false };
+    }
+
+    return {
+      isOnboarded: onboardingData.isOnboarded,
+    };
+  }
+
+  async deleteOnboarding({ user }: AuthDto): Promise<void> {
+    await this.userRepository.deleteMetadata(user.id, UserMetadataKey.ONBOARDING);
+  }
+
+  async setOnboarding(auth: AuthDto, onboarding: OnboardingDto): Promise<OnboardingResponseDto> {
+    await this.userRepository.upsertMetadata(auth.user.id, {
+      key: UserMetadataKey.ONBOARDING,
+      value: {
+        isOnboarded: onboarding.isOnboarded,
+      },
+    });
+
+    return {
+      isOnboarded: onboarding.isOnboarded,
+    };
+  }
+
   @OnJob({ name: JobName.USER_SYNC_USAGE, queue: QueueName.BACKGROUND_TASK })
   async handleUserSyncUsage(): Promise<JobStatus> {
     await this.userRepository.syncUsage();
diff --git a/server/src/types.ts b/server/src/types.ts
index 9d5ba46e12..2e613c124e 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -510,4 +510,5 @@ export interface UserPreferences {
 export interface UserMetadata extends Record<UserMetadataKey, Record<string, any>> {
   [UserMetadataKey.PREFERENCES]: DeepPartial<UserPreferences>;
   [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: string };
+  [UserMetadataKey.ONBOARDING]: { isOnboarded: boolean };
 }
diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts
index 75e36c1da2..b70f02bcf5 100644
--- a/server/test/small.factory.ts
+++ b/server/test/small.factory.ts
@@ -15,8 +15,8 @@ import {
 } from 'src/database';
 import { MapAsset } from 'src/dtos/asset-response.dto';
 import { AuthDto } from 'src/dtos/auth.dto';
-import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserStatus } from 'src/enum';
-import { OnThisDayData } from 'src/types';
+import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserMetadataKey, UserStatus } from 'src/enum';
+import { OnThisDayData, UserMetadataItem } from 'src/types';
 
 export const newUuid = () => randomUUID() as string;
 export const newUuids = () =>
@@ -146,6 +146,12 @@ const userFactory = (user: Partial<User> = {}) => ({
   avatarColor: null,
   profileImagePath: '',
   profileChangedAt: newDate(),
+  metadata: [
+    {
+      key: UserMetadataKey.ONBOARDING,
+      value: 'true',
+    },
+  ] as UserMetadataItem[],
   ...user,
 });
 
diff --git a/web/src/lib/components/onboarding-page/onboarding-card.svelte b/web/src/lib/components/onboarding-page/onboarding-card.svelte
index 54951dfa09..4a373fc310 100644
--- a/web/src/lib/components/onboarding-page/onboarding-card.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-card.svelte
@@ -1,15 +1,32 @@
 <script lang="ts">
   import Icon from '$lib/components/elements/icon.svelte';
+  import { Button } from '@immich/ui';
+  import { mdiArrowLeft, mdiArrowRight, mdiCheck } from '@mdi/js';
   import type { Snippet } from 'svelte';
+  import { t } from 'svelte-i18n';
   import { fade } from 'svelte/transition';
 
   interface Props {
     title?: string | undefined;
     icon?: string | undefined;
     children?: Snippet;
+    previousTitle?: string | undefined;
+    nextTitle?: string | undefined;
+    onNext?: () => void;
+    onPrevious?: () => void;
+    onLeave?: () => void;
   }
 
-  let { title = undefined, icon = undefined, children }: Props = $props();
+  let {
+    title = undefined,
+    icon = undefined,
+    children,
+    previousTitle,
+    nextTitle,
+    onLeave,
+    onNext,
+    onPrevious,
+  }: Props = $props();
 </script>
 
 <div
@@ -30,4 +47,37 @@
     </div>
   {/if}
   {@render children?.()}
+
+  <div class="flex pt-4">
+    {#if previousTitle}
+      <div class="w-full flex place-content-start">
+        <Button
+          shape="round"
+          leadingIcon={mdiArrowLeft}
+          class="flex gap-2 place-content-center"
+          onclick={() => {
+            onLeave?.();
+            onPrevious?.();
+          }}
+        >
+          <p>{previousTitle}</p>
+        </Button>
+      </div>
+    {/if}
+
+    <div class="flex w-full place-content-end">
+      <Button
+        shape="round"
+        trailingIcon={nextTitle ? mdiArrowRight : mdiCheck}
+        onclick={() => {
+          onLeave?.();
+          onNext?.();
+        }}
+      >
+        <span class="flex place-content-center place-items-center gap-2">
+          {nextTitle ?? $t('done')}
+        </span>
+      </Button>
+    </div>
+  </div>
 </div>
diff --git a/web/src/lib/components/onboarding-page/onboarding-hello.svelte b/web/src/lib/components/onboarding-page/onboarding-hello.svelte
index 70df619ae0..f1b1516bbe 100644
--- a/web/src/lib/components/onboarding-page/onboarding-hello.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-hello.svelte
@@ -1,28 +1,21 @@
 <script lang="ts">
   import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
   import { user } from '$lib/stores/user.store';
-  import { Button } from '@immich/ui';
-  import { mdiArrowRight } from '@mdi/js';
   import { t } from 'svelte-i18n';
-  import OnboardingCard from './onboarding-card.svelte';
+  import { OnboardingRole } from '$lib/models/onboarding-role';
+  import { serverConfig } from '$lib/stores/server-config.store';
 
-  interface Props {
-    onDone: () => void;
-  }
-
-  let { onDone }: Props = $props();
+  let userRole = $derived($user.isAdmin && !$serverConfig.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER);
 </script>
 
-<OnboardingCard>
-  <ImmichLogo noText class="h-[50px]" />
-  <p class="font-medium text-6xl my-6 text-immich-primary dark:text-immich-dark-primary">
+<div class="gap-4">
+  <ImmichLogo noText class="h-[100px] mb-2" />
+  <p class="font-medium mb-6 text-6xl text-immich-primary dark:text-immich-dark-primary">
     {$t('onboarding_welcome_user', { values: { user: $user.name } })}
   </p>
-  <p class="text-3xl pb-6 font-light">{$t('onboarding_welcome_description')}</p>
-
-  <div class="w-full flex place-content-end">
-    <Button shape="round" trailingIcon={mdiArrowRight} class="flex gap-2 place-content-center" onclick={onDone}>
-      <p>{$t('theme')}</p>
-    </Button>
-  </div>
-</OnboardingCard>
+  <p class="text-3xl pb-6 font-light">
+    {userRole == OnboardingRole.SERVER
+      ? $t('onboarding_server_welcome_description')
+      : $t('onboarding_user_welcome_description')}
+  </p>
+</div>
diff --git a/web/src/lib/components/onboarding-page/onboarding-language.svelte b/web/src/lib/components/onboarding-page/onboarding-language.svelte
new file mode 100644
index 0000000000..a37b026f13
--- /dev/null
+++ b/web/src/lib/components/onboarding-page/onboarding-language.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+  import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
+  import { t } from 'svelte-i18n';
+</script>
+
+<div class="flex flex-col gap-4">
+  <p>
+    {$t('onboarding_locale_description')}
+  </p>
+
+  <SettingsLanguageSelector />
+</div>
diff --git a/web/src/lib/components/onboarding-page/onboarding-privacy.svelte b/web/src/lib/components/onboarding-page/onboarding-privacy.svelte
deleted file mode 100644
index 12f4084fbc..0000000000
--- a/web/src/lib/components/onboarding-page/onboarding-privacy.svelte
+++ /dev/null
@@ -1,74 +0,0 @@
-<script lang="ts">
-  import AdminSettings from '$lib/components/admin-page/settings/admin-settings.svelte';
-  import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
-  import { user } from '$lib/stores/user.store';
-  import { getConfig, type SystemConfigDto } from '@immich/sdk';
-  import { Button } from '@immich/ui';
-  import { mdiArrowLeft, mdiArrowRight, mdiIncognito } from '@mdi/js';
-  import { onMount } from 'svelte';
-  import { t } from 'svelte-i18n';
-  import OnboardingCard from './onboarding-card.svelte';
-
-  interface Props {
-    onDone: () => void;
-    onPrevious: () => void;
-  }
-
-  let { onDone, onPrevious }: Props = $props();
-
-  let config: SystemConfigDto | null = $state(null);
-  let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>();
-
-  onMount(async () => {
-    config = await getConfig();
-  });
-</script>
-
-<OnboardingCard title={$t('privacy')} icon={mdiIncognito}>
-  <p>
-    {$t('onboarding_privacy_description')}
-  </p>
-
-  {#if config && $user}
-    <AdminSettings bind:config bind:this={adminSettingsComponent}>
-      {#if config}
-        <SettingSwitch
-          title={$t('admin.map_settings')}
-          subtitle={$t('admin.map_implications')}
-          bind:checked={config.map.enabled}
-        />
-        <SettingSwitch
-          title={$t('admin.version_check_settings')}
-          subtitle={$t('admin.version_check_implications')}
-          bind:checked={config.newVersionCheck.enabled}
-        />
-        <div class="flex pt-4">
-          <div class="w-full flex place-content-start">
-            <Button
-              shape="round"
-              leadingIcon={mdiArrowLeft}
-              class="flex gap-2 place-content-center"
-              onclick={() => onPrevious()}
-            >
-              <p>{$t('theme')}</p>
-            </Button>
-          </div>
-          <div class="flex w-full place-content-end">
-            <Button
-              shape="round"
-              trailingIcon={mdiArrowRight}
-              onclick={() => {
-                adminSettingsComponent?.handleSave({ map: config?.map, newVersionCheck: config?.newVersionCheck });
-                onDone();
-              }}
-            >
-              <span class="flex place-content-center place-items-center gap-2">
-                {$t('admin.storage_template_settings')}
-              </span>
-            </Button>
-          </div>
-        </div>
-      {/if}
-    </AdminSettings>
-  {/if}
-</OnboardingCard>
diff --git a/web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte b/web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte
new file mode 100644
index 0000000000..a4af880fb4
--- /dev/null
+++ b/web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+  import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
+  import { systemConfig } from '$lib/stores/server-config.store';
+  import { updateConfig } from '@immich/sdk';
+  import { onDestroy } from 'svelte';
+  import { t } from 'svelte-i18n';
+  import { get } from 'svelte/store';
+
+  onDestroy(async () => {
+    const cfg = get(systemConfig);
+
+    await updateConfig({
+      systemConfigDto: cfg,
+    });
+  });
+</script>
+
+<div class="flex flex-col gap-4">
+  <p>
+    {$t('onboarding_privacy_description')}
+  </p>
+
+  {#if $systemConfig}
+    <SettingSwitch
+      title={$t('admin.map_settings')}
+      subtitle={$t('admin.map_implications')}
+      bind:checked={$systemConfig.map.enabled}
+    />
+    <SettingSwitch
+      title={$t('admin.version_check_settings')}
+      subtitle={$t('admin.version_check_implications')}
+      bind:checked={$systemConfig.newVersionCheck.enabled}
+    />
+  {/if}
+</div>
diff --git a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
index de2ce7e980..baa45779e5 100644
--- a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte
@@ -5,18 +5,7 @@
   import { featureFlags } from '$lib/stores/server-config.store';
   import { user } from '$lib/stores/user.store';
   import { getConfig, type SystemConfigDto } from '@immich/sdk';
-  import { Button } from '@immich/ui';
-  import { mdiArrowLeft, mdiCheck, mdiHarddisk } from '@mdi/js';
   import { onMount } from 'svelte';
-  import { t } from 'svelte-i18n';
-  import OnboardingCard from './onboarding-card.svelte';
-
-  interface Props {
-    onDone: () => void;
-    onPrevious: () => void;
-  }
-
-  let { onDone, onPrevious }: Props = $props();
 
   let config: SystemConfigDto | undefined = $state();
   let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>();
@@ -24,9 +13,13 @@
   onMount(async () => {
     config = await getConfig();
   });
+
+  export const save = async () => {
+    await adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate });
+  };
 </script>
 
-<OnboardingCard title={$t('admin.storage_template_settings')} icon={mdiHarddisk}>
+<div class="flex flex-col">
   <p>
     <FormatMessage key="admin.storage_template_onboarding_description">
       {#snippet children({ message })}
@@ -48,36 +41,9 @@
             onSave={(config) => adminSettingsComponent?.handleSave(config)}
             onReset={(options) => adminSettingsComponent?.handleReset(options)}
             duration={0}
-          >
-            <div class="flex pt-4">
-              <div class="w-full flex place-content-start">
-                <Button
-                  shape="round"
-                  leadingIcon={mdiArrowLeft}
-                  class="flex gap-2 place-content-center"
-                  onclick={() => onPrevious()}
-                >
-                  <p>{$t('privacy')}</p>
-                </Button>
-              </div>
-              <div class="flex w-full place-content-end">
-                <Button
-                  shape="round"
-                  trailingIcon={mdiCheck}
-                  onclick={() => {
-                    adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate });
-                    onDone();
-                  }}
-                >
-                  <span class="flex place-content-center place-items-center gap-2">
-                    {$t('done')}
-                  </span>
-                </Button>
-              </div>
-            </div>
-          </StorageTemplateSettings>
+          />
         {/if}
       {/snippet}
     </AdminSettings>
   {/if}
-</OnboardingCard>
+</div>
diff --git a/web/src/lib/components/onboarding-page/onboarding-theme.svelte b/web/src/lib/components/onboarding-page/onboarding-theme.svelte
index b128a9755c..26e8fd9c7a 100644
--- a/web/src/lib/components/onboarding-page/onboarding-theme.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-theme.svelte
@@ -3,27 +3,16 @@
   import Icon from '$lib/components/elements/icon.svelte';
   import { Theme } from '$lib/constants';
   import { themeManager } from '$lib/managers/theme-manager.svelte';
-  import { Button } from '@immich/ui';
-  import { mdiArrowRight, mdiThemeLightDark } from '@mdi/js';
   import { t } from 'svelte-i18n';
-  import OnboardingCard from './onboarding-card.svelte';
-
-  interface Props {
-    onDone: () => void;
-  }
-
-  let { onDone }: Props = $props();
 </script>
 
-<OnboardingCard icon={mdiThemeLightDark} title={$t('color_theme')}>
-  <div>
-    <p class="pb-6 font-light">{$t('onboarding_theme_description')}</p>
-  </div>
+<div class="flex flex-col gap-4">
+  <p>{$t('onboarding_theme_description')}</p>
 
-  <div class="flex gap-4 mb-6">
+  <div class="flex gap-4">
     <button
       type="button"
-      class="w-1/2 aspect-square bg-light dark:bg-dark rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-dark-primary/80 border-immich-primary dark:border dark:border-transparent"
+      class="w-1/2 aspect-square bg-light dark:bg-dark rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-primary dark:border dark:border-transparent"
       onclick={() => themeManager.setTheme(Theme.LIGHT)}
     >
       <div
@@ -35,7 +24,7 @@
     </button>
     <button
       type="button"
-      class="dark w-1/2 aspect-square bg-light rounded-3xl dark:border-[3px] dark:border-immich-dark-primary/80 dark:border-immich-dark-primary border border-transparent"
+      class="dark w-1/2 aspect-square bg-light rounded-3xl dark:border-[3px] dark:border-immich-dark-primary border border-transparent"
       onclick={() => themeManager.setTheme(Theme.DARK)}
     >
       <div
@@ -46,17 +35,4 @@
       </div>
     </button>
   </div>
-
-  <div class="flex">
-    <div class="w-full flex place-content-end">
-      <Button
-        trailingIcon={mdiArrowRight}
-        shape="round"
-        class="flex gap-2 place-content-center"
-        onclick={() => onDone()}
-      >
-        <p>{$t('privacy')}</p>
-      </Button>
-    </div>
-  </div>
-</OnboardingCard>
+</div>
diff --git a/web/src/lib/components/onboarding-page/onboarding-user-privacy.svelte b/web/src/lib/components/onboarding-page/onboarding-user-privacy.svelte
new file mode 100644
index 0000000000..d65ade1b18
--- /dev/null
+++ b/web/src/lib/components/onboarding-page/onboarding-user-privacy.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+  import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
+  import { preferences } from '$lib/stores/user.store';
+  import { handleError } from '$lib/utils/handle-error';
+  import { updateMyPreferences } from '@immich/sdk';
+  import { onDestroy } from 'svelte';
+  import { t } from 'svelte-i18n';
+
+  let gCastEnabled = $state($preferences?.cast?.gCastEnabled ?? false);
+
+  onDestroy(async () => {
+    try {
+      const data = await updateMyPreferences({
+        userPreferencesUpdateDto: {
+          cast: { gCastEnabled },
+        },
+      });
+
+      $preferences = { ...data };
+    } catch (error) {
+      handleError(error, $t('errors.unable_to_update_settings'));
+    }
+  });
+</script>
+
+<div class="flex flex-col gap-4">
+  <p>
+    {$t('onboarding_privacy_description')}
+  </p>
+
+  <SettingSwitch title={$t('gcast_enabled')} subtitle={$t('gcast_enabled_description')} bind:checked={gCastEnabled} />
+</div>
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte
index 91282ae9bc..8d5800e9a8 100644
--- a/web/src/lib/components/shared-components/combobox.svelte
+++ b/web/src/lib/components/shared-components/combobox.svelte
@@ -348,7 +348,7 @@
   <ul
     role="listbox"
     id={listboxId}
-    transition:fly={{ duration: 250 }}
+    in:fly={{ duration: 250 }}
     class="fixed z-1 text-start text-sm w-full overflow-y-auto bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-900"
     class:rounded-b-xl={dropdownDirection === 'bottom'}
     class:rounded-t-xl={dropdownDirection === 'top'}
diff --git a/web/src/lib/components/shared-components/settings/settings-language-selector.svelte b/web/src/lib/components/shared-components/settings/settings-language-selector.svelte
new file mode 100644
index 0000000000..7e395748a0
--- /dev/null
+++ b/web/src/lib/components/shared-components/settings/settings-language-selector.svelte
@@ -0,0 +1,58 @@
+<script lang="ts">
+  import { invalidateAll } from '$app/navigation';
+  import Combobox from '$lib/components/shared-components/combobox.svelte';
+  import { defaultLang, langs } from '$lib/constants';
+  import { lang } from '$lib/stores/preferences.store';
+  import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
+  import { locale as i18nLocale, t } from 'svelte-i18n';
+
+  interface Props {
+    showSettingDescription?: boolean;
+  }
+
+  let { showSettingDescription = false }: Props = $props();
+
+  const langOptions = langs
+    .map((lang) => ({ label: lang.name, value: lang.code }))
+    .sort((a, b) => {
+      if (b.label.startsWith('Development')) {
+        return -1;
+      }
+      return a.label.localeCompare(b.label);
+    });
+
+  const defaultLangOption = { label: defaultLang.name, value: defaultLang.code };
+
+  const handleLanguageChange = async (newLang: string | undefined) => {
+    if (newLang) {
+      $lang = newLang;
+      await i18nLocale.set(newLang);
+      await invalidateAll();
+    }
+  };
+
+  let closestLanguage = $derived(getClosestAvailableLocale([$lang], langCodes));
+</script>
+
+<div class={showSettingDescription ? 'grid grid-cols-2' : ''}>
+  {#if showSettingDescription}
+    <div>
+      <div class="flex h-[26px] place-items-center gap-1">
+        <label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={$t('language')}>
+          {$t('language')}
+        </label>
+      </div>
+
+      <p class="text-sm dark:text-immich-dark-fg">{$t('language_setting_description')}</p>
+    </div>
+  {/if}
+
+  <Combobox
+    label={$t('language')}
+    hideLabel={true}
+    selectedOption={langOptions.find(({ value }) => value === closestLanguage) || defaultLangOption}
+    placeholder={$t('language')}
+    onSelect={(event) => handleLanguageChange(event?.value)}
+    options={langOptions}
+  />
+</div>
diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte
index f1d8e14787..adb37d5d93 100644
--- a/web/src/lib/components/user-settings-page/app-settings.svelte
+++ b/web/src/lib/components/user-settings-page/app-settings.svelte
@@ -1,22 +1,20 @@
 <script lang="ts">
-  import { invalidateAll } from '$app/navigation';
   import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
   import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
-  import { defaultLang, fallbackLocale, langs, locales } from '$lib/constants';
+  import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
+  import { fallbackLocale, locales } from '$lib/constants';
   import { themeManager } from '$lib/managers/theme-manager.svelte';
   import {
     alwaysLoadOriginalFile,
-    lang,
     locale,
     loopVideo,
     playVideoThumbnailOnHover,
     showDeleteModal,
   } from '$lib/stores/preferences.store';
   import { findLocale } from '$lib/utils';
-  import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
   import { onMount } from 'svelte';
-  import { locale as i18nLocale, t } from 'svelte-i18n';
+  import { t } from 'svelte-i18n';
   import { fade } from 'svelte/transition';
 
   let time = $state(new Date());
@@ -44,24 +42,6 @@
     $locale = $locale ? undefined : fallbackLocale.code;
   };
 
-  const langOptions = langs
-    .map((lang) => ({ label: lang.name, value: lang.code }))
-    .sort((a, b) => {
-      if (b.label.startsWith('Development')) {
-        return -1;
-      }
-      return a.label.localeCompare(b.label);
-    });
-  const defaultLangOption = { label: defaultLang.name, value: defaultLang.code };
-
-  const handleLanguageChange = async (newLang: string | undefined) => {
-    if (newLang) {
-      $lang = newLang;
-      await i18nLocale.set(newLang);
-      await invalidateAll();
-    }
-  };
-
   const handleLocaleChange = (newLocale: string | undefined) => {
     if (newLocale) {
       $locale = newLocale;
@@ -87,7 +67,6 @@
     value: findLocale(editedLocale).code || fallbackLocale.code,
     label: findLocale(editedLocale).name || fallbackLocale.name,
   });
-  let closestLanguage = $derived(getClosestAvailableLocale([$lang], langCodes));
 </script>
 
 <section class="my-4">
@@ -103,14 +82,7 @@
       </div>
 
       <div class="ms-4">
-        <SettingCombobox
-          comboboxPlaceholder={$t('language')}
-          selectedOption={langOptions.find(({ value }) => value === closestLanguage) || defaultLangOption}
-          options={langOptions}
-          title={$t('language')}
-          subtitle={$t('language_setting_description')}
-          onSelect={(combobox) => handleLanguageChange(combobox?.value)}
-        />
+        <SettingsLanguageSelector showSettingDescription />
       </div>
 
       <div class="ms-4">
diff --git a/web/src/lib/models/onboarding-role.ts b/web/src/lib/models/onboarding-role.ts
new file mode 100644
index 0000000000..4efc307932
--- /dev/null
+++ b/web/src/lib/models/onboarding-role.ts
@@ -0,0 +1,4 @@
+export enum OnboardingRole {
+  SERVER = 'server',
+  USER = 'user',
+}
diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts
index 254db71946..ce2d8c2842 100644
--- a/web/src/lib/stores/server-config.store.ts
+++ b/web/src/lib/stores/server-config.store.ts
@@ -1,4 +1,11 @@
-import { getServerConfig, getServerFeatures, type ServerConfigDto, type ServerFeaturesDto } from '@immich/sdk';
+import {
+  getConfig,
+  getServerConfig,
+  getServerFeatures,
+  type ServerConfigDto,
+  type ServerFeaturesDto,
+  type SystemConfigDto,
+} from '@immich/sdk';
 import { writable } from 'svelte/store';
 
 export type FeatureFlags = ServerFeaturesDto & { loaded: boolean };
@@ -37,9 +44,17 @@ export const serverConfig = writable<ServerConfig>({
   publicUsers: true,
 });
 
+export type SystemConfig = SystemConfigDto & { loaded: boolean };
+export const systemConfig = writable<SystemConfig>();
+
 export const retrieveServerConfig = async () => {
   const [flags, config] = await Promise.all([getServerFeatures(), getServerConfig()]);
 
   featureFlags.update(() => ({ ...flags, loaded: true }));
   serverConfig.update(() => ({ ...config, loaded: true }));
 };
+
+export const retrieveSystemConfig = async () => {
+  const config = await getConfig();
+  systemConfig.update(() => ({ ...config, loaded: true }));
+};
diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte
index 7937a55f80..fca888006a 100644
--- a/web/src/routes/auth/login/+page.svelte
+++ b/web/src/routes/auth/login/+page.svelte
@@ -26,7 +26,6 @@
   let oauthLoading = $state(true);
 
   const onSuccess = async (user: LoginResponseDto) => {
-    console.log(data.continueUrl);
     await goto(data.continueUrl, { invalidateAll: true });
     eventManager.emit('auth.login', user);
   };
@@ -43,6 +42,12 @@
     if (oauth.isCallback(globalThis.location)) {
       try {
         const user = await oauth.login(globalThis.location);
+
+        if (!user.isOnboarded) {
+          await onOnboarding();
+          return;
+        }
+
         await onSuccess(user);
         return;
       } catch (error) {
@@ -79,10 +84,19 @@
         return;
       }
 
+      // change the user password before we onboard them
       if (!user.isAdmin && user.shouldChangePassword) {
         await onFirstLogin();
         return;
       }
+
+      // We want to onboard after the first login since their password will change
+      // and handleLogin will be called again (relogin). We then do onboarding on that next call.
+      if (!user.isOnboarded) {
+        await onOnboarding();
+        return;
+      }
+
       await onSuccess(user);
       return;
     } catch (error) {
diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte
index 091681002e..2978e4fd2a 100644
--- a/web/src/routes/auth/onboarding/+page.svelte
+++ b/web/src/routes/auth/onboarding/+page.svelte
@@ -1,17 +1,21 @@
 <script lang="ts">
-  import { run } from 'svelte/legacy';
-
   import { goto } from '$app/navigation';
   import { page } from '$app/stores';
+  import OnboardingCard from '$lib/components/onboarding-page/onboarding-card.svelte';
   import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte';
-  import OnboardingPrivacy from '$lib/components/onboarding-page/onboarding-privacy.svelte';
+  import OnboardingLocale from '$lib/components/onboarding-page/onboarding-language.svelte';
+  import OnboardingServerPrivacy from '$lib/components/onboarding-page/onboarding-server-privacy.svelte';
   import OnboardingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte';
   import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte';
+  import OnboardingUserPrivacy from '$lib/components/onboarding-page/onboarding-user-privacy.svelte';
   import { AppRoute, QueryParameter } from '$lib/constants';
-  import { retrieveServerConfig } from '$lib/stores/server-config.store';
-  import { updateAdminOnboarding } from '@immich/sdk';
-
-  let index = $state(0);
+  import { OnboardingRole } from '$lib/models/onboarding-role';
+  import { retrieveServerConfig, retrieveSystemConfig, serverConfig } from '$lib/stores/server-config.store';
+  import { user } from '$lib/stores/user.store';
+  import { setUserOnboarding, updateAdminOnboarding } from '@immich/sdk';
+  import { mdiHarddisk, mdiIncognito, mdiThemeLightDark, mdiTranslate } from '@mdi/js';
+  import { onMount } from 'svelte';
+  import { t } from 'svelte-i18n';
 
   interface OnboardingStep {
     name: string;
@@ -19,41 +23,116 @@
       | typeof OnboardingHello
       | typeof OnboardingTheme
       | typeof OnboardingStorageTemplate
-      | typeof OnboardingPrivacy;
+      | typeof OnboardingServerPrivacy
+      | typeof OnboardingUserPrivacy
+      | typeof OnboardingLocale;
+    role: OnboardingRole;
+    title?: string;
+    icon?: string;
   }
 
-  const onboardingSteps: OnboardingStep[] = [
-    { name: 'hello', component: OnboardingHello },
-    { name: 'theme', component: OnboardingTheme },
-    { name: 'privacy', component: OnboardingPrivacy },
-    { name: 'storage', component: OnboardingStorageTemplate },
-  ];
+  const onboardingSteps: OnboardingStep[] = $derived([
+    { name: 'hello', component: OnboardingHello, role: OnboardingRole.USER },
+    {
+      name: 'theme',
+      component: OnboardingTheme,
+      role: OnboardingRole.USER,
+      title: $t('theme'),
+      icon: mdiThemeLightDark,
+    },
+    {
+      name: 'language',
+      component: OnboardingLocale,
+      role: OnboardingRole.USER,
+      title: $t('language'),
+      icon: mdiTranslate,
+    },
+    {
+      name: 'server_privacy',
+      component: OnboardingServerPrivacy,
+      role: OnboardingRole.SERVER,
+      title: $t('server_privacy'),
+      icon: mdiIncognito,
+    },
+    {
+      name: 'user_privacy',
+      component: OnboardingUserPrivacy,
+      role: OnboardingRole.USER,
+      title: $t('user_privacy'),
+      icon: mdiIncognito,
+    },
+    {
+      name: 'storage_template',
+      component: OnboardingStorageTemplate,
+      role: OnboardingRole.SERVER,
+      title: $t('admin.storage_template_settings'),
+      icon: mdiHarddisk,
+    },
+  ]);
 
-  run(() => {
+  let index = $state(0);
+  let userRole = $derived($user.isAdmin && !$serverConfig.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER);
+
+  let onboardingStepCount = $derived(onboardingSteps.filter((step) => shouldRunStep(step.role, userRole)).length);
+  let onboardingProgress = $derived(
+    onboardingSteps.filter((step, i) => shouldRunStep(step.role, userRole) && i <= index).length - 1,
+  );
+
+  const shouldRunStep = (stepRole: OnboardingRole, userRole: OnboardingRole) => {
+    return (
+      stepRole === OnboardingRole.USER ||
+      (stepRole === OnboardingRole.SERVER && userRole === OnboardingRole.SERVER && !$serverConfig.isOnboarded)
+    );
+  };
+
+  $effect(() => {
     const stepState = $page.url.searchParams.get('step');
     const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState);
     index = temporaryIndex === -1 ? 0 : temporaryIndex;
   });
 
-  const handleDoneClicked = async () => {
-    if (index >= onboardingSteps.length - 1) {
-      await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } });
-      await retrieveServerConfig();
+  const previousStepIndex = $derived(
+    onboardingSteps.findLastIndex((step, i) => shouldRunStep(step.role, userRole) && i < index),
+  );
+
+  const nextStepIndex = $derived(
+    onboardingSteps.findIndex((step, i) => shouldRunStep(step.role, userRole) && i > index),
+  );
+
+  const handleNextClicked = async () => {
+    if (nextStepIndex == -1) {
+      if ($user.isAdmin) {
+        await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } });
+        await retrieveServerConfig();
+      }
+
+      await setUserOnboarding({
+        onboardingDto: { isOnboarded: true },
+      });
+
       await goto(AppRoute.PHOTOS);
     } else {
-      index++;
-      await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
+      await goto(
+        `${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[nextStepIndex].name}`,
+      );
     }
   };
 
   const handlePrevious = async () => {
-    if (index >= 1) {
-      index--;
-      await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
+    if (previousStepIndex === -1) {
+      return;
     }
+
+    await goto(
+      `${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[previousStepIndex].name}`,
+    );
   };
 
-  const SvelteComponent = $derived(onboardingSteps[index].component);
+  onMount(async () => {
+    await retrieveSystemConfig();
+  });
+
+  const OnboardingStep = $derived(onboardingSteps[index].component);
 </script>
 
 <section id="onboarding-page" class="min-w-dvw flex min-h-dvh p-4">
@@ -61,11 +140,20 @@
     <div class=" bg-gray-300 dark:bg-gray-600 rounded-md h-2">
       <div
         class="progress-bar bg-immich-primary dark:bg-immich-dark-primary h-2 rounded-md transition-all duration-200 ease-out"
-        style="width: {(index / (onboardingSteps.length - 1)) * 100}%"
+        style="width: {(onboardingProgress / onboardingStepCount) * 100}%"
       ></div>
     </div>
     <div class="py-8 flex place-content-center place-items-center m-auto">
-      <SvelteComponent onDone={handleDoneClicked} onPrevious={handlePrevious} />
+      <OnboardingCard
+        title={onboardingSteps[index].title}
+        icon={onboardingSteps[index].icon}
+        onNext={handleNextClicked}
+        onPrevious={handlePrevious}
+        previousTitle={onboardingSteps[previousStepIndex]?.title}
+        nextTitle={onboardingSteps[nextStepIndex]?.title}
+      >
+        <OnboardingStep />
+      </OnboardingCard>
     </div>
   </div>
 </section>
diff --git a/web/src/routes/auth/onboarding/+page.ts b/web/src/routes/auth/onboarding/+page.ts
index 86c19c10a8..66cb3de2c1 100644
--- a/web/src/routes/auth/onboarding/+page.ts
+++ b/web/src/routes/auth/onboarding/+page.ts
@@ -3,7 +3,7 @@ import { getFormatter } from '$lib/utils/i18n';
 import type { PageLoad } from './$types';
 
 export const load = (async ({ url }) => {
-  await authenticate(url, { admin: true });
+  await authenticate(url);
 
   const $t = await getFormatter();