diff --git a/lib/manager/bundler/artifacts.ts b/lib/manager/bundler/artifacts.ts
index 0070ab298b49086e7289094b5c4ce3b271911b8b..c31689327c8e26061d13b8dd76bf3304d646f7b9 100644
--- a/lib/manager/bundler/artifacts.ts
+++ b/lib/manager/bundler/artifacts.ts
@@ -1,7 +1,9 @@
-import { outputFile, readFile } from 'fs-extra';
-import { join, dirname } from 'upath';
-import { exec } from '../../util/exec';
-import { getChildProcessEnv } from '../../util/exec/env';
+import {
+  getSiblingFileName,
+  readLocalFile,
+  writeLocalFile,
+} from '../../util/fs';
+import { exec, ExecOptions } from '../../util/exec';
 import { logger } from '../../logger';
 import { getPkgReleases } from '../../datasource/docker';
 import {
@@ -16,122 +18,132 @@ import {
   BUNDLER_INVALID_CREDENTIALS,
   BUNDLER_UNKNOWN_ERROR,
 } from '../../constants/error-messages';
-import { BinarySource } from '../../util/exec/common';
 
-export async function updateArtifacts({
-  packageFileName,
-  updatedDeps,
-  newPackageFileContent,
-  config,
-}: UpdateArtifact): Promise<UpdateArtifactsResult[] | null> {
+async function getRubyConstraint(
+  updateArtifact: UpdateArtifact
+): Promise<string> {
+  const { packageFileName, config } = updateArtifact;
+  const { compatibility = {} } = config;
+  const { ruby } = compatibility;
+
+  let rubyConstraint: string;
+  if (ruby) {
+    logger.debug('Using rubyConstraint from config');
+    rubyConstraint = ruby;
+  } else {
+    const rubyVersionFile = getSiblingFileName(
+      packageFileName,
+      '.ruby-version'
+    );
+    const rubyVersionFileContent = await platform.getFile(rubyVersionFile);
+    if (rubyVersionFileContent) {
+      logger.debug('Using ruby version specified in .ruby-version');
+      rubyConstraint = rubyVersionFileContent
+        .replace(/^ruby-/, '')
+        .replace(/\n/g, '')
+        .trim();
+    }
+  }
+  return rubyConstraint;
+}
+
+async function getDockerTag(updateArtifact: UpdateArtifact): Promise<string> {
+  const constraint = await getRubyConstraint(updateArtifact);
+  if (!constraint) {
+    logger.debug('No ruby version constraint found, so using latest');
+    return 'latest';
+  }
+  if (!isValid(constraint)) {
+    logger.warn({ constraint }, 'Invalid ruby version constraint');
+    return 'latest';
+  }
+  logger.debug(
+    { constraint },
+    'Found ruby version constraint - checking for a compatible renovate/ruby image to use'
+  );
+  const rubyReleases = await getPkgReleases({
+    lookupName: 'renovate/ruby',
+  });
+  // istanbul ignore else
+  if (rubyReleases && rubyReleases.releases) {
+    let versions = rubyReleases.releases.map(release => release.version);
+    versions = versions.filter(
+      version => isVersion(version) && matches(version, constraint)
+    );
+    versions = versions.sort(sortVersions);
+    if (versions.length) {
+      const rubyVersion = versions.pop();
+      logger.debug(
+        { constraint, rubyVersion },
+        'Found compatible ruby version'
+      );
+      return rubyVersion;
+    }
+  } else {
+    logger.error('No renovate/ruby releases found');
+    return 'latest';
+  }
+  logger.warn(
+    { constraint },
+    'Failed to find a tag satisfying ruby constraint, using latest ruby image instead'
+  );
+  return 'latest';
+}
+
+export async function updateArtifacts(
+  updateArtifact: UpdateArtifact
+): Promise<UpdateArtifactsResult[] | null> {
+  const {
+    packageFileName,
+    updatedDeps,
+    newPackageFileContent,
+    config,
+  } = updateArtifact;
+  const { compatibility = {} } = config;
+
   logger.debug(`bundler.updateArtifacts(${packageFileName})`);
   // istanbul ignore if
   if (global.repoCache.bundlerArtifactsError) {
     logger.info('Aborting Bundler artifacts due to previous failed attempt');
     throw new Error(global.repoCache.bundlerArtifactsError);
   }
-  const lockFileName = packageFileName + '.lock';
+  const lockFileName = `${packageFileName}.lock`;
   const existingLockFileContent = await platform.getFile(lockFileName);
   if (!existingLockFileContent) {
     logger.debug('No Gemfile.lock found');
     return null;
   }
-  const cwd = join(config.localDir, dirname(packageFileName));
   try {
-    const localPackageFileName = join(config.localDir, packageFileName);
-    await outputFile(localPackageFileName, newPackageFileContent);
-    const localLockFileName = join(config.localDir, lockFileName);
-    const env = getChildProcessEnv();
-    let cmd;
-    if (config.binarySource === BinarySource.Docker) {
-      logger.info('Running bundler via docker');
-      let tag = 'latest';
-      let rubyConstraint: string;
-      if (config && config.compatibility && config.compatibility.ruby) {
-        logger.debug('Using rubyConstraint from config');
-        rubyConstraint = config.compatibility.ruby;
-      } else {
-        const rubyVersionFile = join(dirname(packageFileName), '.ruby-version');
-        logger.debug('Checking ' + rubyVersionFile);
-        const rubyVersionFileContent = await platform.getFile(rubyVersionFile);
-        if (rubyVersionFileContent) {
-          logger.debug('Using ruby version specified in .ruby-version');
-          rubyConstraint = rubyVersionFileContent
-            .replace(/^ruby-/, '')
-            .replace(/\n/g, '')
-            .trim();
-        }
-      }
-      if (rubyConstraint && isValid(rubyConstraint)) {
-        logger.debug({ rubyConstraint }, 'Found ruby compatibility');
-        const rubyReleases = await getPkgReleases({
-          lookupName: 'renovate/ruby',
-        });
-        if (rubyReleases && rubyReleases.releases) {
-          let versions = rubyReleases.releases.map(release => release.version);
-          versions = versions.filter(version => isVersion(version));
-          versions = versions.filter(version =>
-            matches(version, rubyConstraint)
-          );
-          versions = versions.sort(sortVersions);
-          if (versions.length) {
-            tag = versions.pop();
-          }
-        }
-        if (tag === 'latest') {
-          logger.warn(
-            { rubyConstraint },
-            'Failed to find a tag satisfying ruby constraint, using latest ruby image instead'
-          );
-        }
-      }
-      const bundlerConstraint =
-        config && config.compatibility && config.compatibility.bundler
-          ? config.compatibility.bundler
-          : undefined;
-      let bundlerVersion = '';
-      if (bundlerConstraint && isVersion(bundlerConstraint)) {
-        bundlerVersion = ' -v ' + bundlerConstraint;
-      }
-      cmd = `docker run --rm `;
-      if (config.dockerUser) {
-        cmd += `--user=${config.dockerUser} `;
-      }
-      const volumes = [config.localDir];
-      cmd += volumes.map(v => `-v "${v}":"${v}" `).join('');
-      cmd += `-w "${cwd}" `;
-      cmd += `renovate/ruby:${tag} bash -l -c "ruby --version && `;
-      cmd += 'gem install bundler' + bundlerVersion + ' --no-document';
-      cmd += ' && bundle';
-    } else if (
-      config.binarySource === BinarySource.Auto ||
-      config.binarySource === BinarySource.Global
-    ) {
-      logger.info('Running bundler via global bundler');
-      cmd = 'bundle';
-    } else {
-      logger.warn({ config }, 'Unsupported binarySource');
-      cmd = 'bundle';
-    }
-    cmd += ` lock --update ${updatedDeps.join(' ')}`;
-    if (cmd.includes('bash -l -c "')) {
-      cmd += '"';
-    }
-    logger.debug({ cmd }, 'bundler command');
-    await exec(cmd, {
-      cwd,
-      env,
-    });
+    await writeLocalFile(packageFileName, newPackageFileContent);
+
+    const cmd = `bundle lock --update ${updatedDeps.join(' ')}`;
+
+    const { bundler } = compatibility;
+    const bundlerVersion = bundler && isValid(bundler) ? ` -v ${bundler}` : '';
+    const preCommands = [
+      'ruby --version',
+      `gem install bundler${bundlerVersion} --no-document`,
+    ];
+
+    const execOptions: ExecOptions = {
+      docker: {
+        image: 'renovate/ruby',
+        tag: await getDockerTag(updateArtifact),
+        preCommands,
+      },
+    };
+    await exec(cmd, execOptions);
     const status = await platform.getRepoStatus();
     if (!status.modified.includes(lockFileName)) {
       return null;
     }
     logger.debug('Returning updated Gemfile.lock');
+    const lockFileContent = await readLocalFile(lockFileName);
     return [
       {
         file: {
           name: lockFileName,
-          contents: await readFile(localLockFileName, 'utf8'),
+          contents: lockFileContent,
         },
       },
     ];
diff --git a/test/manager/bundler/__snapshots__/artifacts.spec.ts.snap b/test/manager/bundler/__snapshots__/artifacts.spec.ts.snap
index 9ba7eff927b0b1189f669e03e1c03a1ffa6d18a6..4b19918f81f23c0a3479aa59044eb19eda63c0bd 100644
--- a/test/manager/bundler/__snapshots__/artifacts.spec.ts.snap
+++ b/test/manager/bundler/__snapshots__/artifacts.spec.ts.snap
@@ -14,7 +14,13 @@ Array [
 exports[`bundler.updateArtifacts() Docker .ruby-version 2`] = `
 Array [
   Object {
-    "cmd": "docker run --rm -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -w \\"/tmp/github/some/repo\\" renovate/ruby:1.2.0 bash -l -c \\"ruby --version && gem install bundler --no-document && bundle lock --update \\"",
+    "cmd": "docker pull renovate/ruby:1.2.0",
+    "options": Object {
+      "encoding": "utf-8",
+    },
+  },
+  Object {
+    "cmd": "docker run --rm --user=foobar -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -w \\"/tmp/github/some/repo\\" renovate/ruby:1.2.0 bash -l -c \\"ruby --version && gem install bundler --no-document && bundle lock --update foo bar\\"",
     "options": Object {
       "cwd": "/tmp/github/some/repo",
       "encoding": "utf-8",
@@ -46,7 +52,51 @@ Array [
 exports[`bundler.updateArtifacts() Docker compatibility options 2`] = `
 Array [
   Object {
-    "cmd": "docker run --rm --user=foobar -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -w \\"/tmp/github/some/repo\\" renovate/ruby:latest bash -l -c \\"ruby --version && gem install bundler -v 3.2.1 --no-document && bundle lock --update \\"",
+    "cmd": "docker pull renovate/ruby:latest",
+    "options": Object {
+      "encoding": "utf-8",
+    },
+  },
+  Object {
+    "cmd": "docker run --rm --user=foobar -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -w \\"/tmp/github/some/repo\\" renovate/ruby:latest bash -l -c \\"ruby --version && gem install bundler -v 3.2.1 --no-document && bundle lock --update foo bar\\"",
+    "options": Object {
+      "cwd": "/tmp/github/some/repo",
+      "encoding": "utf-8",
+      "env": Object {
+        "HOME": "/home/user",
+        "HTTPS_PROXY": "https://example.com",
+        "HTTP_PROXY": "http://example.com",
+        "LANG": "en_US.UTF-8",
+        "LC_ALL": "en_US",
+        "NO_PROXY": "localhost",
+        "PATH": "/tmp/path",
+      },
+    },
+  },
+]
+`;
+
+exports[`bundler.updateArtifacts() Docker invalid compatibility options 1`] = `
+Array [
+  Object {
+    "file": Object {
+      "contents": "Updated Gemfile.lock",
+      "name": "Gemfile.lock",
+    },
+  },
+]
+`;
+
+exports[`bundler.updateArtifacts() Docker invalid compatibility options 2`] = `
+Array [
+  Object {
+    "cmd": "docker pull renovate/ruby:latest",
+    "options": Object {
+      "encoding": "utf-8",
+    },
+  },
+  Object {
+    "cmd": "docker run --rm --user=foobar -v \\"/tmp/github/some/repo\\":\\"/tmp/github/some/repo\\" -w \\"/tmp/github/some/repo\\" renovate/ruby:latest bash -l -c \\"ruby --version && gem install bundler --no-document && bundle lock --update foo bar\\"",
     "options": Object {
       "cwd": "/tmp/github/some/repo",
       "encoding": "utf-8",
@@ -69,7 +119,7 @@ exports[`bundler.updateArtifacts() returns null if Gemfile.lock was not changed
 exports[`bundler.updateArtifacts() returns null if Gemfile.lock was not changed 2`] = `
 Array [
   Object {
-    "cmd": "bundle lock --update ",
+    "cmd": "bundle lock --update foo bar",
     "options": Object {
       "cwd": "/tmp/github/some/repo",
       "encoding": "utf-8",
@@ -101,7 +151,7 @@ Array [
 exports[`bundler.updateArtifacts() works explicit global binarySource 2`] = `
 Array [
   Object {
-    "cmd": "bundle lock --update ",
+    "cmd": "bundle lock --update foo bar",
     "options": Object {
       "cwd": "/tmp/github/some/repo",
       "encoding": "utf-8",
@@ -133,7 +183,7 @@ Array [
 exports[`bundler.updateArtifacts() works for default binarySource 2`] = `
 Array [
   Object {
-    "cmd": "bundle lock --update ",
+    "cmd": "bundle lock --update foo bar",
     "options": Object {
       "cwd": "/tmp/github/some/repo",
       "encoding": "utf-8",
diff --git a/test/manager/bundler/artifacts.spec.ts b/test/manager/bundler/artifacts.spec.ts
index f6b537d835c279ca2413344f81ffa6ea6526f754..26b15659b4bfb800790c007c1b68a1ecfc1d35bf 100644
--- a/test/manager/bundler/artifacts.spec.ts
+++ b/test/manager/bundler/artifacts.spec.ts
@@ -1,3 +1,4 @@
+import { join } from 'upath';
 import _fs from 'fs-extra';
 import { exec as _exec } from 'child_process';
 import Git from 'simple-git/promise';
@@ -8,6 +9,8 @@ import { mocked } from '../../util';
 import { envMock, mockExecAll } from '../../execUtil';
 import * as _env from '../../../lib/util/exec/env';
 import { BinarySource } from '../../../lib/util/exec/common';
+import { setUtilConfig } from '../../../lib/util';
+import { resetPrefetchedImages } from '../../../lib/util/exec/docker';
 
 const fs: jest.Mocked<typeof _fs> = _fs as any;
 const exec: jest.Mock<typeof _exec> = _exec as any;
@@ -29,16 +32,20 @@ describe('bundler.updateArtifacts()', () => {
     jest.resetModules();
 
     config = {
-      localDir: '/tmp/github/some/repo',
+      // `join` fixes Windows CI
+      localDir: join('/tmp/github/some/repo'),
+      dockerUser: 'foobar',
     };
 
     env.getChildProcessEnv.mockReturnValue(envMock.basic);
+    resetPrefetchedImages();
+    setUtilConfig(config);
   });
   it('returns null by default', async () => {
     expect(
       await updateArtifacts({
         packageFileName: '',
-        updatedDeps: [],
+        updatedDeps: ['foo', 'bar'],
         newPackageFileContent: '',
         config,
       })
@@ -55,7 +62,7 @@ describe('bundler.updateArtifacts()', () => {
     expect(
       await updateArtifacts({
         packageFileName: 'Gemfile',
-        updatedDeps: [],
+        updatedDeps: ['foo', 'bar'],
         newPackageFileContent: 'Updated Gemfile content',
         config,
       })
@@ -73,7 +80,7 @@ describe('bundler.updateArtifacts()', () => {
     expect(
       await updateArtifacts({
         packageFileName: 'Gemfile',
-        updatedDeps: [],
+        updatedDeps: ['foo', 'bar'],
         newPackageFileContent: 'Updated Gemfile content',
         config,
       })
@@ -91,7 +98,7 @@ describe('bundler.updateArtifacts()', () => {
     expect(
       await updateArtifacts({
         packageFileName: 'Gemfile',
-        updatedDeps: [],
+        updatedDeps: ['foo', 'bar'],
         newPackageFileContent: 'Updated Gemfile content',
         config: {
           ...config,
@@ -102,6 +109,9 @@ describe('bundler.updateArtifacts()', () => {
     expect(execSnapshots).toMatchSnapshot();
   });
   describe('Docker', () => {
+    beforeEach(() => {
+      setUtilConfig({ ...config, binarySource: BinarySource.Docker });
+    });
     it('.ruby-version', async () => {
       platform.getFile.mockResolvedValueOnce('Current Gemfile.lock');
       fs.outputFile.mockResolvedValueOnce(null as never);
@@ -121,7 +131,7 @@ describe('bundler.updateArtifacts()', () => {
       expect(
         await updateArtifacts({
           packageFileName: 'Gemfile',
-          updatedDeps: [],
+          updatedDeps: ['foo', 'bar'],
           newPackageFileContent: 'Updated Gemfile content',
           config: {
             ...config,
@@ -149,7 +159,7 @@ describe('bundler.updateArtifacts()', () => {
       expect(
         await updateArtifacts({
           packageFileName: 'Gemfile',
-          updatedDeps: [],
+          updatedDeps: ['foo', 'bar'],
           newPackageFileContent: 'Updated Gemfile content',
           config: {
             ...config,
@@ -164,5 +174,38 @@ describe('bundler.updateArtifacts()', () => {
       ).toMatchSnapshot();
       expect(execSnapshots).toMatchSnapshot();
     });
+    it('invalid compatibility options', async () => {
+      platform.getFile.mockResolvedValueOnce('Current Gemfile.lock');
+      fs.outputFile.mockResolvedValueOnce(null as never);
+      datasource.getPkgReleases.mockResolvedValueOnce({
+        releases: [
+          { version: '1.0.0' },
+          { version: '1.2.0' },
+          { version: '1.3.0' },
+        ],
+      });
+      const execSnapshots = mockExecAll(exec);
+      platform.getRepoStatus.mockResolvedValueOnce({
+        modified: ['Gemfile.lock'],
+      } as Git.StatusResult);
+      fs.readFile.mockResolvedValueOnce('Updated Gemfile.lock' as any);
+      expect(
+        await updateArtifacts({
+          packageFileName: 'Gemfile',
+          updatedDeps: ['foo', 'bar'],
+          newPackageFileContent: 'Updated Gemfile content',
+          config: {
+            ...config,
+            binarySource: BinarySource.Docker,
+            dockerUser: 'foobar',
+            compatibility: {
+              ruby: 'foo',
+              bundler: 'bar',
+            },
+          },
+        })
+      ).toMatchSnapshot();
+      expect(execSnapshots).toMatchSnapshot();
+    });
   });
 });