diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3b692bf74ae0a16386766a426585ee104343223..c3b18c5aab4d06de816644d647efd9d1471472c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 ### Added
 
 - Link settings that enable registrations and invites
+- Ability to upload logo, background, default user avatar, instance thumbnail, and NSFW hiding images
 
 ### Changed
 
diff --git a/src/api/mediaUpload.js b/src/api/mediaUpload.js
new file mode 100644
index 0000000000000000000000000000000000000000..1cbde204c05a870aa7c6abae16c0144ee8cdb9ae
--- /dev/null
+++ b/src/api/mediaUpload.js
@@ -0,0 +1,19 @@
+import { getToken } from '@/utils/auth'
+import { baseName } from './utils'
+
+const UPLOAD_URL = '/api/v1/media'
+
+export function uploadMedia({ formData, authHost }) {
+  const url = baseName(authHost) + UPLOAD_URL
+
+  return fetch(url, {
+    body: formData,
+    method: 'POST',
+    headers: authHeaders()
+  })
+    .then((data) => data.json())
+}
+
+const authHeaders = () => {
+  return { 'Authorization': `Bearer ${getToken()}` }
+}
diff --git a/src/lang/en.js b/src/lang/en.js
index 35d912528f4dec53cad1712a973c830d06d6215d..8375520900f4096836112ff7f3b509a87291cae9 100644
--- a/src/lang/en.js
+++ b/src/lang/en.js
@@ -391,7 +391,10 @@ export default {
     instanceReboot: 'Reboot Instance',
     restartApp: 'You must restart the instance to apply settings',
     restartSuccess: 'Instance rebooted successfully!',
-    removeSettingConfirmation: 'Are you sure you want to remove this setting\'s value from the database?'
+    removeSettingConfirmation: 'Are you sure you want to remove this setting\'s value from the database?',
+    changeImage: 'Change image',
+    uploadImage: 'Upload image',
+    remove: 'Remove'
   },
   invites: {
     inviteTokens: 'Invite tokens',
diff --git a/src/views/settings/components/Inputs.vue b/src/views/settings/components/Inputs.vue
index 0b14e2828d481c4b2fcc9ebc0399f97399505ce0..d8ca8cc28cb44c371b9537e4bab0da6ce7103d28 100644
--- a/src/views/settings/components/Inputs.vue
+++ b/src/views/settings/components/Inputs.vue
@@ -33,8 +33,16 @@
         </el-tooltip>
       </span>
       <div class="input-row">
+        <image-upload-input
+          v-if="isImageUrl"
+          :data="data"
+          :setting-group="settingGroup"
+          :setting="setting"
+          :input-value="inputValue"
+          @change="update($event, settingGroup.group, settingGroup.key, settingParent, setting.key, setting.type, nested)"
+        />
         <el-input
-          v-if="setting.type === 'string' || (setting.type.includes('string') && setting.type.includes('atom'))"
+          v-else-if="setting.type === 'string' || (setting.type.includes('string') && setting.type.includes('atom'))"
           :value="inputValue"
           :placeholder="setting.suggestions ? setting.suggestions[0] : null"
           :data-search="setting.key || setting.group"
@@ -125,6 +133,7 @@ import {
   CrontabInput,
   EditableKeywordInput,
   IconsInput,
+  ImageUploadInput,
   MascotsInput,
   ProxyUrlInput,
   PruneInput,
@@ -143,6 +152,7 @@ export default {
     CrontabInput,
     EditableKeywordInput,
     IconsInput,
+    ImageUploadInput,
     MascotsInput,
     ProxyUrlInput,
     PruneInput,
@@ -276,6 +286,9 @@ export default {
     },
     updatedSettings() {
       return this.$store.state.settings.updatedSettings
+    },
+    isImageUrl() {
+      return [':background', ':logo', ':nsfwCensorImage', ':default_user_avatar', ':instance_thumbnail'].includes(this.setting.key)
     }
   },
   methods: {
diff --git a/src/views/settings/components/inputComponents/ImageUploadInput.vue b/src/views/settings/components/inputComponents/ImageUploadInput.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f54b4190f144c0ff088fcee263e3a7764792a817
--- /dev/null
+++ b/src/views/settings/components/inputComponents/ImageUploadInput.vue
@@ -0,0 +1,204 @@
+<template>
+  <div class="image-upload-area">
+    <div class="input-row">
+      <div :style="dimensions" class="image-upload-wrapper">
+        <div :style="dimensions" class="image-upload-overlay">
+          <input
+            :aria-label="$t('settings.changeImage')"
+            class="input-file"
+            type="file"
+            accept=".jpg,.jpeg,.png"
+            @change="handleFiles" >
+          <div class="caption">
+            {{ $t('settings.changeImage') }}
+          </div>
+          <el-image
+            v-loading="loading"
+            :src="imageUrl(inputValue)"
+            :style="dimensions"
+            class="uploaded-image"
+            fit="cover" />
+        </div>
+      </div>
+    </div>
+    <div class="image-button-group">
+      <el-button class="upload-button" size="small">
+        {{ $t('settings.uploadImage') }}
+        <input
+          :aria-label="$t('settings.changeImage')"
+          class="input-file"
+          type="file"
+          accept=".jpg,.jpeg,.png"
+          @change="handleFiles">
+      </el-button>
+      <el-button v-if="!isDefault" type="danger" size="small" style="margin-left: 5px;" @click="removeFile()">
+        {{ $t('settings.remove') }}
+      </el-button>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+import _ from 'lodash'
+import { baseName } from '../../../../api/utils'
+import { uploadMedia } from '../../../../api/mediaUpload'
+
+export default {
+  name: 'ImageUploadInput',
+  props: {
+    inputValue: {
+      type: [String, Object],
+      default: function() {
+        return {}
+      }
+    },
+    setting: {
+      type: Object,
+      default: function() {
+        return {}
+      }
+    }
+  },
+  data() {
+    return {
+      loading: false
+    }
+  },
+  computed: {
+    ...mapGetters([
+      'authHost'
+    ]),
+    fullSize() {
+      if (_.includes([':background', ':nsfwCensorImage'], this.setting.key)) {
+        return true
+      }
+
+      return false
+    },
+    dimensions() {
+      return {
+        width: this.fullSize ? '100%' : '100px',
+        height: this.fullSize ? '250px' : '100px'
+      }
+    },
+    isDefault() {
+      return this.defaultImage === this.inputValue
+    },
+    defaultImage() {
+      return this.baseName + _.get(this.setting, 'suggestions[0]')
+    },
+    baseName() {
+      return baseName(this.authHost)
+    }
+  },
+  methods: {
+    imageUrl(url) {
+      if (_.isString(url)) {
+        const isUrl = url.startsWith('http') || url.startsWith('https')
+        return isUrl ? url : this.baseName + url
+      } else {
+        return this.defaultImage
+      }
+    },
+    handleFiles(event) {
+      const file = event.target.files[0]
+      if (!file) { return }
+      const reader = new FileReader()
+      reader.onload = ({ target }) => {
+        const formData = new FormData()
+        formData.append('file', file)
+        this.loading = true
+        uploadMedia({ formData, authHost: this.authHost }).then(response => {
+          this.loading = false
+          this.$emit('change', response.url)
+        })
+      }
+      reader.readAsDataURL(file)
+    },
+    removeFile() {
+      this.$emit('change', this.defaultImage)
+    }
+  }
+}
+</script>
+
+<style rel='stylesheet/scss' lang='scss'>
+@import '../../styles/main';
+@include settings;
+
+.image-upload-area {
+  .input-row {
+    display: flex;
+    align-items: center;
+  }
+
+  .input-file {
+    z-index: 100;
+    position: absolute;
+    top: 0px;
+    left: 0px;
+    width: 100%;
+    height: 100%;
+    opacity: 0;
+    cursor: pointer;
+  }
+
+  .image-button-group {
+    margin-top: 20px;
+
+    .upload-button {
+      position: relative;
+    }
+  }
+
+  .image-upload-wrapper {
+    position: relative;
+
+    .image-upload-overlay {
+      transition: box-shadow .1s;
+      border-radius: 5px;
+
+      .caption {
+        visibility: hidden;
+        position: absolute;
+        top: 0;
+        bottom: 0;
+        right: 0;
+        left: 0;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        font-weight: 700;
+        font-size: 10px;
+        text-transform: uppercase;;
+        color: #fff;
+        z-index: 9;
+        transition: box-shadow .1s;
+      }
+
+      .uploaded-image {
+        border-radius: 5px;
+        box-shadow: 0 2px 10px 0 rgba(0,0,0,.1);
+      }
+
+      &:hover {
+        visibility: visible;
+        cursor: pointer;
+        border-radius: 5px;
+
+        .el-image__error {
+          visibility: hidden;
+        }
+
+        .caption {
+          visibility: visible;
+          box-shadow: 0 2px 10px 0 rgba(0, 0, 0, 0.1), inset 0 0 120px 25px rgba(0, 0, 0, 0.8);
+          border-radius: 5px;
+        }
+      }
+    }
+  }
+}
+
+</style>
diff --git a/src/views/settings/components/inputComponents/index.js b/src/views/settings/components/inputComponents/index.js
index 3b9bc788e66afe54311cf7a3ef5dab2fd771cbd8..112819503463f491c232735915f551275845a8d0 100644
--- a/src/views/settings/components/inputComponents/index.js
+++ b/src/views/settings/components/inputComponents/index.js
@@ -2,6 +2,7 @@ export { default as AutoLinkerInput } from './AutoLinkerInput'
 export { default as EditableKeywordInput } from './EditableKeywordInput'
 export { default as CrontabInput } from './CrontabInput'
 export { default as IconsInput } from './IconsInput'
+export { default as ImageUploadInput } from './ImageUploadInput'
 export { default as MascotsInput } from './MascotsInput'
 export { default as ProxyUrlInput } from './ProxyUrlInput'
 export { default as PruneInput } from './PruneInput'