diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index dd6702382c1d7eb2b15f967251e46859ea3aa040..72cd979f3aec58eed69188761e5c487fc2fad5e0 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -43,10 +43,7 @@ variables:
       ${APT_PROXY:+-e config.apt_proxy=${APT_PROXY}}
       $CREATE_ENV_VARS $BUILD_DIR
     - cp -v ${TEST_DIR}/site.yml ${BUILD_DIR}
-    - sed -i '/ansible_ssh_private_key_file/d' ${BUILD_DIR}/hosts.yml
-    - sed -i 's/ansible_user:\ vagrant/ansible_user:\ root/' ${BUILD_DIR}/hosts.yml
     - echo "$(awk '!/- backend/ || ++ctr != 2' ${BUILD_DIR}/hosts.yml)" > ${BUILD_DIR}/hosts.yml
-    - curl -o float/scripts/floatup.py https://git.autistici.org/ai3/float/-/raw/master/scripts/floatup.py
     - with-ssh-key ./float/scripts/floatup.py --url $VMINE_URL --ssh $VMINE_SSH --inventory $BUILD_DIR/hosts.yml --ram 3072 --image ${VM_IMAGE:-bullseye} up
     - sed -i '2 i\   User leap_ci\n   IdentityFile /root/.ssh/key' ~/.ssh/config
     - echo "    ProxyJump remotevirt.riseup.net" >> ~/.ssh/config
diff --git a/float/.gitlab-ci.yml b/float/.gitlab-ci.yml
index 34531018ebf56b03aeccd6f5af4597b35fec07fe..ef06edef9ccc800f2ea4da42929d98061a432e31 100644
--- a/float/.gitlab-ci.yml
+++ b/float/.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/.gitrepo b/float/.gitrepo
index 60b6a9b1f9554124f75e4e0a3ead379595bede23..90225847b3fb258312fd54870b87c64a26227b24 100644
--- a/float/.gitrepo
+++ b/float/.gitrepo
@@ -6,7 +6,7 @@
 [subrepo]
 	remote = https://git.autistici.org/ai3/float.git
 	branch = master
-	commit = 89039534fb72c317de51d7a5c2f8e6815d61b982
+	commit = 7e37a32b31c5243273ec16e8dfd8b3d48ce663d3
 	parent = 155d2691324dc97829db4e0a5f77b512bb8c0647
-	cmdver = 0.4.7
+	cmdver = 0.4.6
 	method = merge
diff --git a/float/docs/old/playbook.md b/float/docs/old/playbook.md
deleted file mode 100644
index 3811ec51cfa9f376ce6ffc5eb27dd52b39e175b1..0000000000000000000000000000000000000000
--- a/float/docs/old/playbook.md
+++ /dev/null
@@ -1,177 +0,0 @@
-Playbook
-===
-
-This document describes how to perform some common operations in
-*float*.
-
-
-## Applying changes
-
-### Rolling back the configuration
-
-If you are using a Git repository as your configuration source,
-*float* will keep track of which commit has been pushed to production
-last, and it will try to prevent you from pushing an old version of
-the configuration, failing immediately with an error. This is a simple
-check to make sure that people do not inadvertently roll back the
-production configuration by pushing from an out-of-date client.
-
-In most cases what you want to do in that case is to simply run *git
-pull* and bring your copy of the repository up to date. But if you
-really need to push an old version of the configuration in an
-emergency, you can do so by setting the *rollback* value to *true* on
-the command-line:
-
-```shell
-$ float run -e rollback=true site.yml
-```
-
-
-## For administrators
-
-### SSH Client Setup
-
-If you delegated SSH management to float by setting *enable_ssh* to
-true (see the [configuration reference](configuration.md)), float will
-create a SSH CA to sign all your host keys.
-
-You will find the public key for this CA in the
-*credentials/ssh/key.pub* file, it will be created the first time you
-run the "init-credentials" playbook.
-
-Assuming that all your target hosts share the same domain (so you can
-use a wildcard), you should add the following entry to
-*~/.ssh/known_hosts*:
-
-```
-@cert_authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAA....
-```
-
-Since all logins happen as root, it may be convenient to also add a
-section to your *~/.ssh/config* file like the following:
-
-```
-Host *.example.com
-    User root
-```
-
-### Adding an admin account
-
-Adding a new administrator account is just a matter of editing the
-*admins* [configuration variable](configuration.md) and add a new
-entry to it.
-
-The first thing you will need is a hashed version of your
-password. The authentication service in float supports a number of
-legacy hashing schemes, including those supported by the system
-crypt(). The most secure hashing scheme supported is Argon2, and you
-can use our custom tool to generate a valid hash. To install it:
-
-```shell
-$ go install git.autistici.org/ai3/go-common/cmd/pwtool
-```
-
-Run the *pwtool* utility with your new password as an argument, as
-shown below:
-
-```shell
-# Do not save your password in the history of your shell
-$ export HISTIGNORE="./pwtool.amd64*"
-$ ./pwtool.amd64 PASSWORD
-```
-
-where PASSWORD is your desired password.
-
-It will output the hashed password.
-
-Then modify the YAML file *group_vars/all/admins.yml*. At the bare
-minimum the new account should have a *name*, *email*, *password* and
-*ssh_keys* attributes, e.g.:
-
-```yaml
----
-admins:
-  - name: "foo"
-    email: "foo@example.com"
-    password: "$a2$3$32768$4$abcdef...."
-    ssh_keys:
-      - "ssh-ed25519 AAAAC3Nza..."
-```
-
-Here above "ssh_keys:" needs to be populated with your public key,
-possibly stripped from the trailing user@hostname text (which may leak
-your personal information), and "password:" must be the hashed
-password you got from *pwtool* earlier.
-
-### Setting up OTP for an admin account
-
-First you need to manually generate the OTP secret on your computer:
-
-```shell
-$ SECRET=$(dd if=/dev/urandom bs=20 count=1 2>/dev/null | base32)
-$ echo $SECRET
-EVUVNACTWRAIERATIZUQA6YQ4WS63RN2
-```
-
-Install the package qrencode, and feed the OTP secret to it.
-For example with apt ["apt install qrencode" of course].
-
-```shell
-$ EMAIL="sub@krutt.org"
-$ qrencode -t UTF8 "otpauth://totp/example.com:${EMAIL}?secret=${SECRET}&issuer=example.com&algorithm=SHA1&digits=6&period=30"
-```
-
-and read the qrcode with your favourite app.
-
-Then add it to your user object in *group_vars/all/admins.yml* as the
-*totp_secret* attribute:
-
-```yaml
----
-admins:
-  - name: "foo"
-    totp_secret: "EVUVNACTWRAIERATIZUQA6YQ4WS63RN2"
-    ...
-```
-
-Finally, configure your TOTP client (app, YubiKey, etc.) with the same
-secret.
-
-Note that the secret is stored in cleartext in the git repository, so
-using a hardware token (U2F) is preferred.
-
-### Registering a U2F hardware token for an admin account
-
-In the *group_vars/all/admins.yml* file, you can add the
-*u2f_registrations* attribute to accounts, which is a list of the
-allowed U2F device registrations.
-
-To register a new device, you are going to need the *pamu2fcfg* tool
-(part of the *pamu2fcfg* Debian package). The following snippet should
-produce the two YAML attributes that you need to set:
-
-```shell
-$ pamu2fcfg --nouser --appid https://accounts.example.com \
-    | tr -d : \
-    | awk -F, '{print "key_handle: \"" $1 "\"\npublic_key: \"" $2 "\""}'
-```
-
-press enter, touch the key, copy the output and insert it in
-*group_vars/all/admins.yml*, the final results should look like:
-
-```yaml
----
-admins:
-  - name: "foo"
-    email: "foo@example.com"
-    password: "$a2$3$32768$4$abcdef...."
-    ssh_keys:
-      - "ssh-ed25519 AAAAC3Nza..."
-    u2f_registrations:
-      - key_handle: "r4wWRHgzJjl..."
-        public_key: "04803e4aff4..."
-```
-
-**NOTE**: the above will work with *pam_u2f* version 1.0.7, but it will *not*
-work with pam_u2f version 1.1.0 due to changes in the output format!
-
diff --git a/float/docs/reference.md b/float/docs/reference.md
index b088a68039b5911cfa9055093dccfd1bee310879..c1e6eb8511ab1af935be4dfebccbd0cee1823f3b 100644
--- a/float/docs/reference.md
+++ b/float/docs/reference.md
@@ -2798,7 +2798,7 @@ There are some minimal requirements on how your Ansible environment
 should be set up for this to work:
 
 * you must have a *group_vars/all* directory (this is where we'll
-  write the autogenerated application credentials file *secrets.yml*q)
+  write the autogenerated application credentials file *secrets.yml*)
 * you must include float's *playbooks/all.yml* playbook file from the
   toolkit source directory at the beginning of your playbook
 * you should use the *float* wrapper instead of running
@@ -3241,7 +3241,7 @@ Install the package qrencode, and feed the OTP secret to it.
 For example with apt ["apt install qrencode" of course].
 
 ```shell
-$ EMAIL="sub@krutt.org"
+$ EMAIL="foo@example.com"
 $ qrencode -t UTF8 "otpauth://totp/example.com:${EMAIL}?secret=${SECRET}&issuer=example.com&algorithm=SHA1&digits=6&period=30"
 ```
 
@@ -3318,6 +3318,19 @@ If you want more control over this process (Debian upgrades have been
 event-less for a while now, but it's not always been the case) you
 can of course run the upgrade manually.
 
+### Decommissioning a host
+
+When turning down a host, it is necessary, at some point, to
+reschedule the services that were there onto some other hosts. To
+achieve a smooth transition, this is best done while the host is still
+available.
+
+To do this, set the *turndown* attribute to *true* in the inventory
+for the host you want to turn down, and then run *float* once more.
+This should safely reschedule all services, and remove them from the
+target host. It is then possible to simply shut down the target host
+and wipe its data.
+
 # Example scenarios
 
 This section will look at some example scenarios and use cases for
diff --git a/float/docs/reference.pdf b/float/docs/reference.pdf
index c55f860229873a58eaa48b0efa865a48b7a81f3a..7a29260cc0f44d7f4bac04e7b096da388ecd8d67 100644
Binary files a/float/docs/reference.pdf and b/float/docs/reference.pdf differ
diff --git a/float/float b/float/float
index eae52a4b41c868271de51dee956f69ca1a7ee62a..ce358ce1857283a25f6d7bb4dfcc827a0ac6acd1 100755
--- a/float/float
+++ b/float/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/float/plugins/inventory/float.py b/float/plugins/inventory/float.py
index 46c2b25a0e4d0d1c53635da2e197e14f576963c9..e67df79ecb905ae7792d9ae24b952ab3ef688613 100644
--- a/float/plugins/inventory/float.py
+++ b/float/plugins/inventory/float.py
@@ -282,6 +282,16 @@ def _global_dns_map(inventory):
     return dns
 
 
+# Return the hosts that are not available for scheduling, as a
+# Python set.
+def _unavailable_hosts(inventory):
+    unavail = set()
+    for name, values in inventory['hosts'].items():
+        if values.get('turndown'):
+            unavail.add(name)
+    return unavail
+
+
 # Build a group -> hosts map out of an inventory.
 def _build_group_map(inventory, assignments=None):
     group_map = {}
@@ -499,7 +509,8 @@ class Assignments(object):
         return str(self._fwd)
 
     @classmethod
-    def _available_hosts(cls, service, group_map, service_hosts_map):
+    def _available_hosts(cls, service, group_map, service_hosts_map,
+                         unavailable_hosts={}):
         if 'schedule_with' in service:
             return service_hosts_map[service['schedule_with']]
         scheduling_groups = ['all']
@@ -512,7 +523,7 @@ class Assignments(object):
             if g not in group_map:
                 raise Exception(f'The scheduling_group "{g}" is not defined in inventoy')
             available_hosts.update(group_map[g])
-        return list(available_hosts)
+        return list(available_hosts.difference(unavailable_hosts))
 
     @classmethod
     def schedule(cls, services, inventory):
@@ -525,6 +536,7 @@ class Assignments(object):
         """
         service_hosts_map = {}
         service_master_map = {}
+        unavailable_hosts = _unavailable_hosts(inventory)
         group_map = _build_group_map(inventory)
         host_occupation = collections.defaultdict(int)
 
@@ -540,13 +552,16 @@ class Assignments(object):
         for service_name in sorted(services.keys(), key=_sort_key):
             service = services[service_name]
             available_hosts = cls._available_hosts(service, group_map,
-                                                   service_hosts_map)
+                                                   service_hosts_map,
+                                                   unavailable_hosts)
             num_instances = service.get('num_instances', 'all')
             if num_instances == 'all':
                 service_hosts = sorted(available_hosts)
             else:
                 service_hosts = sorted(_binpack(
                     available_hosts, host_occupation, num_instances))
+            if not service_hosts:
+                raise Exception(f'No hosts available to schedule service {service_name}')
             service_hosts_map[service_name] = service_hosts
             for h in service_hosts:
                 host_occupation[h] += 1
diff --git a/float/roles/float-base/files/journald.conf b/float/roles/float-base/files/journald.conf
index b69850df1d39ae8454044390bc35994ab52518b1..700ceca35bf50b7b7bbfd5a76445bdda020e44bc 100644
--- a/float/roles/float-base/files/journald.conf
+++ b/float/roles/float-base/files/journald.conf
@@ -1,2 +1,6 @@
 [Journal]
 Storage=volatile
+RateLimitIntervalSec=0
+RateLimitBurst=0
+Compress=no
+Seal=no
diff --git a/float/roles/float-base/tasks/apt.yml b/float/roles/float-base/tasks/apt.yml
index 7923add755781d6ddcccab6b8a2e4b3d72fc194e..608ffd89c0dc189ea74dc5ce318108d47debc592 100644
--- a/float/roles/float-base/tasks/apt.yml
+++ b/float/roles/float-base/tasks/apt.yml
@@ -107,6 +107,7 @@
       - acpid
       - auditd
       - ca-certificates
+      - cron
       - curl
       - git
       - gpg
diff --git a/float/roles/float-base/tasks/main.yml b/float/roles/float-base/tasks/main.yml
index 41b65d59ec0b3780ffdfc762098b1a2c3e103c00..e47d2bb3a5ae986551fe4d0f8df16f661af4c9af 100644
--- a/float/roles/float-base/tasks/main.yml
+++ b/float/roles/float-base/tasks/main.yml
@@ -40,14 +40,6 @@
 - include_tasks: rollback_protection.yml
   when: "git_revision != 'none' and not testing|default(True)"
 
-# Detect virtual machines / physical hardware.
-- name: Detect virtual machine
-  slurp:
-    src: "/sys/class/dmi/id/sys_vendor"
-  register: slurp_sysfs_dmi_vendor
-- set_fact:
-    float_is_vm: "{{ slurp_sysfs_dmi_vendor['content'] | b64decode == 'QEMU' }}"
-
 # Create the /usr/lib/float and /var/lib/float directories for
 # internal scripts.
 - file:
diff --git a/float/roles/float-infra-nginx/defaults/main.yml b/float/roles/float-infra-nginx/defaults/main.yml
index 932c06c8eec426906fef261333356569226837ea..cba99a727bf4dbf753214980633d0195513a4e19 100644
--- a/float/roles/float-infra-nginx/defaults/main.yml
+++ b/float/roles/float-infra-nginx/defaults/main.yml
@@ -22,7 +22,7 @@ nginx_limit_perserver_rate: "100r/s"
 nginx_limit_perserver_burst: 100
 
 # Various top-level NGINX configuration options that might need tuning.
-nginx_worker_connections: 4096
+nginx_worker_connections: 65536
 nginx_keepalive_timeout: "20s"
 nginx_server_names_hash_max_size: 2048
 nginx_server_names_hash_bucket_size: 2048
@@ -36,3 +36,4 @@ nginx_custom_error_pages: true
 # nginx_install_custom_error_pages: install float's custom error pages
 # in /var/www/html/__errors/.
 nginx_install_custom_error_pages: true
+
diff --git a/float/roles/float-infra-nginx/tasks/nginx.yml b/float/roles/float-infra-nginx/tasks/nginx.yml
index e30b833a840d06d2f5f170093d2fec6723c69876..7d0c0722dfdf2a151ae19fa23bece9c29719c8af 100644
--- a/float/roles/float-infra-nginx/tasks/nginx.yml
+++ b/float/roles/float-infra-nginx/tasks/nginx.yml
@@ -67,8 +67,8 @@
     - proxy
 
 - name: Install NGINX systemd unit
-  copy:
-    src: nginx.service
+  template:
+    src: nginx.service.j2
     dest: /etc/systemd/system/nginx.service
   notify: reload nginx
 
diff --git a/float/roles/float-infra-nginx/templates/config/conf.d/limits.conf b/float/roles/float-infra-nginx/templates/config/conf.d/limits.conf
index 380d4887a488bcb4b945d288c33324ace0a990da..7c8fb60f06a201c56f4ce48fdc0c09ddac9aa01b 100644
--- a/float/roles/float-infra-nginx/templates/config/conf.d/limits.conf
+++ b/float/roles/float-infra-nginx/templates/config/conf.d/limits.conf
@@ -1,5 +1,6 @@
 # Per-IP rate limiting.
-limit_req_zone $binary_remote_addr zone=perip:32m rate={{ nginx_limit_perip_rate }};
+# A 128M zone holds information on about 1M IPs.
+limit_req_zone $binary_remote_addr zone=perip:128m rate={{ nginx_limit_perip_rate }};
 # Per-server rate limiting.
 limit_req_zone $server_name zone=perserver:10m rate={{ nginx_limit_perserver_rate }};
 
diff --git a/float/roles/float-infra-nginx/templates/config/conf.d/proxy.conf b/float/roles/float-infra-nginx/templates/config/conf.d/proxy.conf
index 7d31efc24a25d7ba30aca733b6d9e0d4c6becbbc..c67e0401b5c7145dcfec69b00788b0580a24e964 100644
--- a/float/roles/float-infra-nginx/templates/config/conf.d/proxy.conf
+++ b/float/roles/float-infra-nginx/templates/config/conf.d/proxy.conf
@@ -32,3 +32,9 @@ proxy_cache_min_uses 2;
 # Show our own error pages, not the remote ones.
 proxy_intercept_errors on;
 
+# Ensure we only send one upstream request, and allow NGINX to serve
+# stale data while updating.
+proxy_cache_use_stale updating;
+proxy_cache_background_update on;
+proxy_cache_lock on;
+
diff --git a/float/roles/float-infra-nginx/files/nginx.service b/float/roles/float-infra-nginx/templates/nginx.service.j2
similarity index 94%
rename from float/roles/float-infra-nginx/files/nginx.service
rename to float/roles/float-infra-nginx/templates/nginx.service.j2
index dda45307e70a7183df007d72151f3bea742c0f76..ad0a7fcc3d104ba638ac67308206249fa527fbfe 100644
--- a/float/roles/float-infra-nginx/files/nginx.service
+++ b/float/roles/float-infra-nginx/templates/nginx.service.j2
@@ -18,7 +18,7 @@ ExecStop=/bin/kill -TERM $MAINPID
 
 User=nginx
 Group=nginx
-LimitNOFILE=65535
+LimitNOFILE={{ nginx_worker_connections * 2 }}
 
 NoNewPrivileges=yes
 PrivateTmp=yes
@@ -36,5 +36,7 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE
 RuntimeDirectory=nginx
 RuntimeDirectoryMode=750
 
+JournalNamespace=nginx
+
 [Install]
 WantedBy=multi-user.target
diff --git a/float/roles/float-infra-prometheus/templates/rules/rules_nginx.conf.yml b/float/roles/float-infra-prometheus/templates/rules/rules_nginx.conf.yml
index 60b59bb0e4fd51a18bc2026dab5f1228b6c0ee53..d9abacdf866f3f3da931d8953476a75fbe8db82f 100644
--- a/float/roles/float-infra-prometheus/templates/rules/rules_nginx.conf.yml
+++ b/float/roles/float-infra-prometheus/templates/rules/rules_nginx.conf.yml
@@ -17,6 +17,8 @@ groups:
     expr: (global:nginx_http_requests_errs:rate5m / global:nginx_http_requests_total:rate5m)
   - record: global:nginx_http_cached_requests:ratio
     expr: clamp_max(sum(rate(nginx_http_requests_cache[5m])) by (vhost, cache_status) / ignoring (cache_status) group_left global:nginx_http_requests_200:rate5m, 1)
+  - record: host:nginx_http_cached_requests:ratio
+    expr: clamp_max(sum(rate(nginx_http_requests_cache[5m])) by (host, cache_status) / ignoring (cache_status) group_left sum(rate(nginx_http_requests{code="200"}[5m])) by (host), 1)
 
 - name: http_requests_ms_histogram
   rules:
diff --git a/float/scripts/floatup.py b/float/scripts/floatup.py
index 5647d7dc306e28791a4f8fb2e4536d6000b68ad3..fa3aa850d2121401a6a99d07eb6613a7211ce0eb 100755
--- a/float/scripts/floatup.py
+++ b/float/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/float/test/integration-test.yml b/float/test/integration-test.yml
index 449b919e26415a4c7a065831270f009f0e4bd352..e43a784e9e45d3cc36ce72bade708f98e8ef9f94 100644
--- a/float/test/integration-test.yml
+++ b/float/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"