diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md
index 548e279bd9a7e9af833b224fa10ec86e9977be7f..73ca769ec2a1abb3dd7417128b2e2a4465aac4bb 100644
--- a/docs/usage/self-hosted-configuration.md
+++ b/docs/usage/self-hosted-configuration.md
@@ -265,6 +265,49 @@ Warning: this is an experimental feature and may be modified or removed in a fut
 
 ## requireConfig
 
+## secrets
+
+Secrets may be configured by a bot admin in `config.js`, which will then make them available for templating within repository configs.
+For example, to configure a `GOOGLE_TOKEN` to be accessible by all repositories:
+
+```js
+module.exports = {
+  secrets: {
+    GOOGLE_TOKEN: 'abc123',
+  },
+};
+```
+
+They can also be configured per repository, e.g.
+
+```js
+module.exports = {
+  repositories: [
+    {
+      repository: 'abc/def',
+      secrets: {
+        GOOGLE_TOKEN: 'abc123',
+      },
+    },
+  ],
+};
+```
+
+It could then be used in a repository config or preset like so:
+
+```json
+{
+  "hostRules": [
+    {
+      "domainName": "google.com",
+      "token": "{{ secrets.GOOGLE_TOKEN }}"
+    }
+  ]
+}
+```
+
+Secret names must start with a upper or lower case character and can contain only characters, digits, or underscores.
+
 ## skipInstalls
 
 By default, Renovate will use the most efficient approach to updating package files and lock files, which in most cases skips the need to perform a full module install by the bot.
diff --git a/lib/config/__snapshots__/secrets.spec.ts.snap b/lib/config/__snapshots__/secrets.spec.ts.snap
new file mode 100644
index 0000000000000000000000000000000000000000..39a49c77ad7ea2adb47c6a9629d01c8d8d9f7602
--- /dev/null
+++ b/lib/config/__snapshots__/secrets.spec.ts.snap
@@ -0,0 +1,34 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`config/secrets applySecretsToConfig(config) replaces secrets in a array of objects 1`] = `
+Object {
+  "hostRules": Array [
+    Object {
+      "hostType": "npm",
+      "token": "abc123==",
+    },
+  ],
+}
+`;
+
+exports[`config/secrets applySecretsToConfig(config) replaces secrets in a array of strings 1`] = `
+Object {
+  "allowedManagers": Array [
+    "npm",
+  ],
+}
+`;
+
+exports[`config/secrets applySecretsToConfig(config) replaces secrets in a subobject 1`] = `
+Object {
+  "npm": Object {
+    "npmToken": "abc123==",
+  },
+}
+`;
+
+exports[`config/secrets applySecretsToConfig(config) replaces secrets in the top level 1`] = `
+Object {
+  "npmToken": "abc123==",
+}
+`;
diff --git a/lib/config/definitions.ts b/lib/config/definitions.ts
index eae0fb02e28e19ab2e753d41484e0aac645a0c60..cd825d0e120c584edd498de485367721193f47e2 100644
--- a/lib/config/definitions.ts
+++ b/lib/config/definitions.ts
@@ -106,6 +106,17 @@ const options: RenovateOptions[] = [
       format: 'uri',
     },
   },
+  {
+    name: 'secrets',
+    description: 'Object containing secret name/value pairs',
+    type: 'object',
+    admin: true,
+    mergeable: true,
+    default: {},
+    additionalProperties: {
+      type: 'string',
+    },
+  },
   {
     name: 'extends',
     description:
diff --git a/lib/config/secrets.spec.ts b/lib/config/secrets.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1ea2c095baf14d541f0aca7fc3bbe7459e0e7b9f
--- /dev/null
+++ b/lib/config/secrets.spec.ts
@@ -0,0 +1,101 @@
+import { defaultConfig, getName } from '../../test/util';
+import {
+  CONFIG_SECRETS_INVALID,
+  CONFIG_VALIDATION,
+} from '../constants/error-messages';
+import { applySecretsToConfig, validateConfigSecrets } from './secrets';
+
+describe(getName(__filename), () => {
+  describe('validateConfigSecrets(config)', () => {
+    it('works with default config', () => {
+      expect(() => validateConfigSecrets(defaultConfig)).not.toThrow();
+    });
+    it('returns if no secrets', () => {
+      expect(validateConfigSecrets({})).toBeUndefined();
+    });
+    it('throws if secrets is not an object', () => {
+      expect(() => validateConfigSecrets({ secrets: 'hello' } as any)).toThrow(
+        CONFIG_SECRETS_INVALID
+      );
+    });
+    it('throws for invalid secret names', () => {
+      expect(() =>
+        validateConfigSecrets({ secrets: { '123': 'abc' } })
+      ).toThrow(CONFIG_SECRETS_INVALID);
+    });
+    it('throws for non-string secret', () => {
+      expect(() =>
+        validateConfigSecrets({ secrets: { abc: 123 } } as any)
+      ).toThrow(CONFIG_SECRETS_INVALID);
+    });
+    it('throws for secrets inside repositories', () => {
+      expect(() =>
+        validateConfigSecrets({
+          repositories: [
+            { repository: 'abc/def', secrets: { abc: 123 } },
+          ] as any,
+        })
+      ).toThrow(CONFIG_SECRETS_INVALID);
+    });
+  });
+
+  describe('applySecretsToConfig(config)', () => {
+    it('works with default config', () => {
+      expect(() => applySecretsToConfig(defaultConfig)).not.toThrow();
+    });
+
+    it('throws if disallowed field is used', () => {
+      const config = {
+        prTitle: '{{ secrets.ARTIFACTORY_TOKEN }}',
+        secrets: {
+          ARTIFACTORY_TOKEN: 'abc123==',
+        },
+      };
+      expect(() => applySecretsToConfig(config)).toThrow(CONFIG_VALIDATION);
+    });
+    it('throws if an unknown secret is used', () => {
+      const config = {
+        npmToken: '{{ secrets.ARTIFACTORY_TOKEN }}',
+      };
+      expect(() => applySecretsToConfig(config)).toThrow(CONFIG_VALIDATION);
+    });
+    it('replaces secrets in the top level', () => {
+      const config = {
+        secrets: { ARTIFACTORY_TOKEN: 'abc123==' },
+        npmToken: '{{ secrets.ARTIFACTORY_TOKEN }}',
+      };
+      const res = applySecretsToConfig(config);
+      expect(res).toMatchSnapshot();
+      expect(Object.keys(res)).not.toContain('secrets');
+    });
+    it('replaces secrets in a subobject', () => {
+      const config = {
+        secrets: { ARTIFACTORY_TOKEN: 'abc123==' },
+        npm: { npmToken: '{{ secrets.ARTIFACTORY_TOKEN }}' },
+      };
+      const res = applySecretsToConfig(config);
+      expect(res).toMatchSnapshot();
+      expect(Object.keys(res)).not.toContain('secrets');
+    });
+    it('replaces secrets in a array of objects', () => {
+      const config = {
+        secrets: { ARTIFACTORY_TOKEN: 'abc123==' },
+        hostRules: [
+          { hostType: 'npm', token: '{{ secrets.ARTIFACTORY_TOKEN }}' },
+        ],
+      };
+      const res = applySecretsToConfig(config);
+      expect(res).toMatchSnapshot();
+      expect(Object.keys(res)).not.toContain('secrets');
+    });
+    it('replaces secrets in a array of strings', () => {
+      const config = {
+        secrets: { SECRET_MANAGER: 'npm' },
+        allowedManagers: ['{{ secrets.SECRET_MANAGER }}'],
+      };
+      const res = applySecretsToConfig(config);
+      expect(res).toMatchSnapshot();
+      expect(Object.keys(res)).not.toContain('secrets');
+    });
+  });
+});
diff --git a/lib/config/secrets.ts b/lib/config/secrets.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7b1fd70ed6cec9c0358b86a4c8246f3a26e872a0
--- /dev/null
+++ b/lib/config/secrets.ts
@@ -0,0 +1,124 @@
+import is from '@sindresorhus/is';
+import {
+  CONFIG_SECRETS_INVALID,
+  CONFIG_VALIDATION,
+} from '../constants/error-messages';
+import { logger } from '../logger';
+import { regEx } from '../util/regex';
+import { add } from '../util/sanitize';
+import { GlobalConfig, RenovateConfig } from './types';
+
+const secretNamePattern = '[A-Za-z][A-Za-z0-9_]*';
+
+const secretNameRegex = regEx(`^${secretNamePattern}$`);
+const secretTemplateRegex = regEx(`{{ secrets\\.(${secretNamePattern}) }}`);
+
+function validateSecrets(secrets_: unknown): void {
+  if (!secrets_) {
+    return;
+  }
+  const validationErrors: string[] = [];
+  if (is.plainObject(secrets_)) {
+    for (const [secretName, secretValue] of Object.entries(secrets_)) {
+      if (!secretNameRegex.test(secretName)) {
+        validationErrors.push(`Invalid secret name "${secretName}"`);
+      }
+      if (!is.string(secretValue)) {
+        validationErrors.push(
+          `Secret values must be strings. Found type ${typeof secretValue} for secret ${secretName}`
+        );
+      }
+    }
+  } else {
+    validationErrors.push(
+      `Config secrets must be a plain object. Found: ${typeof secrets_}`
+    );
+  }
+  if (validationErrors.length) {
+    logger.error({ validationErrors }, 'Invalid secrets configured');
+    throw new Error(CONFIG_SECRETS_INVALID);
+  }
+}
+
+export function validateConfigSecrets(config: GlobalConfig): void {
+  validateSecrets(config.secrets);
+  if (config.repositories) {
+    for (const repository of config.repositories) {
+      if (is.plainObject(repository)) {
+        validateSecrets(repository.secrets);
+      }
+    }
+  }
+}
+
+function replaceSecretsInString(
+  key: string,
+  value: string,
+  secrets: Record<string, string>
+): string {
+  // do nothing if no secret template found
+  if (!secretTemplateRegex.test(value)) {
+    return value;
+  }
+
+  const disallowedPrefixes = ['branch', 'commit', 'group', 'pr', 'semantic'];
+  if (disallowedPrefixes.some((prefix) => key.startsWith(prefix))) {
+    const error = new Error(CONFIG_VALIDATION);
+    error.configFile = 'config';
+    error.validationError = 'Disallowed secret substitution';
+    error.validationMessage = `The field ${key} may not use secret substitution`;
+    throw error;
+  }
+  return value.replace(secretTemplateRegex, (_, secretName) => {
+    if (secrets[secretName]) {
+      return secrets[secretName];
+    }
+    const error = new Error(CONFIG_VALIDATION);
+    error.configFile = 'config';
+    error.validationError = 'Unknown secret name';
+    error.validationMessage = `The following secret name was not found in config: ${String(
+      secretName
+    )}`;
+    throw error;
+  });
+}
+
+function replaceSecretsinObject(
+  config_: RenovateConfig,
+  secrets: Record<string, string> = {}
+): RenovateConfig {
+  const config = { ...config_ };
+  delete config.secrets;
+  for (const [key, value] of Object.entries(config)) {
+    if (is.plainObject(value)) {
+      config[key] = replaceSecretsinObject(value, secrets);
+    }
+    if (is.string(value)) {
+      config[key] = replaceSecretsInString(key, value, secrets);
+    }
+    if (is.array(value)) {
+      for (const [arrayIndex, arrayItem] of value.entries()) {
+        if (is.plainObject(arrayItem)) {
+          config[key][arrayIndex] = replaceSecretsinObject(arrayItem, secrets);
+        } else if (is.string(arrayItem)) {
+          config[key][arrayIndex] = replaceSecretsInString(
+            key,
+            arrayItem,
+            secrets
+          );
+        }
+      }
+    }
+  }
+  return config;
+}
+
+export function applySecretsToConfig(config: RenovateConfig): RenovateConfig {
+  // Add all secrets to be sanitized
+  if (is.plainObject(config.secrets)) {
+    for (const secret of Object.values(config.secrets)) {
+      add(String(secret));
+    }
+  }
+  return replaceSecretsinObject(config, config.secrets);
+}
diff --git a/lib/config/types.ts b/lib/config/types.ts
index 82e2658e2330824c66c6d19d48f03f6fe8f2e6a7..0d9b623746dc2ed93da36f716025ab6eb019e98e 100644
--- a/lib/config/types.ts
+++ b/lib/config/types.ts
@@ -127,6 +127,7 @@ export type RenovateRepository =
   | string
   | {
       repository: string;
+      secrets?: Record<string, string>;
     };
 
 export interface CustomManager {
@@ -189,6 +190,7 @@ export interface RenovateConfig
   regexManagers?: CustomManager[];
 
   fetchReleaseNotes?: boolean;
+  secrets?: Record<string, string>;
 }
 
 export interface GlobalConfig extends RenovateConfig, GlobalOnlyConfig {}
diff --git a/lib/constants/error-messages.ts b/lib/constants/error-messages.ts
index b2dff002a2f4b994eb0aee0e4dcfc169d1239d8a..b032e18fcee8a206113b760be3248008606aec02 100644
--- a/lib/constants/error-messages.ts
+++ b/lib/constants/error-messages.ts
@@ -13,6 +13,7 @@ export const PLATFORM_RATE_LIMIT_EXCEEDED = 'rate-limit-exceeded';
 // Config Error
 export const CONFIG_VALIDATION = 'config-validation';
 export const CONFIG_SECRETS_EXPOSED = 'config-secrets-exposed';
+export const CONFIG_SECRETS_INVALID = 'config-secrets-invalid';
 
 // Repository Errors - causes repo to be considered as disabled
 export const REPOSITORY_ACCESS_FORBIDDEN = 'forbidden';
diff --git a/lib/logger/index.spec.ts b/lib/logger/index.spec.ts
index 79f599a06199b2fde59ec7b80e2bdb2132221e15..38ed6bf4d1af9e6912a63298b383eb80651e4133 100644
--- a/lib/logger/index.spec.ts
+++ b/lib/logger/index.spec.ts
@@ -154,6 +154,9 @@ describe('logger', () => {
       buffer: Buffer.from('test'),
       content: 'test',
       prBody: 'test',
+      secrets: {
+        foo: 'barsecret',
+      },
     });
 
     expect(logged.foo).not.toEqual('secret"password');
@@ -164,5 +167,6 @@ describe('logger', () => {
     expect(logged.buffer).toEqual('[content]');
     expect(logged.content).toEqual('[content]');
     expect(logged.prBody).toEqual('[Template]');
+    expect(logged.secrets.foo).toEqual('***********');
   });
 });
diff --git a/lib/logger/utils.ts b/lib/logger/utils.ts
index d9cb166a507471ddd97ee95aaddbe9ec6635ae96..77f77e73ca2268c7b8b97d15a402822d2dd2c927 100644
--- a/lib/logger/utils.ts
+++ b/lib/logger/utils.ts
@@ -135,6 +135,11 @@ export function sanitizeValue(value: unknown, seen = new WeakMap()): any {
         curValue = '[content]';
       } else if (templateFields.includes(key)) {
         curValue = '[Template]';
+      } else if (key === 'secrets') {
+        curValue = {};
+        Object.keys(val).forEach((secretKey) => {
+          curValue[secretKey] = '***********';
+        });
       } else {
         curValue = seen.has(val) ? seen.get(val) : sanitizeValue(val, seen);
       }
diff --git a/lib/workers/global/index.ts b/lib/workers/global/index.ts
index 3576a08d133218b72ac20e85d57cba542039dd8b..06600d5da6ef1b22922305b9da21b323e16895cd 100644
--- a/lib/workers/global/index.ts
+++ b/lib/workers/global/index.ts
@@ -6,6 +6,7 @@ import upath from 'upath';
 import * as pkg from '../../../package.json';
 import * as configParser from '../../config';
 import { GlobalConfig } from '../../config';
+import { validateConfigSecrets } from '../../config/secrets';
 import { getProblems, logger, setMeta } from '../../logger';
 import { setUtilConfig } from '../../util';
 import * as hostRules from '../../util/host-rules';
@@ -78,6 +79,9 @@ export async function start(): Promise<number> {
 
     checkEnv();
 
+    // validate secrets. Will throw and abort if invalid
+    validateConfigSecrets(config);
+
     // autodiscover repositories (needs to come after platform initialization)
     config = await autodiscoverRepositories(config);
     // Iterate through repositories sequentially
diff --git a/lib/workers/repository/init/index.spec.ts b/lib/workers/repository/init/index.spec.ts
index 6554432451fd694beb9a53a483945e3e5b5fcc55..c4a27665f6f7a53f98ae33cc094731fe524fafca 100644
--- a/lib/workers/repository/init/index.spec.ts
+++ b/lib/workers/repository/init/index.spec.ts
@@ -1,4 +1,5 @@
 import { mocked } from '../../../../test/util';
+import * as _secrets from '../../../config/secrets';
 import * as _onboarding from '../onboarding/branch';
 import * as _apis from './apis';
 import * as _config from './config';
@@ -9,11 +10,13 @@ jest.mock('../onboarding/branch');
 jest.mock('../configured');
 jest.mock('../init/apis');
 jest.mock('../init/config');
+jest.mock('../../../config/secrets');
 jest.mock('../init/semantic');
 
 const apis = mocked(_apis);
 const config = mocked(_config);
 const onboarding = mocked(_onboarding);
+const secrets = mocked(_secrets);
 
 describe('workers/repository/init', () => {
   describe('initRepo', () => {
@@ -22,6 +25,7 @@ describe('workers/repository/init', () => {
       onboarding.checkOnboardingBranch.mockResolvedValueOnce({});
       config.getRepoConfig.mockResolvedValueOnce({});
       config.mergeRenovateConfig.mockResolvedValueOnce({});
+      secrets.applySecretsToConfig.mockReturnValueOnce({} as never);
       const renovateConfig = await initRepo({});
       expect(renovateConfig).toMatchSnapshot();
     });
diff --git a/lib/workers/repository/init/index.ts b/lib/workers/repository/init/index.ts
index da3a12015119fe91dee9f804670c3a5a9add20cb..4896b9501d580618d8491029ea81c2c7bd455e17 100644
--- a/lib/workers/repository/init/index.ts
+++ b/lib/workers/repository/init/index.ts
@@ -1,4 +1,5 @@
 import { RenovateConfig } from '../../../config';
+import { applySecretsToConfig } from '../../../config/secrets';
 import { logger } from '../../../logger';
 import { clone } from '../../../util/clone';
 import { setUserRepoConfig } from '../../../util/git';
@@ -20,6 +21,7 @@ export async function initRepo(
   config = await initApis(config);
   config = await getRepoConfig(config);
   checkIfConfigured(config);
+  config = applySecretsToConfig(config);
   await setUserRepoConfig(config);
   config = await detectVulnerabilityAlerts(config);
   // istanbul ignore if