diff --git a/spec/dependencies_test.js b/spec/dependencies_test.js
index f89e4ebc5111a3f05b4fa18b43f1891386a3e3fd..e97e4193d393212cb5e144ac88f08d334383f48f 100644
--- a/spec/dependencies_test.js
+++ b/spec/dependencies_test.js
@@ -3,7 +3,7 @@ import { describe, it, expect } from '@jest/globals'
 import { viteManifestToDeps } from '../src/manifests.js'
 
 describe('Vite manifest parsing', () => {
-  it('should', () => {
+  it('works for simple modules', () => {
     const deps = viteManifestToDeps({
       '../io.ox/guidedtours/i18n.de_DE.js': {
         file: 'io.ox/guidedtours/i18n.de_DE.js',
@@ -96,7 +96,16 @@ describe('Vite manifest parsing', () => {
       },
       '_preload-helper-a7bbbf37.js': {
         file: 'io.ox/guidedtours/preload-helper-a7bbbf37.js'
-      },
+      }
+    })
+    expect(typeof deps).toBe('object')
+    expect(Object.keys(deps).length).toBe(8)
+    expect(deps['io.ox/guidedtours/main.07676e21.js']).toEqual(['io.ox/guidedtours/preload-helper-a7bbbf37.js', 'io.ox/guidedtours/i18n.3de05d46.js'])
+    expect(deps['io.ox/guidedtours/multifactor.22d3e17d.js']).toEqual(['io.ox/guidedtours/preload-helper-a7bbbf37.js', 'io.ox/guidedtours/i18n.3de05d46.js', 'io.ox/guidedtours/main.07676e21.js', 'io.ox/guidedtours/assets/multifactor.91962241.css'])
+  })
+
+  it('exports assets as entrypoints without dependencies', async () => {
+    const deps = viteManifestToDeps({
       'themes/icons/alarm.svg': {
         file: 'assets/alarm.6d2fbb40.js',
         src: 'themes/icons/alarm.svg',
@@ -107,10 +116,59 @@ describe('Vite manifest parsing', () => {
         meta: {}
       }
     })
-    expect(typeof deps).toBe('object')
-    expect(Object.keys(deps).length).toBe(8)
-    expect(deps['io.ox/guidedtours/main.07676e21.js']).toEqual(['io.ox/guidedtours/preload-helper-a7bbbf37.js', 'io.ox/guidedtours/i18n.3de05d46.js'])
-    expect(deps['io.ox/guidedtours/multifactor.22d3e17d.js']).toEqual(['io.ox/guidedtours/preload-helper-a7bbbf37.js', 'io.ox/guidedtours/i18n.3de05d46.js', 'io.ox/guidedtours/main.07676e21.js', 'io.ox/guidedtours/assets/multifactor.91962241.css'])
+    expect(Object.keys(deps).length).toBe(2)
     expect(deps['assets/alarm.6d2fbb40.js']).toEqual(['assets/alarm.310541a0.svg'])
+    expect(deps['assets/alarm.310541a0.svg']).toEqual([])
+  })
+
+  it('exports css as entrypoints without dependencies', async () => {
+    const deps = viteManifestToDeps({
+      'main.js': {
+        file: 'main.js',
+        src: 'main.js',
+        isEntry: true,
+        css: ['assets/main.3b761440.css'],
+        meta: {}
+      }
+    })
+    expect(deps['main.js']).toEqual(['assets/main.3b761440.css'])
+    expect(deps['assets/main.3b761440.css']).toEqual([])
+  })
+
+  it('separately exports HTML entrypoints', async () => {
+    const deps = viteManifestToDeps({
+      'index.html': {
+        file: 'index.html.js',
+        src: 'index.html',
+        isEntry: true,
+        imports: ['main.js', '_preload-helper.a295b1c6.js', '_vendor.ae457d06.js'],
+        meta: {}
+      },
+      '_preload-helper.a295b1c6.js': { file: 'assets/preload-helper.a295b1c6.js' },
+      '_vendor.ae457d06.js': {
+        file: 'assets/vendor.ae457d06.js',
+        isDynamicEntry: true,
+        dynamicImports: ['io.ox/core/a11y.js']
+      },
+      'io.ox/core/a11y.js': {
+        file: 'io.ox/core/a11y.js',
+        src: 'io.ox/core/a11y.js',
+        isEntry: true,
+        isDynamicEntry: true,
+        imports: ['_vendor.ae457d06.js'],
+        meta: {}
+      },
+      'main.js': {
+        file: 'main.js',
+        src: 'main.js',
+        isEntry: true,
+        imports: ['_preload-helper.a295b1c6.js', '_vendor.ae457d06.js', 'io.ox/core/a11y.js'],
+        css: ['assets/main.3b761440.css'],
+        meta: {}
+      }
+    })
+
+    expect(Object.keys(deps).length).toBe(7)
+    expect(deps['index.html']).toEqual(['index.html.js'])
   })
 })
diff --git a/spec/file_caching_test.js b/spec/file_caching_test.js
new file mode 100644
index 0000000000000000000000000000000000000000..7a30e60ecb5595ccecfdb863010107800fc94c06
--- /dev/null
+++ b/spec/file_caching_test.js
@@ -0,0 +1,77 @@
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from '@jest/globals'
+import mockfs from 'mock-fs'
+import request from 'supertest'
+import { createApp } from '../src/createApp'
+import { createMockServer, generateSimpleViteManifest, getRandomPort } from './util.js'
+
+describe('File caching service', () => {
+  let app
+  let mockserver
+  const port = getRandomPort()
+
+  beforeAll(() => {
+    mockfs({
+      './config/manifests': {
+        'urls.yaml': `manifests:
+          - http://localhost:${port}/manifest.json`
+      }
+    })
+    app = createApp()
+  })
+
+  afterAll(() => {
+    mockfs.restore()
+  })
+
+  beforeEach(async () => {
+    mockserver = await createMockServer({ port })
+    mockserver.respondWith({
+      '/manifest.json': generateSimpleViteManifest({
+        'example.js': { imports: ['test.txt'] },
+        'test.txt': { },
+        'main.css': {},
+        'index.html': {
+          file: 'index.html.js',
+          isEntry: true,
+          imports: ['example.js'],
+          css: ['main.css']
+        }
+      }),
+      '/example.js': (req, res) => res.setHeader('content-type', 'application/javascript').status(200).send('this is example'),
+      '/test.txt': (req, res) => res.setHeader('content-type', 'text/plain').status(200).send('this is test'),
+      '/index.html.js': (req, res) => res.setHeader('content-type', 'application/javascript').status(200).send('this is index.html.js'),
+      '/index.html': (req, res) => res.setHeader('content-type', 'text/html').status(200).send('<html><head></head><body>it\'s me</body></html>'),
+      '/main.css': (req, res) => res.setHeader('content-type', 'text/css').status(200).send('.foo { color: #000; }')
+
+    })
+  })
+
+  afterEach(() => {
+    mockserver.close()
+    process.env.CACHE_TTL = 30000
+  })
+
+  it('serves files defined in manifest.json file', async () => {
+    const response = await request(app).get('/example.js')
+    expect(response.statusCode).toBe(200)
+    expect(response.headers['content-type']).toBe('application/javascript; charset=utf-8')
+    expect(response.text).toBe('this is example')
+    const response2 = await request(app).get('/test.txt')
+    expect(response2.statusCode).toBe(200)
+    expect(response2.headers['content-type']).toBe('text/plain; charset=utf-8')
+    expect(response2.text).toBe('this is test')
+  })
+
+  it('serves css files', async () => {
+    const response = await request(app).get('/main.css')
+    expect(response.statusCode).toBe(200)
+    expect(response.headers['content-type']).toBe('text/css; charset=utf-8')
+  })
+
+  it('serves / as index.html', async () => {
+    const response = await request(app).get('/')
+    expect(response.statusCode).toBe(200)
+    expect(response.headers['content-type']).toBe('text/html; charset=utf-8')
+    expect(response.text).toBe('<html><head></head><body>it\'s me</body></html>')
+  })
+})
diff --git a/spec/util.js b/spec/util.js
index cd78917e26d22d4267a7739756124a81dfd506cd..5453dd9650845087c34812614d2faaaf1f28ff5b 100644
--- a/spec/util.js
+++ b/spec/util.js
@@ -11,7 +11,11 @@ export async function createMockServer ({ port }) {
   })
   server.respondWith = function (routes) {
     for (const [route, data] of Object.entries(routes)) {
-      app.get(route, (req, res) => res.json(data))
+      if (typeof data === 'function') {
+        app.get(route, data)
+      } else {
+        app.get(route, (req, res) => res.json(data))
+      }
     }
   }
   return server
@@ -19,11 +23,12 @@ export async function createMockServer ({ port }) {
 
 export function generateSimpleViteManifest (mapping) {
   const viteManifest = {}
-  for (const [file, namespace] of Object.entries(mapping)) {
+  for (const [file, value] of Object.entries(mapping)) {
     viteManifest[file] = {
       file,
-      meta: namespace ? { manifests: [{ namespace }] } : {}
+      meta: typeof value === 'string' ? { manifests: [{ namespace: value }] } : {}
     }
+    if (typeof value === 'object') Object.assign(viteManifest[file], value)
   }
   return viteManifest
 }
diff --git a/src/createApp.js b/src/createApp.js
index e4cca76a12cfa3fae792725785b9532d902a248d..ffdaa7800eec5ef5f2f64fefb328d266b9c020d3 100644
--- a/src/createApp.js
+++ b/src/createApp.js
@@ -19,6 +19,7 @@ import swaggerUi from 'swagger-ui-express'
 import yaml from 'js-yaml'
 import fs from 'fs'
 import { getDependencies, getOxManifests } from './manifests.js'
+import { fileCache } from './files.js'
 
 const ignorePaths = ['/ready', '/healthy']
 const swaggerDocument = yaml.load(fs.readFileSync('./src/swagger.yaml', 'utf8'))
@@ -61,6 +62,20 @@ export function createApp () {
     }
   })
 
+  app.get('/', async (req, res, next) => {
+    await getDependencies()
+    const { 'content-type': contentType, content } = fileCache.get('/index.html')
+    if (content) return res.setHeader('content-type', contentType).status(200).send(content)
+    next()
+  })
+
+  app.use(async (req, res, next) => {
+    await getDependencies()
+    const { 'content-type': contentType, content } = fileCache.get(req.path)
+    if (content) return res.setHeader('content-type', contentType).status(200).send(content)
+    next()
+  })
+
   app.use(function (err, req, res, next) {
     logger.error(err)
     res.status(500).end()
diff --git a/src/files.js b/src/files.js
new file mode 100644
index 0000000000000000000000000000000000000000..9d2c45b3bd9f5253b26f94ac00d7e889ab535bf0
--- /dev/null
+++ b/src/files.js
@@ -0,0 +1,40 @@
+import fetch from 'node-fetch'
+
+class FileCache {
+  async warmUp (manifests, deps) {
+    const cache = Object.fromEntries(await (async function () {
+      const files = Object.keys(deps)
+      const chunkSize = Math.ceil(files.length / 50)
+      const result = []
+      while (files.length > 0) {
+        result.push.apply(result, (await Promise.all(files.splice(0, chunkSize).map(async file => {
+          try {
+            const manifest = manifests[file] || Object.values(manifests).find(m =>
+              m.file === file ||
+              (m?.assets?.indexOf(file) >= 0) ||
+              (m?.css?.indexOf(file) >= 0)
+            )
+            if (!manifest) {
+              console.error('could not find manifest for', file)
+              return null
+            }
+            const response = await fetch(new URL(file, manifest.meta.baseUrl))
+            if (!response.ok) return null
+            return [file, {
+              'content-type': response.headers.get('content-type'),
+              content: await response.buffer()
+            }]
+          } catch (e) { console.error(e) }
+        }))).filter(data => Array.isArray(data) && data.length === 2))
+      }
+      return result
+    }()))
+    this._cache = cache
+  }
+
+  get (path) {
+    return this?._cache[path.slice(1)] || {}
+  }
+}
+
+export const fileCache = new FileCache()
diff --git a/src/manifests.js b/src/manifests.js
index 4b95b7fd616429d07ec1c43dcf25b299524525d8..44b925028d725c5be9acf5f6c250d8c197983f16 100644
--- a/src/manifests.js
+++ b/src/manifests.js
@@ -2,6 +2,8 @@ import fs from 'fs/promises'
 import yaml from 'js-yaml'
 import fetch from 'node-fetch'
 import path from 'path'
+import { URL } from 'url'
+import { fileCache } from './files.js'
 
 export const loadViteManifests = (() => {
   let cache
@@ -17,11 +19,17 @@ export const loadViteManifests = (() => {
       // vite manifests contains a set of objects with the vite-manifests
       // from the corresponding registered services
       const viteManifests = await Promise.all(urls.map(async url => {
+        const { origin } = new URL(url)
         // fetch the manifests
         const result = await fetch(url)
         if (!result.ok) throw new Error(`Failed to load manifest for url ${result.url} (Status: ${result.status}: ${result.statusText})`)
         try {
-          return result.json()
+          const manifest = await result.json()
+          for (const file in manifest) {
+            manifest[file].meta = manifest[file].meta || {}
+            manifest[file].meta.baseUrl = origin
+          }
+          return manifest
         } catch (err) {
           throw new Error(`Failed to load manifest for url ${result.url}: ${err}`)
         }
@@ -73,7 +81,10 @@ export const getOxManifests = (() => {
 
 export function viteManifestToDeps (viteManifest) {
   const deps = {}
-  for (const { file, imports, css, assets } of Object.values(viteManifest)) {
+  for (const [codePoint, { isEntry, file, imports, css, assets }] of Object.entries(viteManifest)) {
+    if (isEntry && codePoint.endsWith('.html')) deps[codePoint] = [file]
+    if (Array.isArray(assets)) assets.forEach(asset => { deps[asset] = [] })
+    if (Array.isArray(css)) css.forEach(css => { deps[css] = [] })
     deps[file] = []
       .concat(imports?.map(path => viteManifest[path].file))
       .concat(css)
@@ -90,6 +101,7 @@ export const getDependencies = (() => {
     const viteManifest = await loadViteManifests()
     if (viteManifest !== prevViteManifest) {
       depCache = viteManifestToDeps(viteManifest)
+      await fileCache.warmUp(viteManifest, depCache)
       prevViteManifest = viteManifest
     }
     return depCache