diff --git a/.fpm b/.fpm
new file mode 100644
index 00000000..fbd7b5e3
--- /dev/null
+++ b/.fpm
@@ -0,0 +1,6 @@
+-s dir
+--name defguard-proxy
+--architecture x86_64
+--description "defguard proxy service"
+--url "https://defguard.net/"
+--maintainer "teonite"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 34cb975e..dfa91a13 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -113,6 +113,26 @@ jobs:
             [registry."docker.io"]
               mirrors = ["dockerhub-proxy.teonite.net"]
 
+      - name: Install pnpm
+        uses: pnpm/action-setup@v4
+        with:
+          version: 9
+
+      - name: Use Node.js 20
+        uses: actions/setup-node@v4
+        with:
+          node-version: 20
+          cache: 'pnpm'
+          cache-dependency-path: ./web/pnpm-lock.yaml
+
+      - name: Install frontend dependencies
+        run: pnpm install --ignore-scripts --frozen-lockfile
+        working-directory: web
+
+      - name: Build frontend
+        run: pnpm build
+        working-directory: web
+
       - name: Build release binary
         uses: actions-rs/cargo@v1
         with:
@@ -140,3 +160,39 @@ jobs:
           asset_path: defguard-proxy-${{ github.ref_name }}-${{ matrix.target }}.tar.gz
           asset_name: defguard-proxy-${{ github.ref_name }}-${{ matrix.target }}.tar.gz
           asset_content_type: application/octet-stream
+
+      - name: Build DEB package
+        if: matrix.build == 'linux'
+        uses: bpicode/github-action-fpm@master
+        with:
+          fpm_args: "defguard-proxy-${{ github.ref_name }}-${{ matrix.target }}=/usr/bin/defguard-proxy defguard-proxy.service=/usr/lib/systemd/system/defguard-proxy.service example-config.toml=/etc/defguard/proxy.toml"
+          fpm_opts: "--debug --output-type deb --version ${{ env.VERSION }} --package defguard-proxy-${{ env.VERSION }}-${{ matrix.target }}.deb"
+
+      - name: Upload DEB
+        if: matrix.build == 'linux'
+        uses: actions/upload-release-asset@v1.0.2
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          upload_url: ${{ needs.create-release.outputs.upload_url }}
+          asset_path: defguard-proxy-${{ env.VERSION }}-${{ matrix.target }}.deb
+          asset_name: defguard-proxy-${{ env.VERSION }}-${{ matrix.target }}.deb
+          asset_content_type: application/octet-stream
+
+      - name: Build RPM package
+        if: matrix.build == 'linux'
+        uses: bpicode/github-action-fpm@master
+        with:
+          fpm_args: "defguard-proxy-${{ github.ref_name }}-${{ matrix.target }}=/usr/bin/defguard-proxy defguard-proxy.service=/usr/lib/systemd/system/defguard-proxy.service example-config.toml=/etc/defguard/proxy.toml"
+          fpm_opts: "--debug --output-type rpm --version ${{ env.VERSION }} --package defguard-proxy-${{ env.VERSION }}-${{ matrix.target }}.rpm"
+
+      - name: Upload RPM
+        if: matrix.build == 'linux'
+        uses: actions/upload-release-asset@v1.0.2
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          upload_url: ${{ needs.create-release.outputs.upload_url }}
+          asset_path: defguard-proxy-${{ env.VERSION }}-${{ matrix.target }}.rpm
+          asset_name: defguard-proxy-${{ env.VERSION }}-${{ matrix.target }}.rpm
+          asset_content_type: application/octet-stream
diff --git a/Cargo.lock b/Cargo.lock
index 93a8125b..431b50fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -77,9 +77,9 @@ dependencies = [
 
 [[package]]
 name = "anstyle"
-version = "1.0.4"
+version = "1.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87"
+checksum = "2faccea4cc4ab4a667ce676a30e8ec13922a692c99bb8f5b11f1502c72e04220"
 
 [[package]]
 name = "anstyle-parse"
@@ -343,6 +343,22 @@ dependencies = [
  "generic-array",
 ]
 
+[[package]]
+name = "bstr"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
+
 [[package]]
 name = "bytes"
 version = "1.5.0"
@@ -469,6 +485,12 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
+
 [[package]]
 name = "crypto-common"
 version = "0.1.6"
@@ -489,9 +511,22 @@ dependencies = [
  "cipher",
 ]
 
+[[package]]
+name = "dashmap"
+version = "5.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
+dependencies = [
+ "cfg-if",
+ "hashbrown 0.14.3",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
 [[package]]
 name = "defguard-proxy"
-version = "0.4.0"
+version = "0.5.0"
 dependencies = [
  "anyhow",
  "axum 0.7.4",
@@ -499,17 +534,22 @@ dependencies = [
  "axum-extra",
  "clap",
  "dotenvy",
+ "log",
+ "mime_guess",
  "prost",
  "prost-build",
+ "rust-embed",
  "serde",
  "serde_json",
  "thiserror",
  "time",
  "tokio",
  "tokio-stream",
+ "toml",
  "tonic",
  "tonic-build",
  "tower-http",
+ "tower_governor",
  "tracing",
  "tracing-subscriber",
  "url",
@@ -609,6 +649,21 @@ dependencies = [
  "thiserror",
 ]
 
+[[package]]
+name = "futures"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
 [[package]]
 name = "futures-channel"
 version = "0.3.30"
@@ -616,6 +671,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
 dependencies = [
  "futures-core",
+ "futures-sink",
 ]
 
 [[package]]
@@ -624,6 +680,34 @@ version = "0.3.30"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
 
+[[package]]
+name = "futures-executor"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "futures-sink"
 version = "0.3.30"
@@ -636,16 +720,28 @@ version = "0.3.30"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
 
+[[package]]
+name = "futures-timer"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
+
 [[package]]
 name = "futures-util"
 version = "0.3.30"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
 dependencies = [
+ "futures-channel",
  "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
  "futures-task",
+ "memchr",
  "pin-project-lite",
  "pin-utils",
+ "slab",
 ]
 
 [[package]]
@@ -685,6 +781,39 @@ version = "0.28.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
 
+[[package]]
+name = "globset"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata 0.4.5",
+ "regex-syntax 0.8.2",
+]
+
+[[package]]
+name = "governor"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b"
+dependencies = [
+ "cfg-if",
+ "dashmap",
+ "futures",
+ "futures-timer",
+ "no-std-compat",
+ "nonzero_ext",
+ "parking_lot",
+ "portable-atomic",
+ "quanta",
+ "rand",
+ "smallvec",
+ "spinning_top",
+]
+
 [[package]]
 name = "h2"
 version = "0.3.24"
@@ -697,7 +826,7 @@ dependencies = [
  "futures-sink",
  "futures-util",
  "http 0.2.11",
- "indexmap 2.1.0",
+ "indexmap 2.2.2",
  "slab",
  "tokio",
  "tokio-util",
@@ -716,7 +845,7 @@ dependencies = [
  "futures-sink",
  "futures-util",
  "http 1.0.0",
- "indexmap 2.1.0",
+ "indexmap 2.2.2",
  "slab",
  "tokio",
  "tokio-util",
@@ -911,12 +1040,11 @@ dependencies = [
 
 [[package]]
 name = "hyper-util"
-version = "0.1.2"
+version = "0.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bdea9aac0dbe5a9240d68cfd9501e2db94222c6dc06843e06640b9e07f0fdc67"
+checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
 dependencies = [
  "bytes",
- "futures-channel",
  "futures-util",
  "http 1.0.0",
  "http-body 1.0.0",
@@ -924,7 +1052,6 @@ dependencies = [
  "pin-project-lite",
  "socket2",
  "tokio",
- "tracing",
 ]
 
 [[package]]
@@ -949,9 +1076,9 @@ dependencies = [
 
 [[package]]
 name = "indexmap"
-version = "2.1.0"
+version = "2.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f"
+checksum = "824b2ae422412366ba479e8111fd301f7b5faece8149317bb81925979a53f520"
 dependencies = [
  "equivalent",
  "hashbrown 0.14.3",
@@ -981,6 +1108,15 @@ version = "1.0.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
 
+[[package]]
+name = "js-sys"
+version = "0.3.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
+dependencies = [
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "lazy_static"
 version = "1.4.0"
@@ -989,9 +1125,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
 
 [[package]]
 name = "libc"
-version = "0.2.152"
+version = "0.2.153"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7"
+checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
 
 [[package]]
 name = "linux-raw-sys"
@@ -999,11 +1135,24 @@ version = "0.4.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
 
+[[package]]
+name = "lock_api"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
 [[package]]
 name = "log"
 version = "0.4.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
+dependencies = [
+ "serde",
+]
 
 [[package]]
 name = "matchers"
@@ -1068,12 +1217,24 @@ version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a"
 
+[[package]]
+name = "no-std-compat"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
+
 [[package]]
 name = "nonempty"
 version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
 
+[[package]]
+name = "nonzero_ext"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
+
 [[package]]
 name = "nu-ansi-term"
 version = "0.46.0"
@@ -1084,6 +1245,12 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
 [[package]]
 name = "num_cpus"
 version = "1.16.0"
@@ -1127,6 +1294,29 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
 
+[[package]]
+name = "parking_lot"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.1",
+ "smallvec",
+ "windows-targets 0.52.0",
+]
+
 [[package]]
 name = "percent-encoding"
 version = "2.3.1"
@@ -1140,23 +1330,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9"
 dependencies = [
  "fixedbitset",
- "indexmap 2.1.0",
+ "indexmap 2.2.2",
 ]
 
 [[package]]
 name = "pin-project"
-version = "1.1.3"
+version = "1.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422"
+checksum = "0302c4a0442c456bd56f841aee5c3bfd17967563f6fadc9ceb9f9c23cf3807e0"
 dependencies = [
  "pin-project-internal",
 ]
 
 [[package]]
 name = "pin-project-internal"
-version = "1.1.3"
+version = "1.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405"
+checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1187,6 +1377,12 @@ dependencies = [
  "universal-hash",
 ]
 
+[[package]]
+name = "portable-atomic"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0"
+
 [[package]]
 name = "powerfmt"
 version = "0.2.0"
@@ -1272,6 +1468,21 @@ dependencies = [
  "prost",
 ]
 
+[[package]]
+name = "quanta"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5167a477619228a0b284fac2674e3c388cba90631d7b7de620e6f1fcd08da5"
+dependencies = [
+ "crossbeam-utils",
+ "libc",
+ "once_cell",
+ "raw-cpuid",
+ "wasi",
+ "web-sys",
+ "winapi",
+]
+
 [[package]]
 name = "quote"
 version = "1.0.35"
@@ -1311,6 +1522,15 @@ dependencies = [
  "getrandom",
 ]
 
+[[package]]
+name = "raw-cpuid"
+version = "11.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e29830cbb1290e404f24c73af91c5d8d631ce7e128691e9477556b540cd01ecd"
+dependencies = [
+ "bitflags 2.4.2",
+]
+
 [[package]]
 name = "redox_syscall"
 version = "0.4.1"
@@ -1320,6 +1540,15 @@ dependencies = [
  "bitflags 1.3.2",
 ]
 
+[[package]]
+name = "redox_syscall"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e"
+dependencies = [
+ "bitflags 2.4.2",
+]
+
 [[package]]
 name = "regex"
 version = "1.10.3"
@@ -1328,7 +1557,7 @@ checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15"
 dependencies = [
  "aho-corasick",
  "memchr",
- "regex-automata 0.4.4",
+ "regex-automata 0.4.5",
  "regex-syntax 0.8.2",
 ]
 
@@ -1343,9 +1572,9 @@ dependencies = [
 
 [[package]]
 name = "regex-automata"
-version = "0.4.4"
+version = "0.4.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b7fa1134405e2ec9353fd416b17f8dacd46c473d7d3fd1cf202706a14eb792a"
+checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd"
 dependencies = [
  "aho-corasick",
  "memchr",
@@ -1378,6 +1607,41 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "rust-embed"
+version = "8.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19549741604902eb99a7ed0ee177a0663ee1eda51a29f71401f166e47e77806a"
+dependencies = [
+ "rust-embed-impl",
+ "rust-embed-utils",
+ "walkdir",
+]
+
+[[package]]
+name = "rust-embed-impl"
+version = "8.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb9f96e283ec64401f30d3df8ee2aaeb2561f34c824381efa24a35f79bf40ee4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rust-embed-utils",
+ "syn",
+ "walkdir",
+]
+
+[[package]]
+name = "rust-embed-utils"
+version = "8.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c74a686185620830701348de757fd36bef4aa9680fd23c49fc539ddcc1af32"
+dependencies = [
+ "globset",
+ "sha2",
+ "walkdir",
+]
+
 [[package]]
 name = "rustc-demangle"
 version = "0.1.23"
@@ -1386,9 +1650,9 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
 
 [[package]]
 name = "rustix"
-version = "0.38.30"
+version = "0.38.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca"
+checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949"
 dependencies = [
  "bitflags 2.4.2",
  "errno",
@@ -1452,6 +1716,15 @@ version = "1.0.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c"
 
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
 [[package]]
 name = "schannel"
 version = "0.1.23"
@@ -1461,6 +1734,12 @@ dependencies = [
  "windows-sys 0.52.0",
 ]
 
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
 [[package]]
 name = "sct"
 version = "0.7.1"
@@ -1496,18 +1775,18 @@ dependencies = [
 
 [[package]]
 name = "serde"
-version = "1.0.195"
+version = "1.0.196"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02"
+checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
 name = "serde_derive"
-version = "1.0.195"
+version = "1.0.196"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c"
+checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1516,9 +1795,9 @@ dependencies = [
 
 [[package]]
 name = "serde_json"
-version = "1.0.111"
+version = "1.0.113"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4"
+checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79"
 dependencies = [
  "itoa",
  "ryu",
@@ -1535,6 +1814,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "serde_spanned"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "serde_urlencoded"
 version = "0.7.1"
@@ -1558,6 +1846,17 @@ dependencies = [
  "digest",
 ]
 
+[[package]]
+name = "sha2"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
@@ -1598,6 +1897,15 @@ version = "0.9.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
 
+[[package]]
+name = "spinning_top"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
+dependencies = [
+ "lock_api",
+]
+
 [[package]]
 name = "strsim"
 version = "0.10.0"
@@ -1635,7 +1943,7 @@ checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa"
 dependencies = [
  "cfg-if",
  "fastrand",
- "redox_syscall",
+ "redox_syscall 0.4.1",
  "rustix",
  "windows-sys 0.52.0",
 ]
@@ -1672,12 +1980,13 @@ dependencies = [
 
 [[package]]
 name = "time"
-version = "0.3.31"
+version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e"
+checksum = "00b24b79b7a07f10209f19e683ca1e289d80b1e76ffa8c2b779718566a083679"
 dependencies = [
  "deranged",
  "itoa",
+ "num-conv",
  "powerfmt",
  "serde",
  "time-core",
@@ -1692,10 +2001,11 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
 
 [[package]]
 name = "time-macros"
-version = "0.2.16"
+version = "0.2.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f"
+checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774"
 dependencies = [
+ "num-conv",
  "time-core",
 ]
 
@@ -1716,9 +2026,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
 
 [[package]]
 name = "tokio"
-version = "1.35.1"
+version = "1.36.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104"
+checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
 dependencies = [
  "backtrace",
  "bytes",
@@ -1787,6 +2097,40 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "toml"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6a4b9e8023eb94392d3dca65d717c53abc5dad49c07cb65bb8fcd87115fa325"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1"
+dependencies = [
+ "indexmap 2.2.2",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "winnow",
+]
+
 [[package]]
 name = "tonic"
 version = "0.10.2"
@@ -1889,6 +2233,22 @@ version = "0.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
 
+[[package]]
+name = "tower_governor"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "313fa625fea5790ed56360a30ea980e41229cf482b4835801a67ef1922bf63b9"
+dependencies = [
+ "axum 0.7.4",
+ "forwarded-header-value",
+ "governor",
+ "http 1.0.0",
+ "pin-project",
+ "thiserror",
+ "tower",
+ "tracing",
+]
+
 [[package]]
 name = "tracing"
 version = "0.1.40"
@@ -2038,6 +2398,16 @@ version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
 
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
 [[package]]
 name = "want"
 version = "0.3.1"
@@ -2053,6 +2423,70 @@ version = "0.11.0+wasi-snapshot-preview1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
 
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
+
+[[package]]
+name = "web-sys"
+version = "0.3.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "which"
 version = "4.4.2"
@@ -2081,6 +2515,15 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
 
+[[package]]
+name = "winapi-util"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "winapi-x86_64-pc-windows-gnu"
 version = "0.4.0"
@@ -2218,3 +2661,12 @@ name = "windows_x86_64_msvc"
 version = "0.52.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04"
+
+[[package]]
+name = "winnow"
+version = "0.5.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "818ce546a11a9986bc24f93d0cdf38a8a1a400f1473ea8c82e59f6e0ffab9249"
+dependencies = [
+ "memchr",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 1697a977..b4f9eeef 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "defguard-proxy"
-version = "0.4.0"
+version = "0.5.0"
 edition = "2021"
 license = "Apache-2.0"
 homepage = "https://github.com/DefGuard/proxy"
@@ -23,9 +23,11 @@ tower-http = { version = "0.5", features = ["fs", "trace"] }
 # logging/tracing
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+log = { version = "0.4", features = ["serde"] }
 # data de/serialization
 serde = { version = "1.0", features = ["derive"] }
 serde_json = { version = "1.0" }
+toml = { version = "0.8", default-features = false, features = ["parse"] }
 # gRPC
 tonic = { version = "0.10", features = ["gzip", "tls", "tls-roots"] }
 prost = "0.12"
@@ -37,6 +39,10 @@ clap = { version = "4.4", features = ["derive", "env", "cargo"] }
 # other utils
 dotenvy = "0.15"
 url = "2.4"
+tower_governor = "0.4"
+# UI embedding
+rust-embed = { version = "8.4", features = ["include-exclude"] }
+mime_guess = "2.0"
 
 [build-dependencies]
 tonic-build = { version = "0.10" }
diff --git a/Dockerfile b/Dockerfile
index 7514038d..235fbb1c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,15 @@
+FROM node:19-alpine3.16 as web
+
+WORKDIR /app
+COPY web/package.json .
+COPY web/pnpm-lock.yaml .
+COPY web/.npmrc .
+RUN npm i -g pnpm
+RUN pnpm install --ignore-scripts --frozen-lockfile
+COPY web/ .
+RUN pnpm run generate-translation-types
+RUN pnpm build
+
 FROM rust:1.75 as chef
 
 WORKDIR /build
@@ -19,24 +31,14 @@ COPY --from=planner /build/recipe.json recipe.json
 RUN cargo chef cook --release --recipe-path recipe.json
 
 # build project
+COPY --from=web /app/dist ./web/dist
+COPY web/src/shared/images/svg ./web/src/shared/images/svg
 RUN apt-get update && apt-get -y install protobuf-compiler libprotobuf-dev
 COPY Cargo.toml Cargo.lock build.rs ./
 COPY src src
 COPY proto proto
 RUN cargo install --locked --path . --root /build
 
-FROM node:19-alpine3.16 as web
-
-WORKDIR /app
-COPY web/package.json .
-COPY web/pnpm-lock.yaml .
-COPY web/.npmrc .
-RUN npm i -g pnpm
-RUN pnpm install --ignore-scripts --frozen-lockfile
-COPY web/ .
-RUN pnpm run generate-translation-types
-RUN pnpm build
-
 # run
 FROM debian:bookworm-slim as runtime
 RUN apt-get update -y && \
@@ -44,6 +46,4 @@ RUN apt-get update -y && \
     rm -rf /var/lib/apt/lists/*
 WORKDIR /app
 COPY --from=builder /build/bin/defguard-proxy .
-COPY --from=web /app/dist ./web/dist
-COPY web/src/shared/images/svg ./web/src/shared/images/svg
 ENTRYPOINT ["./defguard-proxy"]
diff --git a/defguard-proxy.service b/defguard-proxy.service
new file mode 100644
index 00000000..cf215993
--- /dev/null
+++ b/defguard-proxy.service
@@ -0,0 +1,22 @@
+[Unit]
+Description=defguard proxy service
+Documentation=https://defguard.gitbook.io/defguard/
+Wants=network-online.target
+After=network-online.target
+
+[Service]
+DynamicUser=yes
+User=defguard
+ExecReload=/bin/kill -HUP $MAINPID
+ExecStart=/usr/bin/defguard-proxy --config /etc/defguard/proxy.toml
+KillMode=process
+KillSignal=SIGINT
+LimitNOFILE=65536
+LimitNPROC=infinity
+Restart=on-failure
+RestartSec=2
+TasksMax=infinity
+OOMScoreAdjust=-1000
+
+[Install]
+WantedBy=multi-user.target
diff --git a/example-config.toml b/example-config.toml
new file mode 100644
index 00000000..025d18d4
--- /dev/null
+++ b/example-config.toml
@@ -0,0 +1,18 @@
+# This is an example config file for defguard proxy
+# To use it fill in actual values for your deployment below
+
+# port the API server will listen on
+http_port = 8080
+# port the gRPC server will listen on
+grpc_port = 50051
+
+# gRPC SSL configuration
+# provide certificate and key to connect to gRPC server with HTTPS
+# https://defguard.gitbook.io/defguard/features/setting-up-your-instance/docker-compose#grpc-ssl-setup
+# Optional: path to cert file
+# grpc_cert: proxy.crt
+# Optional: path to key file
+# grpc_key: proxy.key
+log_level = "info"
+rate_limit_per_second = 0
+rate_limit_burst = 0
diff --git a/proto b/proto
index 29898d9c..c71f3784 160000
--- a/proto
+++ b/proto
@@ -1 +1 @@
-Subproject commit 29898d9cb502ef5e7bb1771b63e6a66debf31e7d
+Subproject commit c71f37847279ee23220fcf9e0e45d2c365b3b8ee
diff --git a/src/assets.rs b/src/assets.rs
new file mode 100644
index 00000000..0ae3ddc7
--- /dev/null
+++ b/src/assets.rs
@@ -0,0 +1,48 @@
+use axum::{
+    http::{header, StatusCode, Uri},
+    response::{IntoResponse, Response},
+};
+use rust_embed::Embed;
+
+pub async fn web_asset(uri: Uri) -> impl IntoResponse {
+    let mut path = uri.path().trim_start_matches('/').to_string();
+    // Rewrite the path to match the structure of the embedded files
+    path.insert_str(0, "dist/");
+    StaticFile(path)
+}
+
+pub async fn index() -> impl IntoResponse {
+    web_asset(Uri::from_static("/index.html")).await
+}
+
+pub async fn svg(uri: Uri) -> impl IntoResponse {
+    let mut path = uri.path().trim_start_matches('/').to_string();
+    // Rewrite the path to match the structure of the embedded files
+    path.insert_str(0, "src/shared/images/");
+    StaticFile(path)
+}
+
+#[derive(Embed)]
+#[folder = "web/"]
+#[include = "dist/*"]
+#[include = "src/shared/images/*"]
+struct WebAsset;
+
+pub struct StaticFile<T>(pub T);
+
+impl<T> IntoResponse for StaticFile<T>
+where
+    T: Into<String>,
+{
+    fn into_response(self) -> Response {
+        let path = self.0.into();
+
+        match WebAsset::get(path.as_str()) {
+            Some(content) => {
+                let mime = mime_guess::from_path(path).first_or_octet_stream();
+                ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
+            }
+            None => (StatusCode::NOT_FOUND, "404 Not Found").into_response(),
+        }
+    }
+}
diff --git a/src/config.rs b/src/config.rs
index 038a5413..55b1a48f 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,7 +1,10 @@
 use clap::Parser;
+use serde::Deserialize;
+use std::{fs, io::Error as IoError};
+use tracing::info;
 use tracing::log::LevelFilter;
 
-#[derive(Parser, Debug)]
+#[derive(Parser, Debug, Deserialize)]
 #[command(version)]
 pub struct Config {
     // port the API server will listen on
@@ -25,4 +28,38 @@ pub struct Config {
 
     #[arg(long, env = "DEFGUARD_PROXY_LOG_LEVEL", default_value_t = LevelFilter::Info)]
     pub log_level: LevelFilter,
+
+    #[arg(long, env = "DEFGUARD_PROXY_RATELIMIT_PERSECOND", default_value_t = 0)]
+    pub rate_limit_per_second: u64,
+
+    #[arg(long, env = "DEFGUARD_PROXY_RATELIMIT_BURST", default_value_t = 0)]
+    pub rate_limit_burst: u32,
+
+    /// Configuration file path
+    #[arg(long = "config", short)]
+    #[serde(skip)]
+    config_path: Option<std::path::PathBuf>,
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum ConfigError {
+    #[error("Failed to read config file")]
+    IoError(#[from] IoError),
+    #[error("Failed to parse config file")]
+    ParseError(#[from] toml::de::Error),
+}
+
+pub fn get_config() -> Result<Config, ConfigError> {
+    // parse CLI arguments to get config file path
+    let cli_config = Config::parse();
+
+    // load config from file if one was specified
+    if let Some(config_path) = cli_config.config_path {
+        info!("Reading configuration from config file: {config_path:?}");
+        let config_toml = fs::read_to_string(config_path)?;
+        let file_config: Config = toml::from_str(&config_toml)?;
+        return Ok(file_config);
+    }
+
+    Ok(cli_config)
 }
diff --git a/src/error.rs b/src/error.rs
index d3bad064..b232fede 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -22,6 +22,8 @@ pub enum ApiError {
     CoreTimeout,
     #[error("Invalid core gRPC response type received")]
     InvalidResponseType,
+    #[error("Permission denied")]
+    PermissionDenied,
 }
 
 impl IntoResponse for ApiError {
@@ -30,6 +32,7 @@ impl IntoResponse for ApiError {
         let (status, error_message) = match self {
             Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
             Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
+            Self::PermissionDenied => (StatusCode::FORBIDDEN, self.to_string()),
             _ => (
                 StatusCode::INTERNAL_SERVER_ERROR,
                 "Internal server error".to_string(),
@@ -51,6 +54,7 @@ impl From<CoreError> for ApiError {
         match status.code() {
             Code::Unauthenticated => ApiError::Unauthorized,
             Code::InvalidArgument => ApiError::BadRequest(status.message().to_string()),
+            Code::PermissionDenied => ApiError::PermissionDenied,
             _ => ApiError::Unexpected(status.to_string()),
         }
     }
diff --git a/src/grpc.rs b/src/grpc.rs
index ba35fa74..0e140f82 100644
--- a/src/grpc.rs
+++ b/src/grpc.rs
@@ -37,7 +37,7 @@ impl ProxyServer {
         }
     }
 
-    /// Sends message to the other side of RPC, with given `payload` and optional 'device_info`.
+    /// Sends message to the other side of RPC, with given `payload` and optional `device_info`.
     /// Returns `tokio::sync::oneshot::Reveicer` to let the caller await reply.
     #[instrument(name = "send_grpc_message", level = "debug", skip(self))]
     pub fn send(
diff --git a/src/handlers/desktop_client_mfa.rs b/src/handlers/desktop_client_mfa.rs
index 36fc72e3..858ff4ba 100644
--- a/src/handlers/desktop_client_mfa.rs
+++ b/src/handlers/desktop_client_mfa.rs
@@ -1,3 +1,6 @@
+use axum::{extract::State, routing::post, Json, Router};
+use tracing::{error, info};
+
 use crate::{
     error::ApiError,
     handlers::get_core_response,
@@ -7,9 +10,8 @@ use crate::{
         ClientMfaStartRequest, ClientMfaStartResponse, DeviceInfo,
     },
 };
-use axum::{extract::State, routing::post, Json, Router};
 
-pub fn router() -> Router<AppState> {
+pub(crate) fn router() -> Router<AppState> {
     Router::new()
         .route("/start", post(start_client_mfa))
         .route("/finish", post(finish_client_mfa))
@@ -27,15 +29,13 @@ async fn start_client_mfa(
         device_info,
     )?;
     let payload = get_core_response(rx).await?;
-    match payload {
-        core_response::Payload::ClientMfaStart(response) => {
-            info!("Started desktop client authorization {req:?}");
-            Ok(Json(response))
-        }
-        _ => {
-            error!("Received invalid gRPC response type: {payload:?}");
-            Err(ApiError::InvalidResponseType)
-        }
+
+    if let core_response::Payload::ClientMfaStart(response) = payload {
+        info!("Started desktop client authorization {req:?}");
+        Ok(Json(response))
+    } else {
+        error!("Received invalid gRPC response type: {payload:#?}");
+        Err(ApiError::InvalidResponseType)
     }
 }
 
@@ -51,14 +51,11 @@ async fn finish_client_mfa(
         device_info,
     )?;
     let payload = get_core_response(rx).await?;
-    match payload {
-        core_response::Payload::ClientMfaFinish(response) => {
-            info!("Finished desktop client authorization");
-            Ok(Json(response))
-        }
-        _ => {
-            error!("Received invalid gRPC response type: {payload:?}");
-            Err(ApiError::InvalidResponseType)
-        }
+    if let core_response::Payload::ClientMfaFinish(response) = payload {
+        info!("Finished desktop client authorization");
+        Ok(Json(response))
+    } else {
+        error!("Received invalid gRPC response type: {payload:#?}");
+        Err(ApiError::InvalidResponseType)
     }
 }
diff --git a/src/handlers/enrollment.rs b/src/handlers/enrollment.rs
index 7e5856fc..b8a0a213 100644
--- a/src/handlers/enrollment.rs
+++ b/src/handlers/enrollment.rs
@@ -40,22 +40,19 @@ pub async fn start_enrollment_process(
         .grpc_server
         .send(Some(core_request::Payload::EnrollmentStart(req)), None)?;
     let payload = get_core_response(rx).await?;
-    match payload {
-        core_response::Payload::EnrollmentStart(response) => {
-            info!(
-                "Started enrollment process for user {:?} by admin {:?}",
-                response.user, response.admin
-            );
-            // set session cookie
-            let cookie = Cookie::build((ENROLLMENT_COOKIE_NAME, token))
-                .expires(OffsetDateTime::from_unix_timestamp(response.deadline_timestamp).unwrap());
-
-            Ok((private_cookies.add(cookie), Json(response)))
-        }
-        _ => {
-            error!("Received invalid gRPC response type: {payload:?}");
-            Err(ApiError::InvalidResponseType)
-        }
+    if let core_response::Payload::EnrollmentStart(response) = payload {
+        info!(
+            "Started enrollment process for user {:?} by admin {:?}",
+            response.user, response.admin
+        );
+        // set session cookie
+        let cookie = Cookie::build((ENROLLMENT_COOKIE_NAME, token))
+            .expires(OffsetDateTime::from_unix_timestamp(response.deadline_timestamp).unwrap());
+
+        Ok((private_cookies.add(cookie), Json(response)))
+    } else {
+        error!("Received invalid gRPC response type: {payload:#?}");
+        Err(ApiError::InvalidResponseType)
     }
 }
 
@@ -78,20 +75,17 @@ pub async fn activate_user(
         .grpc_server
         .send(Some(core_request::Payload::ActivateUser(req)), device_info)?;
     let payload = get_core_response(rx).await?;
-    match payload {
-        core_response::Payload::Empty(_) => {
-            if let Some(cookie) = private_cookies.get(ENROLLMENT_COOKIE_NAME) {
-                info!("Activated user - phone number {phone:?}");
-                debug!("Enrollment finished. Removing session cookie");
-                private_cookies = private_cookies.remove(cookie);
-            }
-
-            Ok(private_cookies)
-        }
-        _ => {
-            error!("Received invalid gRPC response type: {payload:?}");
-            Err(ApiError::InvalidResponseType)
+    if let core_response::Payload::Empty(()) = payload {
+        if let Some(cookie) = private_cookies.get(ENROLLMENT_COOKIE_NAME) {
+            info!("Activated user - phone number {phone:?}");
+            debug!("Enrollment finished. Removing session cookie");
+            private_cookies = private_cookies.remove(cookie);
         }
+
+        Ok(private_cookies)
+    } else {
+        error!("Received invalid gRPC response type: {payload:#?}");
+        Err(ApiError::InvalidResponseType)
     }
 }
 
@@ -114,15 +108,12 @@ pub async fn create_device(
         .grpc_server
         .send(Some(core_request::Payload::NewDevice(req)), device_info)?;
     let payload = get_core_response(rx).await?;
-    match payload {
-        core_response::Payload::DeviceConfig(response) => {
-            info!("Added new device {name} {pubkey}");
-            Ok(Json(response))
-        }
-        _ => {
-            error!("Received invalid gRPC response type: {payload:?}");
-            Err(ApiError::InvalidResponseType)
-        }
+    if let core_response::Payload::DeviceConfig(response) = payload {
+        info!("Added new device {name} {pubkey}");
+        Ok(Json(response))
+    } else {
+        error!("Received invalid gRPC response type: {payload:#?}");
+        Err(ApiError::InvalidResponseType)
     }
 }
 
@@ -144,14 +135,11 @@ pub async fn get_network_info(
         .grpc_server
         .send(Some(core_request::Payload::ExistingDevice(req)), None)?;
     let payload = get_core_response(rx).await?;
-    match payload {
-        core_response::Payload::DeviceConfig(response) => {
-            info!("Got network info for device {pubkey}");
-            Ok(Json(response))
-        }
-        _ => {
-            error!("Received invalid gRPC response type: {payload:?}");
-            Err(ApiError::InvalidResponseType)
-        }
+    if let core_response::Payload::DeviceConfig(response) = payload {
+        info!("Got network info for device {pubkey}");
+        Ok(Json(response))
+    } else {
+        error!("Received invalid gRPC response type: {payload:#?}");
+        Err(ApiError::InvalidResponseType)
     }
 }
diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs
index b88bb5f1..2d1c62f6 100644
--- a/src/handlers/mod.rs
+++ b/src/handlers/mod.rs
@@ -46,19 +46,15 @@ where
 ///
 /// Waits for core response with a given timeout and returns the response payload.
 async fn get_core_response(rx: Receiver<Payload>) -> Result<Payload, ApiError> {
-    match timeout(Duration::from_secs(CORE_RESPONSE_TIMEOUT), rx).await {
-        Ok(core_response) => {
-            debug!("Got gRPC response from Defguard core: {core_response:?}");
-            if let Ok(Payload::CoreError(core_error)) = core_response {
-                return Err(core_error.into());
-            };
-            core_response.map_err(|err| {
-                ApiError::Unexpected(format!("Failed to receive core response: {err}"))
-            })
-        }
-        Err(_) => {
-            error!("Did not receive core response within {CORE_RESPONSE_TIMEOUT} seconds");
-            Err(ApiError::CoreTimeout)
-        }
+    if let Ok(core_response) = timeout(Duration::from_secs(CORE_RESPONSE_TIMEOUT), rx).await {
+        debug!("Got gRPC response from Defguard core: {core_response:?}");
+        if let Ok(Payload::CoreError(core_error)) = core_response {
+            return Err(core_error.into());
+        };
+        core_response
+            .map_err(|err| ApiError::Unexpected(format!("Failed to receive core response: {err}")))
+    } else {
+        error!("Did not receive core response within {CORE_RESPONSE_TIMEOUT} seconds");
+        Err(ApiError::CoreTimeout)
     }
 }
diff --git a/src/handlers/password_reset.rs b/src/handlers/password_reset.rs
index f09a9ce7..dea6d61f 100644
--- a/src/handlers/password_reset.rs
+++ b/src/handlers/password_reset.rs
@@ -32,15 +32,12 @@ pub async fn request_password_reset(
         device_info,
     )?;
     let payload = get_core_response(rx).await?;
-    match payload {
-        core_response::Payload::Empty(_) => {
-            info!("Started password reset request for {}", req.email);
-            Ok(())
-        }
-        _ => {
-            error!("Received invalid gRPC response type: {payload:?}");
-            Err(ApiError::InvalidResponseType)
-        }
+    if let core_response::Payload::Empty(()) = payload {
+        info!("Started password reset request for {}", req.email);
+        Ok(())
+    } else {
+        error!("Received invalid gRPC response type: {payload:#?}");
+        Err(ApiError::InvalidResponseType)
     }
 }
 
@@ -66,19 +63,16 @@ pub async fn start_password_reset(
         device_info,
     )?;
     let payload = get_core_response(rx).await?;
-    match payload {
-        core_response::Payload::PasswordResetStart(response) => {
-            // set session cookie
-            let cookie = Cookie::build((PASSWORD_RESET_COOKIE_NAME, token))
-                .expires(OffsetDateTime::from_unix_timestamp(response.deadline_timestamp).unwrap());
+    if let core_response::Payload::PasswordResetStart(response) = payload {
+        // set session cookie
+        let cookie = Cookie::build((PASSWORD_RESET_COOKIE_NAME, token))
+            .expires(OffsetDateTime::from_unix_timestamp(response.deadline_timestamp).unwrap());
 
-            info!("Started password reset process");
-            Ok((private_cookies.add(cookie), Json(response)))
-        }
-        _ => {
-            error!("Received invalid gRPC response type: {payload:?}");
-            Err(ApiError::InvalidResponseType)
-        }
+        info!("Started password reset process");
+        Ok((private_cookies.add(cookie), Json(response)))
+    } else {
+        error!("Received invalid gRPC response type: {payload:#?}");
+        Err(ApiError::InvalidResponseType)
     }
 }
 
@@ -100,17 +94,14 @@ pub async fn reset_password(
         .grpc_server
         .send(Some(core_request::Payload::PasswordReset(req)), device_info)?;
     let payload = get_core_response(rx).await?;
-    match payload {
-        core_response::Payload::Empty(_) => {
-            if let Some(cookie) = private_cookies.get(PASSWORD_RESET_COOKIE_NAME) {
-                info!("Password reset finished. Removing session cookie");
-                private_cookies = private_cookies.remove(cookie);
-            }
-            Ok(private_cookies)
-        }
-        _ => {
-            error!("Received invalid gRPC response type: {payload:?}");
-            Err(ApiError::InvalidResponseType)
+    if let core_response::Payload::Empty(()) = payload {
+        if let Some(cookie) = private_cookies.get(PASSWORD_RESET_COOKIE_NAME) {
+            info!("Password reset finished. Removing session cookie");
+            private_cookies = private_cookies.remove(cookie);
         }
+        Ok(private_cookies)
+    } else {
+        error!("Received invalid gRPC response type: {payload:#?}");
+        Err(ApiError::InvalidResponseType)
     }
 }
diff --git a/src/http.rs b/src/http.rs
index de80a2e5..f4e0cb14 100644
--- a/src/http.rs
+++ b/src/http.rs
@@ -1,13 +1,13 @@
 use std::{
     fs::read_to_string,
     net::{IpAddr, Ipv4Addr, SocketAddr},
+    time::Duration,
 };
 
 use anyhow::Context;
 use axum::{
     body::Body,
     extract::{ConnectInfo, FromRef},
-    handler::HandlerWithoutStateExt,
     http::{Request, StatusCode},
     routing::get,
     serve, Json, Router,
@@ -17,13 +17,14 @@ use clap::crate_version;
 use serde::Serialize;
 use tokio::{net::TcpListener, task::JoinSet};
 use tonic::transport::{Identity, Server, ServerTlsConfig};
-use tower_http::{
-    services::{ServeDir, ServeFile},
-    trace::{self, TraceLayer},
+use tower_governor::{
+    governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer,
 };
+use tower_http::trace::{self, TraceLayer};
 use tracing::{info_span, Level};
 
 use crate::{
+    assets::{index, svg, web_asset},
     config::Config,
     error::ApiError,
     grpc::ProxyServer,
@@ -33,6 +34,7 @@ use crate::{
 
 pub(crate) static ENROLLMENT_COOKIE_NAME: &str = "defguard_proxy";
 pub(crate) static PASSWORD_RESET_COOKIE_NAME: &str = "defguard_proxy_password_reset";
+const RATE_LIMITER_CLEANUP_PERIOD: Duration = Duration::from_secs(60);
 
 #[derive(Clone)]
 pub(crate) struct AppState {
@@ -128,12 +130,47 @@ pub async fn run_server(config: Config) -> anyhow::Result<()> {
             .context("Error running gRPC server")
     });
 
-    // Serve static frontend files.
-    debug!("Configuring API server routing");
-    let serve_web_dir = ServeDir::new("web/dist").fallback(ServeFile::new("web/dist/index.html"));
-    let serve_images =
-        ServeDir::new("web/src/shared/images/svg").not_found_service(handle_404.into_service());
-    let app = Router::new()
+    // Setup tower_governor rate-limiter
+    debug!(
+        "Configuring rate limiter, per_second: {}, burst: {}",
+        config.rate_limit_per_second, config.rate_limit_burst
+    );
+    let governor_conf = GovernorConfigBuilder::default()
+        .key_extractor(SmartIpKeyExtractor)
+        .per_second(config.rate_limit_per_second)
+        .burst_size(config.rate_limit_burst)
+        .finish();
+
+    let governor_conf = if let Some(conf) = governor_conf {
+        let governor_limiter = conf.limiter().clone();
+
+        // Start background task to cleanup rate-limiter data
+        tokio::spawn(async move {
+            loop {
+                tokio::time::sleep(RATE_LIMITER_CLEANUP_PERIOD).await;
+                tracing::debug!(
+                    "Cleaning-up rate limiter storage, current size: {}",
+                    governor_limiter.len()
+                );
+                governor_limiter.retain_recent();
+            }
+        });
+        info!(
+            "Configured rate limiter, per_second: {}, burst: {}",
+            config.rate_limit_per_second, config.rate_limit_burst
+        );
+        Some(conf)
+    } else {
+        info!("Skipping rate limiter setup");
+        None
+    };
+
+    // Build axum app
+    let mut app = Router::new()
+        .route("/", get(index))
+        .route("/*path", get(index))
+        .route("/assets/*path", get(web_asset))
+        .route("/svg/*path", get(svg))
         .nest(
             "/api/v1",
             Router::new()
@@ -143,8 +180,7 @@ pub async fn run_server(config: Config) -> anyhow::Result<()> {
                 .route("/health", get(healthcheck))
                 .route("/info", get(app_info)),
         )
-        .nest_service("/svg", serve_images)
-        .fallback_service(serve_web_dir)
+        .fallback_service(get(handle_404))
         .with_state(shared_state)
         .layer(
             TraceLayer::new_for_http()
@@ -161,6 +197,11 @@ pub async fn run_server(config: Config) -> anyhow::Result<()> {
                 })
                 .on_response(trace::DefaultOnResponse::new().level(Level::DEBUG)),
         );
+    if let Some(conf) = governor_conf {
+        app = app.layer(GovernorLayer {
+            config: conf.into(),
+        });
+    }
     debug!("Configured API server routing: {app:?}");
 
     // Start web server.
diff --git a/src/lib.rs b/src/lib.rs
index 9f35c309..fc1667a3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,4 @@
+pub mod assets;
 pub mod config;
 mod error;
 mod grpc;
diff --git a/src/main.rs b/src/main.rs
index d2ac2ad0..0ab5d28d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,4 @@
-use clap::Parser;
-use defguard_proxy::{config::Config, http::run_server, tracing::init_tracing};
+use defguard_proxy::{config::get_config, http::run_server, tracing::init_tracing};
 
 #[tokio::main]
 async fn main() -> anyhow::Result<()> {
@@ -7,7 +6,9 @@ async fn main() -> anyhow::Result<()> {
     if dotenvy::from_filename(".env.local").is_err() {
         dotenvy::dotenv().ok();
     }
-    let config = Config::parse();
+
+    // read config from env
+    let config = get_config()?;
     init_tracing(&config.log_level);
 
     // run API web server
diff --git a/web/src/pages/main/SelectPath.tsx b/web/src/pages/main/SelectPath.tsx
index f8f7966b..c01539ef 100644
--- a/web/src/pages/main/SelectPath.tsx
+++ b/web/src/pages/main/SelectPath.tsx
@@ -2,8 +2,8 @@ import { useNavigate } from 'react-router-dom';
 import { shallow } from 'zustand/shallow';
 
 import { Card } from '../../shared/components/layout/Card/Card';
-import EnrollmentSelectGraphic from '../../shared/components/svg/EnrollmentSelectGraphic';
-import PasswordResetSelectGraphic from '../../shared/components/svg/PasswordResetSelectGraphic';
+import SvgEnrollmentSelectGraphic from '../../shared/components/svg/EnrollmentSelectGraphic';
+import SvgPasswordResetSelectGraphic from '../../shared/components/svg/PasswordResetSelectGraphic';
 import { routes } from '../../shared/routes';
 import { usePasswordResetStore } from '../passwordReset/hooks/usePasswordResetStore';
 import { PathSelectCard } from './DeviceSetupMethodCard/DeviceSetupMethodCard';
@@ -19,7 +19,7 @@ export const SelectPath = () => {
         onSelect={() => navigate(routes.token)}
         title="Enrollment process"
         subtitle="Confirm your new account"
-        logo={<EnrollmentSelectGraphic />}
+        logo={<SvgEnrollmentSelectGraphic />}
       />
 
       <PathSelectCard
@@ -30,7 +30,7 @@ export const SelectPath = () => {
         }}
         title="Password reset"
         subtitle="Reset password for existing account"
-        logo={<PasswordResetSelectGraphic />}
+        logo={<SvgPasswordResetSelectGraphic />}
       />
     </Card>
   );
diff --git a/web/src/shared/components/svg/DefguardLogoText.tsx b/web/src/shared/components/svg/DefguardLogoText.tsx
index e2019462..0df432e7 100644
--- a/web/src/shared/components/svg/DefguardLogoText.tsx
+++ b/web/src/shared/components/svg/DefguardLogoText.tsx
@@ -2,15 +2,15 @@ import type { SVGProps } from 'react';
 const SvgDefguardLogoText = (props: SVGProps<SVGSVGElement>) => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
-    width={84}
-    height={24}
+    width={91}
+    height={27}
     fill="none"
-    viewBox="0 0 84 24"
+    viewBox="0 0 91 27"
     {...props}
   >
     <path
       fill="#222"
-      d="M8.424 5.623c-.803-.58-1.695-.803-2.587-.803C3.16 4.82.93 7.005.93 9.68v4.64c0 2.898 2.096 4.86 4.995 4.86 2.676 0 4.995-2.185 4.995-4.86V.225H8.424v5.397Zm0 8.696c0 1.427-1.026 2.542-2.498 2.542-1.427 0-2.497-1.115-2.497-2.542l.044-4.638c0-1.472 1.026-2.542 2.453-2.542 1.472 0 2.498 1.07 2.498 2.542v4.638ZM18.19 4.82c-2.676 0-4.995 2.185-4.995 4.86v4.64c0 2.898 2.096 4.86 4.995 4.86 2.676 0 4.995-2.185 4.995-4.86h-2.498c0 1.426-1.025 2.541-2.497 2.541-1.427 0-2.497-1.115-2.497-2.542v-1.16h7.492V9.682c0-2.899-2.096-4.861-4.995-4.861Zm2.497 6.02h-4.95V9.68c0-1.47 1.026-2.541 2.453-2.541 1.472 0 2.497 1.07 2.497 2.542v1.16ZM26.084 3.66v1.383h-1.249v2.319h1.249v11.595h2.497V7.362h1.249v-2.32H28.58V3.66c0-.713.49-1.16 1.16-1.16h.223V.183h-.402c-2.007 0-3.478 1.427-3.478 3.478ZM36.475 4.82c-2.676 0-4.995 2.185-4.995 4.86v4.64c0 2.898 2.007 4.86 4.772 4.86 1.16 0 2.274-.401 2.765-1.026v.803c0 1.517-.981 2.676-2.498 2.676l-.046-.002v2.187h.046c2.632 0 4.95-1.962 4.95-4.86V9.68c0-2.899-2.096-4.861-4.994-4.861Zm2.497 9.5c0 1.426-1.026 2.541-2.497 2.541-1.427 0-2.498-1.115-2.498-2.542V9.681c0-1.472 1.07-2.542 2.498-2.542 1.471 0 2.497 1.07 2.497 2.542v4.638ZM50.969 14.32c0 1.426-1.026 2.541-2.498 2.541-1.427 0-2.497-1.115-2.497-2.542V5.043h-2.498v9.276c0 2.899 2.052 4.861 4.95 4.861 2.543 0 5.04-1.962 5.04-4.86V5.042H50.97v9.276ZM60.557 4.82c-2.587 0-4.995 1.873-4.995 4.772h2.364c0-1.517 1.026-2.587 2.542-2.587 1.516 0 2.542 1.16 2.542 2.676v.847h-.045c-.713-.58-1.56-.892-2.586-.892-2.676 0-4.906 2.097-4.906 4.772 0 2.9 2.007 4.772 4.772 4.772 1.16 0 2.23-.401 2.765-1.026v.803h2.453V9.681c0-2.899-2.007-4.861-4.906-4.861Zm-.09 12.13c-1.426 0-2.497-1.114-2.497-2.542 0-1.471 1.07-2.542 2.498-2.542 1.472 0 2.497 1.07 2.497 2.542 0 1.428-1.025 2.543-2.497 2.543ZM67.38 8.521v10.436h2.498V8.521c0-.713.49-1.16 1.16-1.16h.713V5.044h-.892c-2.007 0-3.478 1.427-3.478 3.478ZM80.626.226v5.397c-.803-.58-1.695-.803-2.587-.803-2.676 0-4.906 2.185-4.906 4.86v4.64c0 2.898 2.096 4.86 4.995 4.86 2.676 0 4.995-2.185 4.995-4.86V.225h-2.498Zm0 14.093c0 1.427-1.026 2.542-2.498 2.542-1.427 0-2.498-1.115-2.498-2.542l.045-4.638c0-1.472 1.026-2.542 2.453-2.542 1.472 0 2.498 1.07 2.498 2.542v4.638Z"
+      d="M88.87 4.036v6.62h-.15c-.67-1.27-2-2.15-3.53-2.15h-1.48c-2.21 0-4 1.79-4 4v4.01c0 2.21 1.79 4 4 4h1.48c1.51 0 2.82-.85 3.5-2.09h.11l.08 1.87h1.44V4.036h-1.45Zm-3.16 14.98h-1.48c-1.66 0-3-1.34-3-3v-3c0-1.66 1.34-3 3-3h1.48c1.66 0 3 1.34 3 3v3c0 1.66-1.34 3-3 3ZM74.05 8.726h-.65c-.55 0-1 .45-1 1v10.58h1.65v-10.27h4.5v-1.31h-4.5ZM68.58 20.306l-.02-8.22c0-1.97-1.6-3.58-3.58-3.58h-1.95c-1.97 0-3.58 1.6-3.58 3.58h1.49a2.56 2.56 0 0 1 2.55-2.29h1.02a2.58 2.58 0 0 1 2.58 2.58v1.01h-4.44c-1.97 0-3.58 1.6-3.58 3.58s1.6 3.58 3.58 3.58h1.44c1.19 0 2.23-.59 2.88-1.48h.11v1.26h1.48l.02-.02Zm-4.45-1.07h-1.2c-1.26 0-2.27-1.02-2.27-2.27s1.02-2.27 2.27-2.27h4.17v1.58a2.97 2.97 0 0 1-2.97 2.97v-.01ZM54.81 16.016c0 1.66-1.34 3-3 3h-.76c-1.66 0-3-1.34-3-3v-7.29h-1.52v7.8c0 2.21 1.79 4 4 4h.77c1.51 0 2.82-.85 3.5-2.09h.11l.08 1.87h1.44V8.726h-1.59l-.02 7.29h-.01ZM41.5 18.146h-5.14c-.49 0-.88-.35-.88-.84s.39-.88.88-.88h3.15c1.93 0 3.5-1.57 3.5-3.5v-.92c0-1.11-.53-2.09-1.33-2.73v-.81h2.42v-1.31h-2.54c-.55 0-1 .45-1 1v.53c-.33-.11-.68-.18-1.05-.18h-1.72c-1.93 0-3.5 1.57-3.5 3.5v.92c0 1.18.59 2.22 1.48 2.85-.91.04-1.64.78-1.64 1.7 0 .76.51 1.38 1.2 1.58-1.16.36-2 1.33-2 2.48v.33c0 1.46 1.34 2.64 3 2.64h5.15c1.66 0 3-1.34 3-3v-.38c0-1.66-1.34-3-3-3l.02.02Zm-5.69-6.42c0-1.06.9-1.92 2-1.92h1.71c1.1 0 2 .86 2 1.92v1.48c0 1.06-.9 1.92-2 1.92h-1.71c-1.1 0-2-.86-2-1.92v-1.48Zm5.62 11.36h-5.01c-.97 0-1.76-.79-1.76-1.76s.79-1.76 1.76-1.76h5.01c.97 0 1.76.79 1.76 1.76s-.79 1.76-1.76 1.76ZM31.88 5.356v-1.32h-3.36c-.55 0-1 .45-1 1v3.69h-2.01v1.35h2.01v10.23h1.47v-10.23h2.63v-1.35h-2.63v-3.37h2.89ZM22.59 14.846H24v-1.34c0-2.76-2.24-5-5-5h-.46c-2.76 0-5 2.24-5 5v1.75c0 2.9 2.37 5.26 5.26 5.26h.46c2.28 0 4.18-1.53 4.78-3.61h-1.72c-.65 1.64-2.14 2.72-4.09 2.27-1.95-.45-3.12-2.2-3.12-4.11v-.22h7.48Zm-3.74-5.1c2.06 0 3.54 1.67 3.54 3.74v.09h-7.28v-.09a3.74 3.74 0 0 1 3.74-3.74ZM9.16 4.036v6.62h-.15c-.67-1.27-2-2.15-3.53-2.15H4c-2.21 0-4 1.79-4 4v4.01c0 2.21 1.79 4 4 4h1.48c1.51 0 2.82-.85 3.5-2.09h.11l.08 1.87h1.44V4.036H9.16ZM6 19.016H4.52c-1.66 0-3-1.34-3-3v-3c0-1.66 1.34-3 3-3H6c1.66 0 3 1.34 3 3v3c0 1.66-1.34 3-3 3Z"
     />
   </svg>
 );
diff --git a/web/src/shared/components/svg/EnrollmentSelectGraphic.tsx b/web/src/shared/components/svg/EnrollmentSelectGraphic.tsx
index a5566146..aea34c1b 100644
--- a/web/src/shared/components/svg/EnrollmentSelectGraphic.tsx
+++ b/web/src/shared/components/svg/EnrollmentSelectGraphic.tsx
@@ -1,5 +1,13 @@
-const EnrollmentSelectGraphic = () => (
-  <svg xmlns="http://www.w3.org/2000/svg" width={215} height={214} fill="none">
+import type { SVGProps } from 'react';
+const SvgEnrollmentSelectGraphic = (props: SVGProps<SVGSVGElement>) => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width={215}
+    height={214}
+    fill="none"
+    viewBox="0 0 215 214"
+    {...props}
+  >
     <path
       fill="#CBD3D8"
       d="M56.255 98.95h22.9v8.18h-22.9v-8.18ZM50.345 55.06l4.74-5.55 31.28 26.71-4.74 5.55-31.28-26.71ZM137.925 98.95h22.9v8.18h-22.9v-8.18ZM129.365 75.75l30.6-26.25 4.76 5.55-30.6 26.25-4.76-5.55ZM54.805 149.44l26.94-26.94 5.17 5.17-26.94 26.94-5.17-5.17ZM129.235 127.69l5.14-5.19 27.85 27.62-5.14 5.19-27.85-27.62Z"
@@ -57,4 +65,4 @@ const EnrollmentSelectGraphic = () => (
     />
   </svg>
 );
-export default EnrollmentSelectGraphic;
+export default SvgEnrollmentSelectGraphic;
diff --git a/web/src/shared/components/svg/PasswordResetSelectGraphic.tsx b/web/src/shared/components/svg/PasswordResetSelectGraphic.tsx
index 26504c2b..426b8bfa 100644
--- a/web/src/shared/components/svg/PasswordResetSelectGraphic.tsx
+++ b/web/src/shared/components/svg/PasswordResetSelectGraphic.tsx
@@ -1,5 +1,13 @@
-const PasswordResetSelectGraphic = () => (
-  <svg xmlns="http://www.w3.org/2000/svg" width={215} height={214} fill="none">
+import type { SVGProps } from 'react';
+const SvgPasswordResetSelectGraphic = (props: SVGProps<SVGSVGElement>) => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width={215}
+    height={214}
+    fill="none"
+    viewBox="0 0 215 214"
+    {...props}
+  >
     <g clipPath="url(#a)">
       <path
         fill="#CBD3D8"
@@ -152,4 +160,4 @@ const PasswordResetSelectGraphic = () => (
     </defs>
   </svg>
 );
-export default PasswordResetSelectGraphic;
+export default SvgPasswordResetSelectGraphic;
diff --git a/web/src/shared/images/svg/defguard-logo-text.svg b/web/src/shared/images/svg/defguard-logo-text.svg
index c6b32f83..141b6668 100644
--- a/web/src/shared/images/svg/defguard-logo-text.svg
+++ b/web/src/shared/images/svg/defguard-logo-text.svg
@@ -1,10 +1,25 @@
-<svg width="84" height="24" viewBox="0 0 84 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-  <path d="M8.42365 5.62253C7.62097 5.0428 6.72885 4.81985 5.83682 4.81985C3.16104 4.81985 0.931152 7.00504 0.931152 9.68094V14.3192C0.931152 17.218 3.02719 19.1803 5.92606 19.1803C8.60208 19.1803 10.9211 16.995 10.9211 14.3192V0.226318H8.42365V5.62253ZM8.42365 14.3192C8.42365 15.7463 7.39792 16.8613 5.92606 16.8613C4.49893 16.8613 3.42859 15.7463 3.42859 14.3192L3.47321 9.68094C3.47321 8.2092 4.49893 7.13885 5.92606 7.13885C7.39792 7.13885 8.42365 8.2092 8.42365 9.68094V14.3192Z" fill="#222222"/>
-  <path d="M18.19 4.81982C15.5142 4.81982 13.1951 7.005 13.1951 9.68091V14.3192C13.1951 17.218 15.2911 19.1803 18.19 19.1803C20.8659 19.1803 23.185 16.995 23.185 14.3192H20.6874C20.6874 15.7463 19.6617 16.8613 18.19 16.8613C16.7628 16.8613 15.6926 15.7463 15.6926 14.3192V13.1596H23.185V9.68091C23.185 6.78204 21.0889 4.81982 18.19 4.81982ZM20.6874 10.8405H15.7372V9.68091C15.7372 8.20917 16.7628 7.13885 18.19 7.13885C19.6617 7.13885 20.6874 8.20917 20.6874 9.68091V10.8405Z" fill="#222222"/>
-  <path d="M26.0838 3.66022V5.04272H24.835V7.36186H26.0838V18.9573H28.5812V7.36186H29.8299V5.04272H28.5812V3.66022C28.5812 2.94667 29.0718 2.50066 29.7408 2.50066H29.9637V0.181641H29.5624C27.5555 0.181641 26.0838 1.60878 26.0838 3.66022Z" fill="#222222"/>
-  <path d="M36.4747 4.81982C33.7988 4.81982 31.4797 7.005 31.4797 9.68091V14.3192C31.4797 17.218 33.4867 19.1803 36.2518 19.1803C37.4114 19.1803 38.5263 18.779 39.0168 18.1545V18.9573C39.0168 20.4737 38.0357 21.6332 36.5193 21.6332C36.5035 21.6332 36.4885 21.6314 36.4728 21.6312V23.8176C36.4883 23.8176 36.5037 23.8184 36.5193 23.8184C39.1506 23.8184 41.4697 21.8562 41.4697 18.9573V9.68091C41.4697 6.78204 39.3735 4.81982 36.4747 4.81982ZM38.9722 14.3192C38.9722 15.7463 37.9465 16.8613 36.4747 16.8613C35.0476 16.8613 33.9772 15.7463 33.9772 14.3192V9.68091C33.9772 8.20917 35.0476 7.13885 36.4747 7.13885C37.9465 7.13885 38.9722 8.20917 38.9722 9.68091V14.3192Z" fill="#222222"/>
-  <path d="M50.9688 14.3191C50.9688 15.7462 49.9431 16.8612 48.4713 16.8612C47.0441 16.8612 45.9738 15.7462 45.9738 14.3191V5.04272H43.4763V14.3191C43.4763 17.218 45.5279 19.1802 48.4266 19.1802C50.9688 19.1802 53.4662 17.218 53.4662 14.3191V5.04272H50.9688V14.3191Z" fill="#222222"/>
-  <path d="M60.5571 4.81982C57.9704 4.81982 55.5621 6.69284 55.5621 9.59171H57.9258C57.9258 8.07547 58.9516 7.005 60.4678 7.005C61.9842 7.005 63.0099 8.16458 63.0099 9.68091V10.5283H62.9653C62.2518 9.94846 61.4045 9.63632 60.3787 9.63632C57.7028 9.63632 55.4729 11.7325 55.4729 14.4084C55.4729 17.3073 57.4799 19.1803 60.2449 19.1803C61.4045 19.1803 62.4748 18.779 63.0099 18.1545V18.9573H65.463V9.68091C65.463 6.78204 63.4559 4.81982 60.5571 4.81982ZM60.4678 16.9505C59.0407 16.9505 57.9704 15.8355 57.9704 14.4084C57.9704 12.9367 59.0407 11.8663 60.4678 11.8663C61.9396 11.8663 62.9653 12.9367 62.9653 14.4084C62.9653 15.8355 61.9396 16.9505 60.4678 16.9505Z" fill="#222222"/>
-  <path d="M67.3806 8.5213V18.9572H69.8781V8.5213C69.8781 7.80771 70.3686 7.36185 71.0375 7.36185H71.751V5.04272H70.8592C68.8524 5.04272 67.3806 6.46985 67.3806 8.5213Z" fill="#222222"/>
-  <path d="M80.6255 0.226318V5.62253C79.8228 5.0428 78.9309 4.81985 78.039 4.81985C75.363 4.81985 73.1331 7.00504 73.1331 9.68094V14.3192C73.1331 17.218 75.2291 19.1803 78.1281 19.1803C80.804 19.1803 83.123 16.995 83.123 14.3192V0.226318H80.6255ZM80.6255 14.3192C80.6255 15.7463 79.5999 16.8613 78.1281 16.8613C76.7008 16.8613 75.6305 15.7463 75.6305 14.3192L75.6751 9.68094C75.6751 8.2092 76.7008 7.13885 78.1281 7.13885C79.5999 7.13885 80.6255 8.2092 80.6255 9.68094V14.3192Z" fill="#222222"/>
-</svg>
+<svg width="91" height="27" viewBox="0 0 91 27" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path
+    d="M88.87 4.03564V10.6556H88.72C88.05 9.38564 86.72 8.50564 85.19 8.50564H83.71C81.5 8.50564 79.71 10.2956 79.71 12.5056V16.5156C79.71 18.7256 81.5 20.5156 83.71 20.5156H85.19C86.7 20.5156 88.01 19.6656 88.69 18.4256H88.8L88.88 20.2956H90.32V4.03564H88.88H88.87ZM85.71 19.0156H84.23C82.57 19.0156 81.23 17.6756 81.23 16.0156V13.0156C81.23 11.3556 82.57 10.0156 84.23 10.0156H85.71C87.37 10.0156 88.71 11.3556 88.71 13.0156V16.0156C88.71 17.6756 87.37 19.0156 85.71 19.0156Z"
+    fill="#222222" />
+  <path d="M74.05 8.72559H73.4C72.85 8.72559 72.4 9.17559 72.4 9.72559V20.3056H74.05V10.0356H78.55V8.72559H74.05Z"
+    fill="#222222" />
+  <path
+    d="M68.5799 20.3056L68.5599 12.0856C68.5599 10.1156 66.9599 8.50562 64.9799 8.50562H63.0299C61.0599 8.50562 59.4499 10.1056 59.4499 12.0856H60.9399C61.0799 10.7956 62.1599 9.79562 63.4899 9.79562H64.5099C65.9299 9.79562 67.0899 10.9456 67.0899 12.3756V13.3856H62.6499C60.6799 13.3856 59.0699 14.9856 59.0699 16.9656C59.0699 18.9456 60.6699 20.5456 62.6499 20.5456H64.0899C65.2799 20.5456 66.3199 19.9556 66.9699 19.0656H67.0799V20.3256H68.5599L68.5799 20.3056ZM64.1299 19.2356H62.9299C61.6699 19.2356 60.6599 18.2156 60.6599 16.9656C60.6599 15.7156 61.6799 14.6956 62.9299 14.6956H67.0999V16.2756C67.0999 17.9156 65.7699 19.2456 64.1299 19.2456V19.2356Z"
+    fill="#222222" />
+  <path
+    d="M54.81 16.0156C54.81 17.6756 53.47 19.0156 51.81 19.0156H51.05C49.39 19.0156 48.05 17.6756 48.05 16.0156V8.72559H46.53V16.5256C46.53 18.7356 48.32 20.5256 50.53 20.5256H51.3C52.81 20.5256 54.12 19.6756 54.8 18.4356H54.91L54.99 20.3056H56.43V8.72559H54.84L54.82 16.0156H54.81Z"
+    fill="#222222" />
+  <path
+    d="M41.5 18.1456H36.36C35.87 18.1456 35.48 17.7956 35.48 17.3056C35.48 16.8156 35.87 16.4256 36.36 16.4256H37.79H39.51C41.44 16.4256 43.01 14.8556 43.01 12.9256V12.0056C43.01 10.8956 42.48 9.91564 41.68 9.27564V8.46564H44.1V7.15564H41.56C41.01 7.15564 40.56 7.60564 40.56 8.15564V8.68564C40.23 8.57564 39.88 8.50564 39.51 8.50564H37.79C35.86 8.50564 34.29 10.0756 34.29 12.0056V12.9256C34.29 14.1056 34.88 15.1456 35.77 15.7756C34.86 15.8156 34.13 16.5556 34.13 17.4756C34.13 18.2356 34.64 18.8556 35.33 19.0556C34.17 19.4156 33.33 20.3856 33.33 21.5356V21.8656C33.33 23.3256 34.67 24.5056 36.33 24.5056H41.48C43.14 24.5056 44.48 23.1656 44.48 21.5056V21.1256C44.48 19.4656 43.14 18.1256 41.48 18.1256L41.5 18.1456ZM35.81 11.7256C35.81 10.6656 36.71 9.80564 37.81 9.80564H39.52C40.62 9.80564 41.52 10.6656 41.52 11.7256V13.2056C41.52 14.2656 40.62 15.1256 39.52 15.1256H37.81C36.71 15.1256 35.81 14.2656 35.81 13.2056V11.7256ZM41.43 23.0856H36.42C35.45 23.0856 34.66 22.2956 34.66 21.3256C34.66 20.3556 35.45 19.5656 36.42 19.5656H41.43C42.4 19.5656 43.19 20.3556 43.19 21.3256C43.19 22.2956 42.4 23.0856 41.43 23.0856Z"
+    fill="#222222" />
+  <path
+    d="M31.88 5.35564V4.03564H28.52C27.97 4.03564 27.52 4.48564 27.52 5.03564V8.72564H25.51V10.0756H27.52V20.3056H28.99V10.0756H31.62V8.72564H28.99V5.35564H31.88Z"
+    fill="#222222" />
+  <path
+    d="M22.59 14.8456H24V13.5056C24 10.7456 21.76 8.50562 19 8.50562H18.54C15.78 8.50562 13.54 10.7456 13.54 13.5056V15.2556C13.54 18.1556 15.91 20.5156 18.8 20.5156H19.26C21.54 20.5156 23.44 18.9856 24.04 16.9056H22.32C21.67 18.5456 20.18 19.6256 18.23 19.1756C16.28 18.7256 15.11 16.9756 15.11 15.0656V14.8456H22.59ZM18.85 9.74562C20.91 9.74562 22.39 11.4156 22.39 13.4856V13.5756H15.11V13.4856C15.11 11.4256 16.78 9.74562 18.85 9.74562Z"
+    fill="#222222" />
+  <path
+    d="M9.16 4.03564V10.6556H9.01C8.34 9.38564 7.01 8.50564 5.48 8.50564H4C1.79 8.50564 0 10.2956 0 12.5056V16.5156C0 18.7256 1.79 20.5156 4 20.5156H5.48C6.99 20.5156 8.3 19.6656 8.98 18.4256H9.09L9.17 20.2956H10.61V4.03564H9.17H9.16ZM6 19.0156H4.52C2.86 19.0156 1.52 17.6756 1.52 16.0156V13.0156C1.52 11.3556 2.86 10.0156 4.52 10.0156H6C7.66 10.0156 9 11.3556 9 13.0156V16.0156C9 17.6756 7.66 19.0156 6 19.0156Z"
+    fill="#222222" />
+</svg>
\ No newline at end of file
diff --git a/web/src/shared/utils/downloadWGConfig.ts b/web/src/shared/utils/downloadWGConfig.ts
index f3c357d0..b395a076 100644
--- a/web/src/shared/utils/downloadWGConfig.ts
+++ b/web/src/shared/utils/downloadWGConfig.ts
@@ -2,7 +2,9 @@ import saveAs from 'file-saver';
 
 export const downloadWGConfig = (config: string, fileName: string) => {
   const blob = new Blob([config.replace(/^[^\S\r\n]+|[^\S\r\n]+$/gm, '')], {
-    type: 'text/plain;charset=utf-8',
+    // octet-stream is used here as a workaround: some browsers will append
+    // an additional .txt extension to the file name if the MIME type is text/plain.
+    type: 'application/octet-stream',
   });
   saveAs(blob, `${fileName.toLowerCase()}.conf`);
 };
