diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml
index effec83c27f8e41d441cc5f6947eea2f18c85a6b..f509c63566248fc43d9b1166fe2a770c78cbec4d 100644
--- a/config/custom-environment-variables.yml
+++ b/config/custom-environment-variables.yml
@@ -6,6 +6,15 @@ public:
   metrics:
     prometheus:
       enabled: 'METRICS_PROMETHEUS_ENABLED'
+      endpointEnabled: 'METRICS_PROMETHEUS_ENDPOINT_ENABLED'
+    influx:
+      enabled: 'METRICS_INFLUX_ENABLED'
+      url: 'METRICS_INFLUX_URL'
+      timeoutMilliseconds: 'METRICS_INFLUX_TIMEOUT_MILLISECONDS'
+      intervalSeconds: 'METRICS_INFLUX_INTERVAL_SECONDS'
+      instanceIdFrom: 'METRICS_INFLUX_INSTANCE_ID_FROM'
+      instanceIdEnvVarName: 'METRICS_INFLUX_INSTANCE_ID_ENV_VAR_NAME'
+      envLabel: 'METRICS_INFLUX_ENV_LABEL'
 
   ssl:
     isSecure: 'HTTPS'
@@ -85,3 +94,5 @@ private:
   twitch_client_id: 'TWITCH_CLIENT_ID'
   twitch_client_secret: 'TWITCH_CLIENT_SECRET'
   wheelmap_token: 'WHEELMAP_TOKEN'
+  influx_username: 'INFLUX_USERNAME'
+  influx_password: 'INFLUX_PASSWORD'
diff --git a/config/default.yml b/config/default.yml
index 405670561c37a7463f59a295d5a90887763ab01c..e782ca08572cb816eaac144e025c591572edba40 100644
--- a/config/default.yml
+++ b/config/default.yml
@@ -5,7 +5,11 @@ public:
   metrics:
     prometheus:
       enabled: false
-
+      endpointEnabled: false
+    influx:
+      enabled: false
+      timeoutMilliseconds: 1000
+      intervalSeconds: 15
   ssl:
     isSecure: false
 
diff --git a/config/shields-io-production.yml b/config/shields-io-production.yml
index 1ca9d5805a5afdfcd3d139994c4f6c9a29bbbb99..e039532cab39f14399cae1dce373e7e20b4a9084 100644
--- a/config/shields-io-production.yml
+++ b/config/shields-io-production.yml
@@ -2,6 +2,7 @@ public:
   metrics:
     prometheus:
       enabled: true
+      endpointEnabled: true
 
   ssl:
     isSecure: true
diff --git a/core/server/in-process-server-test-helpers.js b/core/server/in-process-server-test-helpers.js
index fbf530a48d0aa7652abffde47174557aa8260cf3..85280a1a316292c5ec132b890c53c0eb47ae7e13 100644
--- a/core/server/in-process-server-test-helpers.js
+++ b/core/server/in-process-server-test-helpers.js
@@ -1,21 +1,16 @@
 'use strict'
 
+const merge = require('deepmerge')
 const config = require('config').util.toObject()
+const portfinder = require('portfinder')
 const Server = require('./server')
 
-function createTestServer({ port }) {
-  const serverConfig = {
-    ...config,
-    public: {
-      ...config.public,
-      bind: {
-        ...config.public.bind,
-        port,
-      },
-    },
+async function createTestServer(customConfig = {}) {
+  const mergedConfig = merge(config, customConfig)
+  if (!mergedConfig.public.bind.port) {
+    mergedConfig.public.bind.port = await portfinder.getPortPromise()
   }
-
-  return new Server(serverConfig)
+  return new Server(mergedConfig)
 }
 
 module.exports = {
diff --git a/core/server/influx-metrics.js b/core/server/influx-metrics.js
new file mode 100644
index 0000000000000000000000000000000000000000..46e10a4f378965f5702455c018a116d158788089
--- /dev/null
+++ b/core/server/influx-metrics.js
@@ -0,0 +1,86 @@
+'use strict'
+const os = require('os')
+const { promisify } = require('util')
+const { post } = require('request')
+const postAsync = promisify(post)
+const generateInstanceId = require('./instance-id-generator')
+const { promClientJsonToInfluxV2 } = require('./metrics/format-converters')
+const log = require('./log')
+
+module.exports = class InfluxMetrics {
+  constructor(metricInstance, config) {
+    this._metricInstance = metricInstance
+    this._config = config
+    this._instanceId = this.getInstanceId()
+  }
+
+  async sendMetrics() {
+    const auth = {
+      user: this._config.username,
+      pass: this._config.password,
+    }
+    const request = {
+      uri: this._config.url,
+      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+      body: this.metrics(),
+      timeout: this._config.timeoutMillseconds,
+      auth,
+    }
+
+    let response
+    try {
+      response = await postAsync(request)
+    } catch (error) {
+      log.error(
+        new Error(`Cannot push metrics. Cause: ${error.name}: ${error.message}`)
+      )
+    }
+    if (response && response.statusCode >= 300) {
+      log.error(
+        new Error(
+          `Cannot push metrics. ${response.request.href} responded with status code ${response.statusCode}`
+        )
+      )
+    }
+  }
+
+  startPushingMetrics() {
+    this._intervalId = setInterval(
+      () => this.sendMetrics(),
+      this._config.intervalSeconds * 1000
+    )
+  }
+
+  metrics() {
+    return promClientJsonToInfluxV2(this._metricInstance.metrics(), {
+      env: this._config.envLabel,
+      application: 'shields',
+      instance: this._instanceId,
+    })
+  }
+
+  getInstanceId() {
+    const {
+      hostnameAliases = {},
+      instanceIdFrom,
+      instanceIdEnvVarName,
+    } = this._config
+    let instance
+    if (instanceIdFrom === 'env-var') {
+      instance = process.env[instanceIdEnvVarName]
+    } else if (instanceIdFrom === 'hostname') {
+      const hostname = os.hostname()
+      instance = hostnameAliases[hostname] || hostname
+    } else if (instanceIdFrom === 'random') {
+      instance = generateInstanceId()
+    }
+    return instance
+  }
+
+  stopPushingMetrics() {
+    if (this._intervalId) {
+      clearInterval(this._intervalId)
+      this._intervalId = undefined
+    }
+  }
+}
diff --git a/core/server/influx-metrics.spec.js b/core/server/influx-metrics.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..9885754fbbe7a157aad921355a49cc017f688779
--- /dev/null
+++ b/core/server/influx-metrics.spec.js
@@ -0,0 +1,177 @@
+'use strict'
+const os = require('os')
+const nock = require('nock')
+const sinon = require('sinon')
+const { expect } = require('chai')
+const log = require('./log')
+const InfluxMetrics = require('./influx-metrics')
+require('../register-chai-plugins.spec')
+describe('Influx metrics', function() {
+  const metricInstance = {
+    metrics() {
+      return [
+        {
+          help: 'counter 1 help',
+          name: 'counter1',
+          type: 'counter',
+          values: [{ value: 11, labels: {} }],
+          aggregator: 'sum',
+        },
+      ]
+    },
+  }
+  describe('"metrics" function', function() {
+    let osHostnameStub
+    afterEach(function() {
+      nock.enableNetConnect()
+      delete process.env.INSTANCE_ID
+      if (osHostnameStub) {
+        osHostnameStub.restore()
+      }
+    })
+    it('should use an environment variable value as an instance label', async function() {
+      process.env.INSTANCE_ID = 'instance3'
+      const influxMetrics = new InfluxMetrics(metricInstance, {
+        instanceIdFrom: 'env-var',
+        instanceIdEnvVarName: 'INSTANCE_ID',
+      })
+
+      expect(influxMetrics.metrics()).to.contain('instance=instance3')
+    })
+
+    it('should use a hostname as an instance label', async function() {
+      osHostnameStub = sinon.stub(os, 'hostname').returns('test-hostname')
+      const customConfig = {
+        instanceIdFrom: 'hostname',
+      }
+      const influxMetrics = new InfluxMetrics(metricInstance, customConfig)
+
+      expect(influxMetrics.metrics()).to.be.contain('instance=test-hostname')
+    })
+
+    it('should use a random string as an instance label', async function() {
+      const customConfig = {
+        instanceIdFrom: 'random',
+      }
+      const influxMetrics = new InfluxMetrics(metricInstance, customConfig)
+
+      expect(influxMetrics.metrics()).to.be.match(/instance=\w+ /)
+    })
+
+    it('should use a hostname alias as an instance label', async function() {
+      osHostnameStub = sinon.stub(os, 'hostname').returns('test-hostname')
+      const customConfig = {
+        instanceIdFrom: 'hostname',
+        hostnameAliases: { 'test-hostname': 'test-hostname-alias' },
+      }
+      const influxMetrics = new InfluxMetrics(metricInstance, customConfig)
+
+      expect(influxMetrics.metrics()).to.be.contain(
+        'instance=test-hostname-alias'
+      )
+    })
+  })
+
+  describe('startPushingMetrics', function() {
+    let influxMetrics, clock
+    beforeEach(function() {
+      clock = sinon.useFakeTimers()
+    })
+    afterEach(function() {
+      influxMetrics.stopPushingMetrics()
+      nock.cleanAll()
+      nock.enableNetConnect()
+      delete process.env.INSTANCE_ID
+      clock.restore()
+    })
+
+    it('should send metrics', async function() {
+      const scope = nock('http://shields-metrics.io/', {
+        reqheaders: {
+          'Content-Type': 'application/x-www-form-urlencoded',
+        },
+      })
+        .persist()
+        .post(
+          '/metrics',
+          'prometheus,application=shields,env=test-env,instance=instance2 counter1=11'
+        )
+        .basicAuth({ user: 'metrics-username', pass: 'metrics-password' })
+        .reply(200)
+      process.env.INSTANCE_ID = 'instance2'
+      influxMetrics = new InfluxMetrics(metricInstance, {
+        url: 'http://shields-metrics.io/metrics',
+        timeoutMillseconds: 100,
+        intervalSeconds: 0.001,
+        username: 'metrics-username',
+        password: 'metrics-password',
+        instanceIdFrom: 'env-var',
+        instanceIdEnvVarName: 'INSTANCE_ID',
+        envLabel: 'test-env',
+      })
+
+      influxMetrics.startPushingMetrics()
+
+      await clock.tickAsync(10)
+      expect(scope.isDone()).to.be.equal(
+        true,
+        `pending mocks: ${scope.pendingMocks()}`
+      )
+    })
+  })
+
+  describe('sendMetrics', function() {
+    beforeEach(function() {
+      sinon.spy(log, 'error')
+    })
+    afterEach(function() {
+      log.error.restore()
+      nock.cleanAll()
+      nock.enableNetConnect()
+    })
+
+    const influxMetrics = new InfluxMetrics(metricInstance, {
+      url: 'http://shields-metrics.io/metrics',
+      timeoutMillseconds: 50,
+      intervalSeconds: 0,
+      username: 'metrics-username',
+      password: 'metrics-password',
+    })
+    it('should log errors', async function() {
+      nock.disableNetConnect()
+
+      await influxMetrics.sendMetrics()
+
+      expect(log.error).to.be.calledWith(
+        sinon.match
+          .instanceOf(Error)
+          .and(
+            sinon.match.has(
+              'message',
+              'Cannot push metrics. Cause: NetConnectNotAllowedError: Nock: Disallowed net connect for "shields-metrics.io:80/metrics"'
+            )
+          )
+      )
+    })
+
+    it('should log error responses', async function() {
+      nock('http://shields-metrics.io/')
+        .persist()
+        .post('/metrics')
+        .reply(400)
+
+      await influxMetrics.sendMetrics()
+
+      expect(log.error).to.be.calledWith(
+        sinon.match
+          .instanceOf(Error)
+          .and(
+            sinon.match.has(
+              'message',
+              'Cannot push metrics. http://shields-metrics.io/metrics responded with status code 400'
+            )
+          )
+      )
+    })
+  })
+})
diff --git a/core/server/instance-id-generator.js b/core/server/instance-id-generator.js
new file mode 100644
index 0000000000000000000000000000000000000000..0036df2648842f0ab739a91a60687377746bcb11
--- /dev/null
+++ b/core/server/instance-id-generator.js
@@ -0,0 +1,10 @@
+'use strict'
+
+function generateInstanceId() {
+  // from https://gist.github.com/gordonbrander/2230317
+  return Math.random()
+    .toString(36)
+    .substr(2, 9)
+}
+
+module.exports = generateInstanceId
diff --git a/core/server/metrics/format-converters.js b/core/server/metrics/format-converters.js
new file mode 100644
index 0000000000000000000000000000000000000000..e6ac8e2332c01cbe28806756ed76b2ab60c03923
--- /dev/null
+++ b/core/server/metrics/format-converters.js
@@ -0,0 +1,27 @@
+'use strict'
+const groupBy = require('lodash.groupby')
+
+function promClientJsonToInfluxV2(metrics, extraLabels = {}) {
+  // TODO Replace with Array.prototype.flatMap() after migrating to Node.js >= 11
+  const flatMap = (f, arr) => arr.reduce((acc, x) => acc.concat(f(x)), [])
+  return flatMap(metric => {
+    const valuesByLabels = groupBy(metric.values, value =>
+      JSON.stringify(Object.entries(value.labels).sort())
+    )
+    return Object.values(valuesByLabels).map(metricsWithSameLabel => {
+      const labels = Object.entries(metricsWithSameLabel[0].labels)
+        .concat(Object.entries(extraLabels))
+        .sort((a, b) => a[0].localeCompare(b[0]))
+        .map(labelEntry => `${labelEntry[0]}=${labelEntry[1]}`)
+        .join(',')
+      const labelsFormatted = labels ? `,${labels}` : ''
+      const values = metricsWithSameLabel
+        .sort((a, b) => a.metricName.localeCompare(b.metricName))
+        .map(value => `${value.metricName || metric.name}=${value.value}`)
+        .join(',')
+      return `prometheus${labelsFormatted} ${values}`
+    })
+  }, metrics).join('\n')
+}
+
+module.exports = { promClientJsonToInfluxV2 }
diff --git a/core/server/metrics/format-converters.spec.js b/core/server/metrics/format-converters.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..373c3d9cdab2ba7f99382a4896efb22a9efc9384
--- /dev/null
+++ b/core/server/metrics/format-converters.spec.js
@@ -0,0 +1,217 @@
+'use strict'
+
+const { expect } = require('chai')
+const prometheus = require('prom-client')
+const { promClientJsonToInfluxV2 } = require('./format-converters')
+
+describe('Metric format converters', function() {
+  describe('prom-client JSON to InfluxDB line protocol (version 2)', function() {
+    it('converts a counter', function() {
+      const json = [
+        {
+          help: 'counter 1 help',
+          name: 'counter1',
+          type: 'counter',
+          values: [{ value: 11, labels: {} }],
+          aggregator: 'sum',
+        },
+      ]
+
+      const influx = promClientJsonToInfluxV2(json)
+
+      expect(influx).to.be.equal('prometheus counter1=11')
+    })
+
+    it('converts a counter (from prometheus registry)', function() {
+      const register = new prometheus.Registry()
+      const counter = new prometheus.Counter({
+        name: 'counter1',
+        help: 'counter 1 help',
+        registers: [register],
+      })
+      counter.inc(11)
+
+      const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON())
+
+      expect(influx).to.be.equal('prometheus counter1=11')
+    })
+
+    it('converts a gauge', function() {
+      const json = [
+        {
+          help: 'gause 1 help',
+          name: 'gauge1',
+          type: 'gauge',
+          values: [{ value: 20, labels: {} }],
+          aggregator: 'sum',
+        },
+      ]
+
+      const influx = promClientJsonToInfluxV2(json)
+
+      expect(influx).to.be.equal('prometheus gauge1=20')
+    })
+
+    it('converts a gauge (from prometheus registry)', function() {
+      const register = new prometheus.Registry()
+      const gauge = new prometheus.Gauge({
+        name: 'gauge1',
+        help: 'gauge 1 help',
+        registers: [register],
+      })
+      gauge.inc(20)
+
+      const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON())
+
+      expect(influx).to.be.equal('prometheus gauge1=20')
+    })
+
+    const sortLines = text =>
+      text
+        .split('\n')
+        .sort()
+        .join('\n')
+
+    it('converts a histogram', function() {
+      const json = [
+        {
+          name: 'histogram1',
+          help: 'histogram 1 help',
+          type: 'histogram',
+          values: [
+            { labels: { le: 5 }, value: 1, metricName: 'histogram1_bucket' },
+            { labels: { le: 15 }, value: 2, metricName: 'histogram1_bucket' },
+            { labels: { le: 50 }, value: 2, metricName: 'histogram1_bucket' },
+            {
+              labels: { le: '+Inf' },
+              value: 3,
+              metricName: 'histogram1_bucket',
+            },
+            { labels: {}, value: 111, metricName: 'histogram1_sum' },
+            { labels: {}, value: 3, metricName: 'histogram1_count' },
+          ],
+          aggregator: 'sum',
+        },
+      ]
+
+      const influx = promClientJsonToInfluxV2(json)
+
+      expect(sortLines(influx)).to.be.equal(
+        sortLines(`prometheus,le=+Inf histogram1_bucket=3
+prometheus,le=50 histogram1_bucket=2
+prometheus,le=15 histogram1_bucket=2
+prometheus,le=5 histogram1_bucket=1
+prometheus histogram1_count=3,histogram1_sum=111`)
+      )
+    })
+
+    it('converts a histogram (from prometheus registry)', function() {
+      const register = new prometheus.Registry()
+      const histogram = new prometheus.Histogram({
+        name: 'histogram1',
+        help: 'histogram 1 help',
+        buckets: [5, 15, 50],
+        registers: [register],
+      })
+      histogram.observe(100)
+      histogram.observe(10)
+      histogram.observe(1)
+
+      const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON())
+
+      expect(sortLines(influx)).to.be.equal(
+        sortLines(`prometheus,le=+Inf histogram1_bucket=3
+prometheus,le=50 histogram1_bucket=2
+prometheus,le=15 histogram1_bucket=2
+prometheus,le=5 histogram1_bucket=1
+prometheus histogram1_count=3,histogram1_sum=111`)
+      )
+    })
+
+    it('converts a summary', function() {
+      const json = [
+        {
+          name: 'summary1',
+          help: 'summary 1 help',
+          type: 'summary',
+          values: [
+            { labels: { quantile: 0.1 }, value: 1 },
+            { labels: { quantile: 0.9 }, value: 100 },
+            { labels: { quantile: 0.99 }, value: 100 },
+            { metricName: 'summary1_sum', labels: {}, value: 111 },
+            { metricName: 'summary1_count', labels: {}, value: 3 },
+          ],
+          aggregator: 'sum',
+        },
+      ]
+
+      const influx = promClientJsonToInfluxV2(json)
+
+      expect(sortLines(influx)).to.be.equal(
+        sortLines(`prometheus,quantile=0.99 summary1=100
+prometheus,quantile=0.9 summary1=100
+prometheus,quantile=0.1 summary1=1
+prometheus summary1_count=3,summary1_sum=111`)
+      )
+    })
+
+    it('converts a summary (from prometheus registry)', function() {
+      const register = new prometheus.Registry()
+      const summary = new prometheus.Summary({
+        name: 'summary1',
+        help: 'summary 1 help',
+        percentiles: [0.1, 0.9, 0.99],
+        registers: [register],
+      })
+      summary.observe(100)
+      summary.observe(10)
+      summary.observe(1)
+
+      const influx = promClientJsonToInfluxV2(register.getMetricsAsJSON())
+
+      expect(sortLines(influx)).to.be.equal(
+        sortLines(`prometheus,quantile=0.99 summary1=100
+prometheus,quantile=0.9 summary1=100
+prometheus,quantile=0.1 summary1=1
+prometheus summary1_count=3,summary1_sum=111`)
+      )
+    })
+
+    it('converts a counter and skip a timestamp', function() {
+      const json = [
+        {
+          help: 'counter 4 help',
+          name: 'counter4',
+          type: 'counter',
+          values: [{ value: 11, labels: {}, timestamp: 1581850552292 }],
+          aggregator: 'sum',
+        },
+      ]
+
+      const influx = promClientJsonToInfluxV2(json)
+
+      expect(influx).to.be.equal('prometheus counter4=11')
+    })
+
+    it('converts a counter and adds extra labels', function() {
+      const json = [
+        {
+          help: 'counter 1 help',
+          name: 'counter1',
+          type: 'counter',
+          values: [{ value: 11, labels: {} }],
+          aggregator: 'sum',
+        },
+      ]
+
+      const influx = promClientJsonToInfluxV2(json, {
+        instance: 'instance1',
+        env: 'production',
+      })
+
+      expect(influx).to.be.equal(
+        'prometheus,env=production,instance=instance1 counter1=11'
+      )
+    })
+  })
+})
diff --git a/core/server/prometheus-metrics.js b/core/server/prometheus-metrics.js
index bda312a40109ec6a352e828058cb24ddc14fc629..69748f52cb19f5bd23381febf1b04520e2ba3c59 100644
--- a/core/server/prometheus-metrics.js
+++ b/core/server/prometheus-metrics.js
@@ -68,11 +68,13 @@ module.exports = class PrometheusMetrics {
         registers: [this.register],
       }),
     }
+    this.interval = prometheus.collectDefaultMetrics({
+      register: this.register,
+    })
   }
 
-  async initialize(server) {
+  async registerMetricsEndpoint(server) {
     const { register } = this
-    this.interval = prometheus.collectDefaultMetrics({ register })
 
     server.route(/^\/metrics$/, (data, match, end, ask) => {
       ask.res.setHeader('Content-Type', register.contentType)
@@ -88,6 +90,10 @@ module.exports = class PrometheusMetrics {
     }
   }
 
+  metrics() {
+    return this.register.getMetricsAsJSON()
+  }
+
   /**
    * @returns {object} `{ inc() {} }`.
    */
diff --git a/core/server/prometheus-metrics.spec.js b/core/server/prometheus-metrics.spec.js
index 5fb503cadb2b29357d41ff0edc13397915cca2b4..2ea95be77623480ba07f57e68e73d5aaf1fd698e 100644
--- a/core/server/prometheus-metrics.spec.js
+++ b/core/server/prometheus-metrics.spec.js
@@ -7,26 +7,26 @@ const got = require('../got-test-client')
 const Metrics = require('./prometheus-metrics')
 
 describe('Prometheus metrics route', function() {
-  let port, baseUrl
+  let port, baseUrl, camp, metrics
   beforeEach(async function() {
     port = await portfinder.getPortPromise()
     baseUrl = `http://127.0.0.1:${port}`
-  })
-
-  let camp
-  beforeEach(async function() {
     camp = Camp.start({ port, hostname: '::' })
     await new Promise(resolve => camp.on('listening', () => resolve()))
   })
   afterEach(async function() {
+    if (metrics) {
+      metrics.stop()
+    }
     if (camp) {
       await new Promise(resolve => camp.close(resolve))
       camp = undefined
     }
   })
 
-  it('returns metrics', async function() {
-    new Metrics({ enabled: true }).initialize(camp)
+  it('returns default metrics', async function() {
+    metrics = new Metrics()
+    metrics.registerMetricsEndpoint(camp)
 
     const { statusCode, body } = await got(`${baseUrl}/metrics`)
 
diff --git a/core/server/server.js b/core/server/server.js
index 3f4f5a4238ad0db17e0f1830c70305874a731146..d2f2ef8d7c3bfe73efeea4854cca4d595bbe0f12 100644
--- a/core/server/server.js
+++ b/core/server/server.js
@@ -23,6 +23,7 @@ const { rasterRedirectUrl } = require('../badge-urls/make-badge-url')
 const log = require('./log')
 const sysMonitor = require('./monitor')
 const PrometheusMetrics = require('./prometheus-metrics')
+const InfluxMetrics = require('./influx-metrics')
 
 const Joi = originalJoi
   .extend(base => ({
@@ -81,6 +82,33 @@ const publicConfigSchema = Joi.object({
   metrics: {
     prometheus: {
       enabled: Joi.boolean().required(),
+      endpointEnabled: Joi.boolean().required(),
+    },
+    influx: {
+      enabled: Joi.boolean().required(),
+      url: Joi.string()
+        .uri()
+        .when('enabled', { is: true, then: Joi.required() }),
+      timeoutMilliseconds: Joi.number()
+        .integer()
+        .min(1)
+        .when('enabled', { is: true, then: Joi.required() }),
+      intervalSeconds: Joi.number()
+        .integer()
+        .min(1)
+        .when('enabled', { is: true, then: Joi.required() }),
+      instanceIdFrom: Joi.string()
+        .equal('hostname', 'env-var', 'random')
+        .when('enabled', { is: true, then: Joi.required() }),
+      instanceIdEnvVarName: Joi.string().when('instanceIdFrom', {
+        is: 'env-var',
+        then: Joi.required(),
+      }),
+      envLabel: Joi.string().when('enabled', {
+        is: true,
+        then: Joi.required(),
+      }),
+      hostnameAliases: Joi.object(),
     },
   },
   ssl: {
@@ -160,8 +188,13 @@ const privateConfigSchema = Joi.object({
   twitch_client_id: Joi.string(),
   twitch_client_secret: Joi.string(),
   wheelmap_token: Joi.string(),
+  influx_username: Joi.string(),
+  influx_password: Joi.string(),
 }).required()
-
+const privateMetricsInfluxConfigSchema = privateConfigSchema.append({
+  influx_username: Joi.string().required(),
+  influx_password: Joi.string().required(),
+})
 /**
  * The Server is based on the web framework Scoutcamp. It creates
  * an http server, sets up helpers for token persistence and monitoring.
@@ -173,22 +206,25 @@ class Server {
    * Badge Server Constructor
    *
    * @param {object} config Configuration object read from config yaml files
-   *    by https://www.npmjs.com/package/config and validated against
-   *    publicConfigSchema and privateConfigSchema
+   * by https://www.npmjs.com/package/config and validated against
+   * publicConfigSchema and privateConfigSchema
    * @see https://github.com/badges/shields/blob/master/doc/production-hosting.md#configuration
    * @see https://github.com/badges/shields/blob/master/doc/server-secrets.md
    */
   constructor(config) {
     const publicConfig = Joi.attempt(config.public, publicConfigSchema)
-    let privateConfig
-    try {
-      privateConfig = Joi.attempt(config.private, privateConfigSchema)
-    } catch (e) {
-      const badPaths = e.details.map(({ path }) => path)
-      throw Error(
-        `Private configuration is invalid. Check these paths: ${badPaths.join(
-          ','
-        )}`
+    const privateConfig = this.validatePrivateConfig(
+      config.private,
+      privateConfigSchema
+    )
+    // We want to require an username and a password for the influx metrics
+    // only if the influx metrics are enabled. The private config schema
+    // and the public config schema are two separate schemas so we have to run
+    // validation manually.
+    if (publicConfig.metrics.influx && publicConfig.metrics.influx.enabled) {
+      this.validatePrivateConfig(
+        config.private,
+        privateMetricsInfluxConfigSchema
       )
     }
     this.config = {
@@ -201,8 +237,31 @@ class Server {
       service: publicConfig.services.github,
       private: privateConfig,
     })
+
     if (publicConfig.metrics.prometheus.enabled) {
       this.metricInstance = new PrometheusMetrics()
+      if (publicConfig.metrics.influx.enabled) {
+        this.influxMetrics = new InfluxMetrics(
+          this.metricInstance,
+          Object.assign({}, publicConfig.metrics.influx, {
+            username: privateConfig.influx_username,
+            password: privateConfig.influx_password,
+          })
+        )
+      }
+    }
+  }
+
+  validatePrivateConfig(privateConfig, privateConfigSchema) {
+    try {
+      return Joi.attempt(privateConfig, privateConfigSchema)
+    } catch (e) {
+      const badPaths = e.details.map(({ path }) => path)
+      throw Error(
+        `Private configuration is invalid. Check these paths: ${badPaths.join(
+          ','
+        )}`
+      )
     }
   }
 
@@ -381,7 +440,12 @@ class Server {
     const { githubConstellation } = this
     githubConstellation.initialize(camp)
     if (metricInstance) {
-      metricInstance.initialize(camp)
+      if (this.config.public.metrics.prometheus.endpointEnabled) {
+        metricInstance.registerMetricsEndpoint(camp)
+      }
+      if (this.influxMetrics) {
+        this.influxMetrics.startPushingMetrics()
+      }
     }
 
     const { apiProvider: githubApiProvider } = this.githubConstellation
@@ -425,6 +489,9 @@ class Server {
     }
 
     if (this.metricInstance) {
+      if (this.influxMetrics) {
+        this.influxMetrics.stopPushingMetrics()
+      }
       this.metricInstance.stop()
     }
   }
diff --git a/core/server/server.spec.js b/core/server/server.spec.js
index 177b12f5c7ee229c815bb65ed720584aad83f415..c2482b5d4177964c70c0c4ace705932f2c1f9aa1 100644
--- a/core/server/server.spec.js
+++ b/core/server/server.spec.js
@@ -2,163 +2,333 @@
 
 const { expect } = require('chai')
 const isSvg = require('is-svg')
-const portfinder = require('portfinder')
+const config = require('config')
 const got = require('../got-test-client')
+const Server = require('./server')
 const { createTestServer } = require('./in-process-server-test-helpers')
 
 describe('The server', function() {
-  let server, baseUrl
-  before('Start the server', async function() {
-    // Fixes https://github.com/badges/shields/issues/2611
-    this.timeout(10000)
-    const port = await portfinder.getPortPromise()
-    server = createTestServer({ port })
-    baseUrl = server.baseUrl
-    await server.start()
-  })
-  after('Shut down the server', async function() {
-    if (server) {
-      await server.stop()
-    }
-    server = undefined
-  })
+  describe('running', function() {
+    let server, baseUrl
+    before('Start the server', async function() {
+      // Fixes https://github.com/badges/shields/issues/2611
+      this.timeout(10000)
+      server = await createTestServer()
+      baseUrl = server.baseUrl
+      await server.start()
+    })
+    after('Shut down the server', async function() {
+      if (server) {
+        await server.stop()
+      }
+      server = undefined
+    })
 
-  it('should allow strings for port', async function() {
-    // fixes #4391 - This allows the app to be run using iisnode, which uses a named pipe for the port.
-    const pipeServer = createTestServer({
-      port: '\\\\.\\pipe\\9c137306-7c4d-461e-b7cf-5213a3939ad6',
+    it('should allow strings for port', async function() {
+      // fixes #4391 - This allows the app to be run using iisnode, which uses a named pipe for the port.
+      const pipeServer = await createTestServer({
+        public: {
+          bind: {
+            port: '\\\\.\\pipe\\9c137306-7c4d-461e-b7cf-5213a3939ad6',
+          },
+        },
+      })
+      expect(pipeServer).to.not.be.undefined
     })
-    expect(pipeServer).to.not.be.undefined
-  })
 
-  it('should produce colorscheme badges', async function() {
-    const { statusCode, body } = await got(`${baseUrl}:fruit-apple-green.svg`)
-    expect(statusCode).to.equal(200)
-    expect(body)
-      .to.satisfy(isSvg)
-      .and.to.include('fruit')
-      .and.to.include('apple')
-  })
+    it('should produce colorscheme badges', async function() {
+      const { statusCode, body } = await got(`${baseUrl}:fruit-apple-green.svg`)
+      expect(statusCode).to.equal(200)
+      expect(body)
+        .to.satisfy(isSvg)
+        .and.to.include('fruit')
+        .and.to.include('apple')
+    })
 
-  it('should redirect colorscheme PNG badges as configured', async function() {
-    const { statusCode, headers } = await got(
-      `${baseUrl}:fruit-apple-green.png`,
-      {
+    it('should redirect colorscheme PNG badges as configured', async function() {
+      const { statusCode, headers } = await got(
+        `${baseUrl}:fruit-apple-green.png`,
+        {
+          followRedirect: false,
+        }
+      )
+      expect(statusCode).to.equal(301)
+      expect(headers.location).to.equal(
+        'http://raster.example.test/:fruit-apple-green.png'
+      )
+    })
+
+    it('should redirect modern PNG badges as configured', async function() {
+      const { statusCode, headers } = await got(`${baseUrl}npm/v/express.png`, {
         followRedirect: false,
-      }
-    )
-    expect(statusCode).to.equal(301)
-    expect(headers.location).to.equal(
-      'http://raster.example.test/:fruit-apple-green.png'
-    )
-  })
+      })
+      expect(statusCode).to.equal(301)
+      expect(headers.location).to.equal(
+        'http://raster.example.test/npm/v/express.png'
+      )
+    })
 
-  it('should redirect modern PNG badges as configured', async function() {
-    const { statusCode, headers } = await got(`${baseUrl}npm/v/express.png`, {
-      followRedirect: false,
+    it('should produce json badges', async function() {
+      const { statusCode, body, headers } = await got(
+        `${baseUrl}twitter/follow/_Pyves.json`
+      )
+      expect(statusCode).to.equal(200)
+      expect(headers['content-type']).to.equal('application/json')
+      expect(() => JSON.parse(body)).not.to.throw()
     })
-    expect(statusCode).to.equal(301)
-    expect(headers.location).to.equal(
-      'http://raster.example.test/npm/v/express.png'
-    )
-  })
 
-  it('should produce json badges', async function() {
-    const { statusCode, body, headers } = await got(
-      `${baseUrl}twitter/follow/_Pyves.json`
-    )
-    expect(statusCode).to.equal(200)
-    expect(headers['content-type']).to.equal('application/json')
-    expect(() => JSON.parse(body)).not.to.throw()
-  })
+    it('should preserve label case', async function() {
+      const { statusCode, body } = await got(`${baseUrl}:fRuiT-apple-green.svg`)
+      expect(statusCode).to.equal(200)
+      expect(body)
+        .to.satisfy(isSvg)
+        .and.to.include('fRuiT')
+    })
 
-  it('should preserve label case', async function() {
-    const { statusCode, body } = await got(`${baseUrl}:fRuiT-apple-green.svg`)
-    expect(statusCode).to.equal(200)
-    expect(body)
-      .to.satisfy(isSvg)
-      .and.to.include('fRuiT')
-  })
+    // https://github.com/badges/shields/pull/1319
+    it('should not crash with a numeric logo', async function() {
+      const { statusCode, body } = await got(
+        `${baseUrl}:fruit-apple-green.svg?logo=1`
+      )
+      expect(statusCode).to.equal(200)
+      expect(body)
+        .to.satisfy(isSvg)
+        .and.to.include('fruit')
+        .and.to.include('apple')
+    })
 
-  // https://github.com/badges/shields/pull/1319
-  it('should not crash with a numeric logo', async function() {
-    const { statusCode, body } = await got(
-      `${baseUrl}:fruit-apple-green.svg?logo=1`
-    )
-    expect(statusCode).to.equal(200)
-    expect(body)
-      .to.satisfy(isSvg)
-      .and.to.include('fruit')
-      .and.to.include('apple')
-  })
+    it('should not crash with a numeric link', async function() {
+      const { statusCode, body } = await got(
+        `${baseUrl}:fruit-apple-green.svg?link=1`
+      )
+      expect(statusCode).to.equal(200)
+      expect(body)
+        .to.satisfy(isSvg)
+        .and.to.include('fruit')
+        .and.to.include('apple')
+    })
 
-  it('should not crash with a numeric link', async function() {
-    const { statusCode, body } = await got(
-      `${baseUrl}:fruit-apple-green.svg?link=1`
-    )
-    expect(statusCode).to.equal(200)
-    expect(body)
-      .to.satisfy(isSvg)
-      .and.to.include('fruit')
-      .and.to.include('apple')
-  })
+    it('should not crash with a boolean link', async function() {
+      const { statusCode, body } = await got(
+        `${baseUrl}:fruit-apple-green.svg?link=true`
+      )
+      expect(statusCode).to.equal(200)
+      expect(body)
+        .to.satisfy(isSvg)
+        .and.to.include('fruit')
+        .and.to.include('apple')
+    })
 
-  it('should not crash with a boolean link', async function() {
-    const { statusCode, body } = await got(
-      `${baseUrl}:fruit-apple-green.svg?link=true`
-    )
-    expect(statusCode).to.equal(200)
-    expect(body)
-      .to.satisfy(isSvg)
-      .and.to.include('fruit')
-      .and.to.include('apple')
-  })
+    it('should return the 404 badge for unknown badges', async function() {
+      const { statusCode, body } = await got(
+        `${baseUrl}this/is/not/a/badge.svg`,
+        {
+          throwHttpErrors: false,
+        }
+      )
+      expect(statusCode).to.equal(404)
+      expect(body)
+        .to.satisfy(isSvg)
+        .and.to.include('404')
+        .and.to.include('badge not found')
+    })
 
-  it('should return the 404 badge for unknown badges', async function() {
-    const { statusCode, body } = await got(
-      `${baseUrl}this/is/not/a/badge.svg`,
-      { throwHttpErrors: false }
-    )
-    expect(statusCode).to.equal(404)
-    expect(body)
-      .to.satisfy(isSvg)
-      .and.to.include('404')
-      .and.to.include('badge not found')
-  })
+    it('should return the 404 badge page for rando links', async function() {
+      const { statusCode, body } = await got(
+        `${baseUrl}this/is/most/definitely/not/a/badge.js`,
+        {
+          throwHttpErrors: false,
+        }
+      )
+      expect(statusCode).to.equal(404)
+      expect(body)
+        .to.satisfy(isSvg)
+        .and.to.include('404')
+        .and.to.include('badge not found')
+    })
 
-  it('should return the 404 badge page for rando links', async function() {
-    const { statusCode, body } = await got(
-      `${baseUrl}this/is/most/definitely/not/a/badge.js`,
-      {
+    it('should redirect the root as configured', async function() {
+      const { statusCode, headers } = await got(baseUrl, {
+        followRedirect: false,
+      })
+
+      expect(statusCode).to.equal(302)
+      // This value is set in `config/test.yml`
+      expect(headers.location).to.equal('http://frontend.example.test')
+    })
+
+    it('should return the 410 badge for obsolete formats', async function() {
+      const { statusCode, body } = await got(`${baseUrl}npm/v/express.jpg`, {
         throwHttpErrors: false,
-      }
-    )
-    expect(statusCode).to.equal(404)
-    expect(body)
-      .to.satisfy(isSvg)
-      .and.to.include('404')
-      .and.to.include('badge not found')
+      })
+      // TODO It would be nice if this were 404 or 410.
+      expect(statusCode).to.equal(200)
+      expect(body)
+        .to.satisfy(isSvg)
+        .and.to.include('410')
+        .and.to.include('jpg no longer available')
+    })
   })
 
-  it('should redirect the root as configured', async function() {
-    const { statusCode, headers } = await got(baseUrl, {
-      followRedirect: false,
+  describe('configuration', function() {
+    let server
+    afterEach(async function() {
+      if (server) {
+        server.stop()
+      }
+    })
+
+    it('should allow to enable prometheus metrics', async function() {
+      // Fixes https://github.com/badges/shields/issues/2611
+      this.timeout(10000)
+      server = await createTestServer({
+        public: {
+          metrics: { prometheus: { enabled: true, endpointEnabled: true } },
+        },
+      })
+      await server.start()
+
+      const { statusCode } = await got(`${server.baseUrl}metrics`)
+
+      expect(statusCode).to.be.equal(200)
     })
 
-    expect(statusCode).to.equal(302)
-    // This value is set in `config/test.yml`
-    expect(headers.location).to.equal('http://frontend.example.test')
+    it('should allow to disable prometheus metrics', async function() {
+      // Fixes https://github.com/badges/shields/issues/2611
+      this.timeout(10000)
+      server = await createTestServer({
+        public: {
+          metrics: { prometheus: { enabled: true, endpointEnabled: false } },
+        },
+      })
+      await server.start()
+
+      const { statusCode } = await got(`${server.baseUrl}metrics`, {
+        throwHttpErrors: false,
+      })
+
+      expect(statusCode).to.be.equal(404)
+    })
   })
 
-  it('should return the 410 badge for obsolete formats', async function() {
-    const { statusCode, body } = await got(`${baseUrl}npm/v/express.jpg`, {
-      throwHttpErrors: false,
+  describe('configuration validation', function() {
+    describe('influx', function() {
+      let customConfig
+      beforeEach(function() {
+        customConfig = config.util.toObject()
+        customConfig.public.metrics.influx = {
+          enabled: true,
+          url: 'http://localhost:8081/telegraf',
+          timeoutMilliseconds: 1000,
+          intervalSeconds: 2,
+          instanceIdFrom: 'random',
+          instanceIdEnvVarName: 'INSTANCE_ID',
+          hostnameAliases: { 'metrics-hostname': 'metrics-hostname-alias' },
+          envLabel: 'test-env',
+        }
+        customConfig.private = {
+          influx_username: 'telegraf',
+          influx_password: 'telegrafpass',
+        }
+      })
+
+      it('should not require influx configuration', function() {
+        delete customConfig.public.metrics.influx
+        expect(() => new Server(config.util.toObject())).to.not.throw()
+      })
+
+      it('should require url when influx configuration is enabled', function() {
+        delete customConfig.public.metrics.influx.url
+        expect(() => new Server(customConfig)).to.throw(
+          '"metrics.influx.url" is required'
+        )
+      })
+
+      it('should not require url when influx configuration is disabled', function() {
+        customConfig.public.metrics.influx.enabled = false
+        delete customConfig.public.metrics.influx.url
+        expect(() => new Server(customConfig)).to.not.throw()
+      })
+
+      it('should require timeoutMilliseconds when influx configuration is enabled', function() {
+        delete customConfig.public.metrics.influx.timeoutMilliseconds
+        expect(() => new Server(customConfig)).to.throw(
+          '"metrics.influx.timeoutMilliseconds" is required'
+        )
+      })
+
+      it('should require intervalSeconds when influx configuration is enabled', function() {
+        delete customConfig.public.metrics.influx.intervalSeconds
+        expect(() => new Server(customConfig)).to.throw(
+          '"metrics.influx.intervalSeconds" is required'
+        )
+      })
+
+      it('should require instanceIdFrom when influx configuration is enabled', function() {
+        delete customConfig.public.metrics.influx.instanceIdFrom
+        expect(() => new Server(customConfig)).to.throw(
+          '"metrics.influx.instanceIdFrom" is required'
+        )
+      })
+
+      it('should require instanceIdEnvVarName when instanceIdFrom is env-var', function() {
+        customConfig.public.metrics.influx.instanceIdFrom = 'env-var'
+        delete customConfig.public.metrics.influx.instanceIdEnvVarName
+        expect(() => new Server(customConfig)).to.throw(
+          '"metrics.influx.instanceIdEnvVarName" is required'
+        )
+      })
+
+      it('should allow instanceIdFrom = hostname', function() {
+        customConfig.public.metrics.influx.instanceIdFrom = 'hostname'
+        expect(() => new Server(customConfig)).to.not.throw()
+      })
+
+      it('should allow instanceIdFrom = env-var', function() {
+        customConfig.public.metrics.influx.instanceIdFrom = 'env-var'
+        expect(() => new Server(customConfig)).to.not.throw()
+      })
+
+      it('should allow instanceIdFrom = random', function() {
+        customConfig.public.metrics.influx.instanceIdFrom = 'random'
+        expect(() => new Server(customConfig)).to.not.throw()
+      })
+
+      it('should require envLabel when influx configuration is enabled', function() {
+        delete customConfig.public.metrics.influx.envLabel
+        expect(() => new Server(customConfig)).to.throw(
+          '"metrics.influx.envLabel" is required'
+        )
+      })
+
+      it('should not require hostnameAliases', function() {
+        delete customConfig.public.metrics.influx.hostnameAliases
+        expect(() => new Server(customConfig)).to.not.throw()
+      })
+
+      it('should allow empty hostnameAliases', function() {
+        customConfig.public.metrics.influx.hostnameAliases = {}
+        expect(() => new Server(customConfig)).to.not.throw()
+      })
+
+      it('should require username when influx configuration is enabled', function() {
+        delete customConfig.private.influx_username
+        expect(() => new Server(customConfig)).to.throw(
+          'Private configuration is invalid. Check these paths: influx_username'
+        )
+      })
+
+      it('should require password when influx configuration is enabled', function() {
+        delete customConfig.private.influx_password
+        expect(() => new Server(customConfig)).to.throw(
+          'Private configuration is invalid. Check these paths: influx_password'
+        )
+      })
+
+      it('should allow other private keys', function() {
+        customConfig.private.gh_token = 'my-token'
+        expect(() => new Server(customConfig)).to.not.throw()
+      })
     })
-    // TODO It would be nice if this were 404 or 410.
-    expect(statusCode).to.equal(200)
-    expect(body)
-      .to.satisfy(isSvg)
-      .and.to.include('410')
-      .and.to.include('jpg no longer available')
   })
 })
diff --git a/core/service-test-runner/cli.js b/core/service-test-runner/cli.js
index 1b7979e15fa9080b1ae6416c0658c582353c54e0..03e789731f0f93bb3563f80e3f2975074b7bffda 100644
--- a/core/service-test-runner/cli.js
+++ b/core/service-test-runner/cli.js
@@ -73,8 +73,14 @@ if (process.env.TESTED_SERVER_URL) {
 } else {
   const port = 1111
   baseUrl = 'http://localhost:1111'
-  before('Start running the server', function() {
-    server = createTestServer({ port })
+  before('Start running the server', async function() {
+    server = await createTestServer({
+      public: {
+        bind: {
+          port,
+        },
+      },
+    })
     server.start()
   })
   after('Shut down the server', async function() {
diff --git a/package-lock.json b/package-lock.json
index 07dc250e2d22e62b8a3001876083c75cfcc60645..35757ce458b8ed055e265b6d2a2e3972ef72dc9a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9905,6 +9905,12 @@
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
       "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ="
     },
+    "deepmerge": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
+      "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
+      "dev": true
+    },
     "default-gateway": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-4.2.0.tgz",
@@ -20171,8 +20177,7 @@
     "lodash.groupby": {
       "version": "4.6.0",
       "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz",
-      "integrity": "sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E=",
-      "dev": true
+      "integrity": "sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E="
     },
     "lodash.includes": {
       "version": "4.3.0",
diff --git a/package.json b/package.json
index e258411c1445274ebf267ad0c7b06ee864d8498f..1774a857a3b5acf496b74b2cbcb664dffebebaa9 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
     "js-yaml": "^3.13.1",
     "jsonpath": "~1.0.2",
     "lodash.countby": "^4.6.0",
+    "lodash.groupby": "^4.6.0",
     "lodash.times": "^4.3.2",
     "moment": "^2.24.0",
     "node-env-flag": "^0.1.0",
@@ -173,6 +174,7 @@
     "cypress": "^4.4.0",
     "danger": "^10.1.1",
     "danger-plugin-no-test-shortcuts": "^2.0.0",
+    "deepmerge": "^4.2.2",
     "enzyme": "^3.11.0",
     "enzyme-adapter-react-16": "^1.15.2",
     "eslint": "^6.8.0",
@@ -212,7 +214,6 @@
     "lint-staged": "^9.5.0",
     "lodash.debounce": "^4.0.8",
     "lodash.difference": "^4.5.0",
-    "lodash.groupby": "^4.6.0",
     "minimist": "^1.2.5",
     "mocha": "^6.2.3",
     "mocha-env-reporter": "^4.0.0",