diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 34531018ebf56b03aeccd6f5af4597b35fec07fe..ef06edef9ccc800f2ea4da42929d98061a432e31 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -25,7 +25,10 @@ variables:
       ${APT_PROXY:+-e config.apt_proxy=${APT_PROXY}}
       $CREATE_ENV_VARS $BUILD_DIR
 
-    - with-ssh-key floatup ${LIBVIRT:+--ssh $LIBVIRT} --inventory $BUILD_DIR/hosts.yml --ram 2048 --cpu 2 --image ${VM_IMAGE:-bullseye} ${FLOATUP_ARGS} up
+    - with-ssh-key floatup ${LIBVIRT:+--ssh $LIBVIRT} --inventory $BUILD_DIR/hosts.yml --ram 2048 --cpu 2 --image ${VM_IMAGE:-bookworm} ${FLOATUP_ARGS} up
+    - ls -al /root/.ssh
+    - cat /root/.ssh/config
+    - cat $BUILD_DIR/hosts.yml
     - with-ssh-key ./test-driver init --no-vagrant $BUILD_DIR
     - with-ssh-key ./test-driver run $BUILD_DIR
   after_script:
@@ -46,15 +49,15 @@ variables:
 base_test:
   <<: *base_test
   variables:
-    VM_IMAGE: "bullseye"
-    CREATE_ENV_VARS: "-e config.float_debian_dist=bullseye -e inventory.group_vars.vagrant.ansible_python_interpreter=/usr/bin/python3"
+    VM_IMAGE: "bookworm"
+    CREATE_ENV_VARS: "-e config.float_debian_dist=bookworm"
     TEST_DIR: "test/base.ref"
 
 full_test:
   <<: *base_test
   variables:
-    VM_IMAGE: "bullseye"
-    CREATE_ENV_VARS: "-e config.float_debian_dist=bullseye -e inventory.group_vars.vagrant.ansible_python_interpreter=/usr/bin/python3"
+    VM_IMAGE: "bookworm"
+    CREATE_ENV_VARS: "-e config.float_debian_dist=bookworm"
     TEST_DIR: "test/full.ref"
   rules:
     - if: $CI_MERGE_REQUEST_ID == ''
@@ -64,8 +67,8 @@ full_test_review:
   after_script:
     - with-ssh-key ./test-driver cleanup --no-vagrant $BUILD_DIR
   variables:
-    VM_IMAGE: "bullseye"
-    CREATE_ENV_VARS: "-e config.float_debian_dist=bullseye -e inventory.group_vars.vagrant.ansible_python_interpreter=/usr/bin/python3"
+    VM_IMAGE: "bookworm"
+    CREATE_ENV_VARS: "-e config.float_debian_dist=bookworm -e inventory.group_vars.vagrant.ansible_python_interpreter=/usr/bin/python3"
     FLOATUP_ARGS: "--state-file .vmine_group_review_$CI_MERGE_REQUEST_ID --ttl 6h --env deploy.env --dashboard-url https://vm.investici.org"
     TEST_DIR: "test/full.ref"
   allow_failure: true
@@ -103,13 +106,6 @@ stop_full_test_review:
 #    CREATE_ENV_VARS: "--additional-config test/backup.ref/config-backup.yml --playbook test/backup.ref/site.yml"
 #    TEST_DIR: "test/backup.ref"
 
-bookworm_test:
-  <<: *base_test
-  variables:
-    VM_IMAGE: "bookworm"
-    CREATE_ENV_VARS: "-e config.float_debian_dist=bookworm"
-    TEST_DIR: "test/full.ref"
-
 docker_build_and_release_tests:
   stage: docker_build
   image: quay.io/podman/stable
diff --git a/float b/float
index eae52a4b41c868271de51dee956f69ca1a7ee62a..ce358ce1857283a25f6d7bb4dfcc827a0ac6acd1 100755
--- a/float
+++ b/float
@@ -162,13 +162,7 @@ DEFAULT_VARS = {
     # Ansible inventory (hosts are created dynamically).
     'inventory': {
         'hosts': {},
-        'group_vars': {
-            'vagrant': {
-                'ansible_user': 'vagrant',
-                'ansible_become': True,
-                'ansible_ssh_private_key_file': '~/.vagrant.d/insecure_private_key',
-            },
-        },
+        'group_vars': {},
     },
 
     # Ansible configuration.
@@ -346,7 +340,7 @@ def _render_skel(target_dir, ctx):
 def command_create_env(path, services, passwords, playbooks,
                        roles_path, num_hosts, additional_host_groups,
                        additional_configs, ram, domain, infra_domain,
-                       extra_vars):
+                       become, extra_vars):
     all_vars = DEFAULT_VARS
 
     # Set paths in the internal config.
@@ -355,6 +349,20 @@ def command_create_env(path, services, passwords, playbooks,
     all_vars['passwords_yml_path'] = passwords
     all_vars['playbooks'] = playbooks
 
+    # Set connection-related user parameters.
+    if become == 'root':
+        all_vars['inventory']['group_vars']['vagrant'] = {
+            'ansible_user': 'root',
+            'ansible_become': False,
+        }
+    else:
+        all_vars['inventory']['group_vars']['vagrant'] = {
+            'ansible_user': become,
+            'ansible_become': True,
+            # For legacy compatibility reasons.
+            'ansible_ssh_private_key_file': '~/.vagrant.d/insecure_private_key',
+        }
+
     # Extend the Ansible roles_path.
     if roles_path:
         for rpath in roles_path.split(':'):
@@ -548,6 +556,9 @@ memberships, using the --additional-host-group command-line option.
     create_env_parser.add_argument(
         '--ram', metavar='MB', type=int, default=3072,
         help='RAM for each VM when using --vagrant (default: 3072)')
+    create_env_parser.add_argument(
+        '--become', metavar='USER', default='root',
+        help='ansible_user, disable ansible_become if "root"')
     create_env_parser.add_argument(
         '--additional-host-group', metavar='GROUP=HOST1[,HOST2...]',
         dest='additional_host_groups',
diff --git a/scripts/floatup.py b/scripts/floatup.py
index 5647d7dc306e28791a4f8fb2e4536d6000b68ad3..fa3aa850d2121401a6a99d07eb6613a7211ce0eb 100755
--- a/scripts/floatup.py
+++ b/scripts/floatup.py
@@ -15,37 +15,6 @@ import yaml
 import zlib
 
 
-# The Vagrant "insecure" SSH key that is used to log onto the VMs.
-INSECURE_PRIVATE_KEY = '''-----BEGIN RSA PRIVATE KEY-----
-MIIEogIBAAKCAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7gHzI
-w+niNltGEFHzD8+v1I2YJ6oXevct1YeS0o9HZyN1Q9qgCgzUFtdOKLv6IedplqoP
-kcmF0aYet2PkEDo3MlTBckFXPITAMzF8dJSIFo9D8HfdOV0IAdx4O7PtixWKn5y2
-hMNG0zQPyUecp4pzC6kivAIhyfHilFR61RGL+GPXQ2MWZWFYbAGjyiYJnAmCP3NO
-Td0jMZEnDkbUvxhMmBYSdETk1rRgm+R4LOzFUGaHqHDLKLX+FIPKcF96hrucXzcW
-yLbIbEgE98OHlnVYCzRdK8jlqm8tehUc9c9WhQIBIwKCAQEA4iqWPJXtzZA68mKd
-ELs4jJsdyky+ewdZeNds5tjcnHU5zUYE25K+ffJED9qUWICcLZDc81TGWjHyAqD1
-Bw7XpgUwFgeUJwUlzQurAv+/ySnxiwuaGJfhFM1CaQHzfXphgVml+fZUvnJUTvzf
-TK2Lg6EdbUE9TarUlBf/xPfuEhMSlIE5keb/Zz3/LUlRg8yDqz5w+QWVJ4utnKnK
-iqwZN0mwpwU7YSyJhlT4YV1F3n4YjLswM5wJs2oqm0jssQu/BT0tyEXNDYBLEF4A
-sClaWuSJ2kjq7KhrrYXzagqhnSei9ODYFShJu8UWVec3Ihb5ZXlzO6vdNQ1J9Xsf
-4m+2ywKBgQD6qFxx/Rv9CNN96l/4rb14HKirC2o/orApiHmHDsURs5rUKDx0f9iP
-cXN7S1uePXuJRK/5hsubaOCx3Owd2u9gD6Oq0CsMkE4CUSiJcYrMANtx54cGH7Rk
-EjFZxK8xAv1ldELEyxrFqkbE4BKd8QOt414qjvTGyAK+OLD3M2QdCQKBgQDtx8pN
-CAxR7yhHbIWT1AH66+XWN8bXq7l3RO/ukeaci98JfkbkxURZhtxV/HHuvUhnPLdX
-3TwygPBYZFNo4pzVEhzWoTtnEtrFueKxyc3+LjZpuo+mBlQ6ORtfgkr9gBVphXZG
-YEzkCD3lVdl8L4cw9BVpKrJCs1c5taGjDgdInQKBgHm/fVvv96bJxc9x1tffXAcj
-3OVdUN0UgXNCSaf/3A/phbeBQe9xS+3mpc4r6qvx+iy69mNBeNZ0xOitIjpjBo2+
-dBEjSBwLk5q5tJqHmy/jKMJL4n9ROlx93XS+njxgibTvU6Fp9w+NOFD/HvxB3Tcz
-6+jJF85D5BNAG3DBMKBjAoGBAOAxZvgsKN+JuENXsST7F89Tck2iTcQIT8g5rwWC
-P9Vt74yboe2kDT531w8+egz7nAmRBKNM751U/95P9t88EDacDI/Z2OwnuFQHCPDF
-llYOUI+SpLJ6/vURRbHSnnn8a/XG+nzedGH5JGqEJNQsz+xT2axM0/W/CRknmGaJ
-kda/AoGANWrLCz708y7VYgAtW2Uf1DPOIYMdvo6fxIB5i9ZfISgcJ/bbCUkFrhoH
-+vq/5CIWxCPp0f85R4qxxQ5ihxJ0YDQT9Jpx4TMss4PSavPaBH3RXow5Ohe+bYoQ
-NE5OgEXk2wVfZczCZpigBKbKZHNYcelXtTt/nP3rsCuGcM4h53s=
------END RSA PRIVATE KEY-----
-'''
-
-
 def parse_inventory(path, host_attrs):
     with open(path) as fd:
         inventory = yaml.safe_load(fd)
@@ -87,16 +56,24 @@ def encode_dashboard_request(req):
     return base64.urlsafe_b64encode(comp.flush()).decode('ascii')
 
 
-def install_vagrant_ssh_key():
-    # Install the SSH key as Vagrant would do, for compatibility.
-    key_path = os.path.join(
-        os.getenv('HOME'), '.vagrant.d', 'insecure_private_key')
-    if os.path.exists(key_path):
-        return
-    os.makedirs(os.path.dirname(key_path), mode=0o700, exist_ok=True)
-    with open(key_path, 'w') as fd:
-        fd.write(INSECURE_PRIVATE_KEY)
-    os.chmod(key_path, 0o600)
+def generate_ssh_key():
+    path = '/root/.ssh/temp'
+    if os.getenv('HOME'):
+        path = os.getenv('HOME') + '/.ssh/temp'
+    os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
+    subprocess.check_call(['ssh-keygen', '-t', 'ed25519', '-f', path, '-C', '', '-N', ''])
+    return path
+
+
+def generate_ssh_config(inventory, private_key_path):
+    netglob = re.sub(r'\.0/24$', '.*', inventory['network'])
+    return f'''
+Host {netglob}
+    User root
+    IdentityFile {private_key_path}
+    StrictHostKeyChecking no
+    UserKnownHostsFile /dev/null
+'''
 
 
 def main():
@@ -135,8 +112,11 @@ def main():
         help='vmine dashboard base URL (for Gitlab CI)')
     parser.add_argument(
         '--ssh-key', metavar='FILE',
-        type=argparse.FileType('r'),
         help='root SSH key to install on VMs')
+    parser.add_argument(
+        '--ssh-config', metavar='FILE',
+        default='/root/.ssh/config',
+        help='append SSH config to this file')
     parser.add_argument(
         '--name', metavar='NAME',
         help='group name (for named groups)')
@@ -153,14 +133,19 @@ def main():
             host_attrs['cpu'] = args.cpu
         if args.image:
             host_attrs['image'] = args.image
+
         req = parse_inventory(args.inventory, host_attrs)
         req['ttl'] = args.ttl
         if args.name:
             req['name'] = args.name
         if args.ssh_key:
-            req['ssh_key'] = args.ssh_key
+            ssh_key_path = args.ssh_key
         else:
-            install_vagrant_ssh_key()
+            ssh_key_path = generate_ssh_key()
+        with open(ssh_key_path + '.pub', 'r') as fd:
+            req['ssh_key'] = fd.read().strip()
+
+        os.umask(0o077)
 
         print(f'creating VM group with attrs {host_attrs} ...')
         print(f'vmine request: {req}')
@@ -170,13 +155,20 @@ def main():
             fd.write(group_id)
         print(f'created VM group {group_id}')
 
+        if args.ssh_config:
+            print(f'updating ssh config')
+            with open(args.ssh_config, 'a') as fd:
+                fd.write(generate_ssh_config(req, ssh_key_path))
+
         if args.env:
             with open(args.env, 'w') as fd:
                 fd.write(f'VMINE_ID={group_id}\n')
                 if args.dashboard_url:
                     base_url = args.dashboard_url.rstrip('/')
                     payload = encode_dashboard_request(req)
-                    fd.write(f'VMINE_GROUP_URL={base_url}/dash/{payload}\n')
+                    dashboard_url = f'{base_url}/dash/{payload}'
+                    fd.write(f'VMINE_GROUP_URL={dashboard_url}\n')
+                    print(f'dashboard URL: {dashboard_url}')
 
     elif args.cmd == 'down':
         req = {}
@@ -192,8 +184,11 @@ def main():
                 return
             req['group_id'] = group_id
             print(f'stopping VM group {group_id}...')
-        do_request(args.url + '/api/stop-group', args.ssh, req)
-        if args.state_file:
+        try:
+            do_request(args.url + '/api/stop-group', args.ssh, req)
+        except:
+            pass
+        if args.state_file and os.path.exists(args.state_file):
             os.remove(args.state_file)
 
 
diff --git a/test-driver b/test-driver
index 863c71768276eac96af1cb3d2487981c076bf6ff..3ae850a751c3ccf13da5d81765821442b1a31607 100755
--- a/test-driver
+++ b/test-driver
@@ -39,7 +39,7 @@ wait_for_vms() {
     local ok=1
     while [ $i -lt 10 ]; do
         sleep 3
-        if ansible -v -i config.yml all -m ping; then
+        if ansible -vvv -i config.yml all -m ping; then
             ok=0
             break
         fi
diff --git a/test/integration-test.yml b/test/integration-test.yml
index 449b919e26415a4c7a065831270f009f0e4bd352..e43a784e9e45d3cc36ce72bade708f98e8ef9f94 100644
--- a/test/integration-test.yml
+++ b/test/integration-test.yml
@@ -14,7 +14,7 @@
       failed_when: "test_container_image.rc not in [0, 42]"
 
     - name: Run tests
-      command: "docker run --rm --network host --mount type=bind,source=/tmp/test-config.yml,destination=/test-config.yml {{ test_image }}"
+      command: "podman run --rm --network host --mount type=bind,source=/tmp/test-config.yml,destination=/test-config.yml {{ test_image }}"
 
   vars:
     test_image: "registry.git.autistici.org/ai3/float:integration-test"