Automated Deployment with Ansible

If you manage a shared virtual machine or bare-metal server, you can use our provided Ansible playbooks to easily deploy Rootless Docker, provision isolated Carto-Lab (JupyterLab) instances for individual users, and maintain the deployment over time.

This automation handles subuid/subgid namespace mappings, systemd lingering, dynamic reverse-proxy configuration via Nginx, Telegraf resource monitoring, automated health reconciliation, and nightly auto-reset cron jobs.

Warning

The ansible playbooks shared here are great for managing many individual instances of Carto-Lab Docker in isolated environments across different cloud VMs. However, for (e.g.) university wide or large cloud deployments (1000+ users), we recommend JupyterHub or deploying Carto-Lab pods via Kubernetes.


Architecture & Progression Philosophy

Carto-Lab is built around a philosophy of gradual empowerment:

  • Zero-Friction Onboarding: For non-technical researchers and students, administrators provision a fully managed web environment. Users log in through their browser (https://<user>.example.com) and immediately have access to interactive maps, notebooks, and pre-configured spatial libraries without touching a terminal.
  • Gradual Progression to Self-Management: As researchers (such as those at IOER FDZ) advance and require more complex workflows, they are not constrained by a rigid web sandbox. Because each workspace runs inside an isolated rootless user namespace (/srv/<username>), administrators can safely grant them direct SSH access. At our institute, these user environments serve as the daily working horse for heavy GIS spatial workloads and the development of reproducible GIS pipelines.
  • Full Pipeline Flexibility: Inside their rootless shell, advanced users can self-manage. They can inspect logs, start and stop containers, attach VS Code Remote, or spin up companion containers (such as a local PostgreSQL/PostGIS database or custom background tasks) for heavy spatial data science pipelines without needing host root privileges.

Directory Structure

Set up your control machine or repository to match this structure:

ansible/
├── ansible.cfg                    # Pipelining and inventory settings
├── 1_setup_rootless_user.yml    # Step 1: Provision rootless system user & Docker daemon
├── 2_setup_cartolab.yml          # Step 2: Deploy Carto-Lab, Nginx, Telegraf, and Compose stack
├── 2.1_reconcile_cartolab.yml   # Step 3: Self-healing & configuration reconciliation for all users
├── group_vars/
│   └── all/
│        └── vault.yml             # Shared InfluxDB monitoring tokens [Vault ID: shared]
├── host_vars/                     # Untracked personal sudo credentials [Vault ID: hosts]
│   └── <hostname>/vault.yml
├── inventories/
│   └── hosts
├── nginx_jupyter.conf.j2
└── README.md

Prerequisites & Setup

1. Install Ansible

sudo apt-get update
sudo apt-get install -y ansible

2. Configure Target Inventory (inventories/hosts)

Define your target server:

[jupyter_servers]
jupyter_server ansible_host=192.168.1.100 ansible_user=admin

[all:vars]
ansible_python_interpreter=/usr/bin/python3

3. Configure Shared Vault (group_vars/all/vault.yml)

Create the shared vault for monitoring credentials:

mkdir -p group_vars/all
ansible-vault create --vault-id shared@prompt group_vars/all/vault.yml

Add your InfluxDB variables for Telegraf metrics:

influxdb_url: "https://influx.example.com"
influxdb_token: "your-token"
influxdb_org: "your-org"
influxdb_bucket: "your-bucket"

Step 1: Set up the Rootless Docker User

Creates a dedicated system user in /srv/<username>, configures subuid/subgid ranges, enables systemd lingering, and starts the user-level rootless Docker daemon.

ansible-playbook -i inventories/hosts 1_setup_rootless_user.yml -l jupyter_server -K
Playbook (1_setup_rootless_user.yml)

---
- name: Setup Rootless Docker User
  hosts: all
  become: yes

  vars_prompt:
    - name: service_user_name
      prompt: "Enter the new service username (e.g., tao, mmu)"
      private: no
    - name: service_user_home_base
      prompt: "Enter the base directory for the user home"
      default: "/srv"
      private: no

  vars:
    service_user_home: "{{ service_user_home_base }}/{{ service_user_name }}"
    docker_packages:
      - docker-ce
      - docker-ce-cli
      - containerd.io
      - docker-buildx-plugin
      - docker-compose-plugin
      - docker-ce-rootless-extras
      - uidmap
      - dbus-user-session
      - slirp4netns

  tasks:
    - name: Ensure Docker and dependencies are installed
      ansible.builtin.apt:
        name: "{{ docker_packages }}"
        state: present
        update_cache: yes

    - name: Stop and disable rootful Docker service/socket
      ansible.builtin.systemd:
        name: "{{ item }}"
        state: stopped
        enabled: no
      loop:
        - docker.service
        - docker.socket
      ignore_errors: yes

    - name: Remove rootful Docker socket file
      ansible.builtin.file:
        path: /var/run/docker.sock
        state: absent

    - name: Create the dedicated service user
      ansible.builtin.user:
        name: "{{ service_user_name }}"
        home: "{{ service_user_home }}"
        shell: /bin/bash
        system: yes
        create_home: yes

    - name: Get User UID
      ansible.builtin.command: "id -u {{ service_user_name }}"
      register: user_uid
      changed_when: false

    - name: Check if user already has subuid mapping
      ansible.builtin.shell: "grep '^{{ service_user_name }}:' /etc/subuid"
      register: subuid_check
      failed_when: false
      changed_when: false

    - name: Calculate next start ID for subuid/subgid
      ansible.builtin.shell: |
        awk -F: '{print $2 + $3}' /etc/subuid /etc/subgid 2>/dev/null | sort -n | tail -1
      register: next_id_cmd
      when: subuid_check.rc != 0
      changed_when: false

    - name: Set next start ID fact (fallback to 100000)
      ansible.builtin.set_fact:
        next_start_id: "{{ next_id_cmd.stdout | default('100000', true) }}"
      when: subuid_check.rc != 0

    - name: Add subuid and subgid ranges
      ansible.builtin.command: >
        usermod --add-subuids {{ next_start_id }}-{{ next_start_id | int + 65535 }}
        --add-subgids {{ next_start_id }}-{{ next_start_id | int + 65535 }} {{ service_user_name }}
      when: subuid_check.rc != 0

    - name: Ensure ip_tables module is loaded
      ansible.builtin.command: modprobe ip_tables
      register: modprobe_res
      failed_when: false
      changed_when: false

    - name: Fallback to insmod if modprobe failed (Ubuntu 24.04 bug workaround)
      ansible.builtin.command: "insmod /lib/modules/{{ ansible_kernel }}/kernel/net/ipv4/netfilter/ip_tables.ko.zst"
      when: modprobe_res.rc != 0
      failed_when: false
      changed_when: false

    - name: Enable linger for the service user (required for systemd user session)
      ansible.builtin.command: "loginctl enable-linger {{ service_user_name }}"

    - name: Install Rootless Docker
      become: yes
      become_user: "{{ service_user_name }}"
      ansible.builtin.shell: |
        export XDG_RUNTIME_DIR=/run/user/{{ user_uid.stdout }}
        export DBUS_SESSION_BUS_ADDRESS=unix:path=${XDG_RUNTIME_DIR}/bus
        export PATH=/usr/bin:$PATH
        dockerd-rootless-setuptool.sh install
      args:
        creates: "{{ service_user_home }}/.config/systemd/user/docker.service"

    - name: Enable and start Rootless Docker service
      become: yes
      become_user: "{{ service_user_name }}"
      ansible.builtin.shell: |
        export XDG_RUNTIME_DIR=/run/user/{{ user_uid.stdout }}
        export DBUS_SESSION_BUS_ADDRESS=unix:path=${XDG_RUNTIME_DIR}/bus
        systemctl --user enable --now docker

    - name: Configure .bashrc with Docker variables
      ansible.builtin.blockinfile:
        path: "{{ service_user_home }}/.bashrc"
        marker: "# {mark} ANSIBLE MANAGED BLOCK - DOCKER ROOTLESS"
        block: |
          export PATH=/usr/bin:$PATH
          export DOCKER_HOST=unix:///run/user/{{ user_uid.stdout }}/docker.sock
          if [[ $- == *i* ]] && [ -d "$HOME/jupyterlab" ]; then
              cd ~/jupyterlab
          fi

Step 2: Deploy the Carto-Lab Environment

Clones Carto-Lab, generates .env, provisions persistent Conda and notebook directories, configures reverse-proxy Nginx virtual hosts, configures Telegraf, and launches the container stack.

ansible-playbook -i inventories/hosts \
    2_setup_cartolab.yml -l \
    jupyter_server -K --vault-id shared@prompt

When prompted, provide the desired username, a unique internal port (e.g., 9288), and the public URL you intend to use (e.g., https://user.example.com).

Playbook (2_setup_cartolab.yml)

---
- name: Setup Carto-Lab Jupyter Instance
  hosts: all
  become: yes

  vars_prompt:
    - name: service_user_name
      prompt: "Enter the service username (e.g., tao, dre, mmu)"
      private: no
    - name: jupyter_port
      prompt: "Enter local Jupyter web port (e.g., 9288)"
      private: no
    - name: jupyter_password
      prompt: "Enter the Jupyter password (leave blank for auto-generation)"
      private: yes
    - name: git_user_name
      prompt: "Enter Git user name for container"
      default: "Jupyter Container Bot"
      private: no
    - name: git_user_email
      prompt: "Enter Git user email for container"
      default: "bot@example.com"
      private: no
    - name: git_token
      prompt: "Enter Git access token (leave blank if public clone works)"
      private: yes

  vars:
    service_user_home: "/srv/{{ service_user_name }}"
    git_repo: "https://{{ 'oauth2:' + git_token + '@' if git_token != '' else '' }}github.com/ioer-dresden/carto-lab-docker.git"

    # Auto-generate a password if the prompt was left empty
    final_jupyter_password: "{{ jupyter_password | default(lookup('password', '/dev/null chars=ascii_letters,digits length=16'), true) }}"

  pre_tasks:
    - name: Prompt for Web URL dynamically
      ansible.builtin.pause:
        prompt: "Enter the public URL (leave blank to default to https://{{ service_user_name }}.example.com)"
      register: prompt_web_url
      run_once: true

    - name: Set final_web_url fact
      ansible.builtin.set_fact:
        final_web_url: "{{ prompt_web_url.user_input if prompt_web_url.user_input != '' else 'https://' + service_user_name + '.example.com' }}"

  tasks:
    - name: Get User UID
      ansible.builtin.command: "id -u {{ service_user_name }}"
      register: user_uid
      changed_when: false

    - name: Ensure target directories exist
      become: yes
      become_user: "{{ service_user_name }}"
      ansible.builtin.file:
        path: "{{ item }}"
        state: directory
        mode: '0755'
      loop:
        - "{{ service_user_home }}/notebooks"
        - "{{ service_user_home }}/envs"

    - name: Clone Carto-Lab repository
      become: yes
      become_user: "{{ service_user_name }}"
      ansible.builtin.git:
        repo: "{{ git_repo }}"
        dest: "{{ service_user_home }}/jupyterlab"
        recursive: yes
        version: master
        update: no

    - name: Create .env file with specific variables
      become: yes
      become_user: "{{ service_user_name }}"
      ansible.builtin.copy:
        dest: "{{ service_user_home }}/jupyterlab/.env"
        mode: '0644'
        content: |
          TAG=v1.1.0

          GIT_USER_NAME="{{ git_user_name }}"
          GIT_USER_EMAIL="{{ git_user_email }}"

          JUPYTER_PASSWORD={{ final_jupyter_password }}

          JUPYTER_NOTEBOOKS={{ service_user_home }}/notebooks
          JUPYTER_WEBPORT={{ jupyter_port }}

          JUPYTER_WEBURL={{ final_web_url }}

          CONDA_ENVS={{ service_user_home }}/envs

          COLLABORATIVE=true
          GENERATE_TOKEN=true
          JUPYTER_EXTRA_ARGS=--ServerApp.allow_origin='{{ final_web_url }}'

    - name: Ensure /srv/base exists
      ansible.builtin.file:
        path: /srv/base
        state: directory
        mode: '0755'

    - name: Ensure login_v2.html exists (prevents Docker mounting it as a folder)
      ansible.builtin.copy:
        dest: /srv/base/login_v2.html
        content: ""
        force: no
        mode: '0644'

    - name: Check for user SSH deploy key
      ansible.builtin.stat:
        path: "{{ service_user_home }}/.ssh/jupyter_deploy_key"
      register: deploy_key_stat

    - name: Check for user SSH config
      ansible.builtin.stat:
        path: "{{ service_user_home }}/.ssh/jupyter_ssh_config"
      register: ssh_config_stat

    - name: Create docker-compose.override.yml
      become: yes
      become_user: "{{ service_user_name }}"
      ansible.builtin.copy:
        dest: "{{ service_user_home }}/jupyterlab/docker-compose.override.yml"
        mode: '0644'
        content: |
          services:
            jupyterlab:
              restart: unless-stopped
              volumes:
                - /srv/base/login_v2.html:/etc/jupyter/templates/login.html:ro
          {% if deploy_key_stat.stat.exists %}
                - {{ service_user_home }}/.ssh/jupyter_deploy_key:/root/.ssh/id_ed25519:ro
          {% endif %}
          {% if ssh_config_stat.stat.exists %}
                - {{ service_user_home }}/.ssh/jupyter_ssh_config:/root/.ssh/config:ro
          {% endif %}
              environment:
                - JUPYTER_AUTOSHUTDOWN_TIMEOUT=0
                - GIT_USER_NAME=${GIT_USER_NAME:-Jupyter Container Bot}
                - GIT_USER_EMAIL=${GIT_USER_EMAIL:-bot@example.com}
            telegraf:
              image: telegraf:alpine
              container_name: telegraf-{{ service_user_name }}
              restart: always
              user: "0:0"
              entrypoint: ["telegraf"]
              volumes:
                - {{ service_user_home }}/telegraf.conf:/etc/telegraf/telegraf.conf:ro
                - /run/user/{{ user_uid.stdout }}/docker.sock:/var/run/docker.sock:ro
                - {{ service_user_home }}:/user_home:ro
              environment:
                - JUPYTER_USER={{ service_user_name }}
              networks:
                - lbsn-network

    - name: Create docker network
      become: yes
      become_user: "{{ service_user_name }}"
      ansible.builtin.shell: docker network create lbsn-network || true
      environment:
        DOCKER_HOST: "unix:///run/user/{{ user_uid.stdout }}/docker.sock"

    - name: Create Telegraf configuration securely outside of the Git repo
      become: yes
      become_user: "{{ service_user_name }}"
      ansible.builtin.copy:
        dest: "{{ service_user_home }}/telegraf.conf"
        mode: '0600' # Restricted permissions since it holds a token
        content: |
          [agent]
            skip_processors_after_aggregators = true

          [[outputs.influxdb_v2]]
            urls = ["{{ influxdb_url }}"]
            token = "{{ influxdb_token }}"
            organization = "{{ influxdb_org }}"
            bucket = "{{ influxdb_bucket }}"

          [[inputs.docker]]
            interval = "60s"
            endpoint = "unix:///var/run/docker.sock"
            [inputs.docker.tags]
              user = "${JUPYTER_USER}"

          [[inputs.exec]]
            interval = "60s"
            commands = ["/usr/bin/du -s /user_home/notebooks"]
            data_format = "grok"
            grok_patterns = ["%{NUMBER:size_bytes:int}\t%{PATH:directory}"]
            name_override = "home_dir_usage"
            [inputs.exec.tags]
              user = "${JUPYTER_USER}"

    - name: Pull and Start Docker compose
      become: yes
      become_user: "{{ service_user_name }}"
      ansible.builtin.shell: |
        docker compose pull
        docker compose up -d
      args:
        chdir: "{{ service_user_home }}/jupyterlab"
      environment:
        DOCKER_HOST: "unix:///run/user/{{ user_uid.stdout }}/docker.sock"

    - name: Create daily cron job for resetting Docker Compose
      become: yes
      ansible.builtin.copy:
        dest: "/etc/cron.daily/reset_jupyter_{{ service_user_name }}"
        owner: root
        group: root
        mode: '0755'
        content: |
          #!/bin/sh

          # --- CONFIGURATION ---
          USER="{{ service_user_name }}"
          WORK_DIR="/srv/$USER/jupyterlab"

          # --- CHECK IF ON HOLD ---
          if [ -f "/srv/$USER/.disabled" ]; then
              echo "User $USER is on hold (/srv/$USER/.disabled exists). Skipping reset."
              exit 0
          fi

          # --- RESTART AS TARGET USER IF RUNNING AS ROOT ---
          if [ "$(id -u)" -eq 0 ]; then
              exec sudo -H -u "$USER" "$0" "$@"
              echo "This line is never reached."
          fi

          echo "Running as user $(id -un)"

          # --- RESET CONTAINERS ---
          cd "$WORK_DIR" || exit 1
          docker compose down
          docker compose up -d

          # --- PRUNE DOCKER ONLY IF CONTAINERS ARE RUNNING ---
          if docker compose ps --filter "status=running" --quiet | grep -q .; then
              echo "Containers are running — pruning unused Docker objects."
              docker system prune -a -f
          else
              echo "No containers running — skipping prune."
          fi

    # --- Local Nginx Configuration (Jupyter VM) ---
    - name: Create Nginx Site configuration
      ansible.builtin.template:
        src: "nginx_jupyter.conf.j2"
        dest: "/etc/nginx/sites-available/{{ service_user_name }}.example.com.conf"

    - name: Enable Nginx configuration
      ansible.builtin.file:
        src: "/etc/nginx/sites-available/{{ service_user_name }}.example.com.conf"
        dest: "/etc/nginx/sites-enabled/{{ service_user_name }}.example.com.conf"
        state: link
      notify: Reload Nginx

  handlers:
    - name: Reload Nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
Nginx Template (nginx_jupyter.conf.j2)

# Top-level HTTP config for WebSocket headers
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# HTTP server to handle JupyterLab
server {
    listen 80;
    listen [::]:80;

    # Adapt to your domain structure
    server_name {{ service_user_name }}.example.com;

    access_log /var/log/nginx/{{ service_user_name }}.example.com-access.log;
    error_log /var/log/nginx/{{ service_user_name }}.example.com-error.log;

    client_max_body_size 100M;

    location / {
        # Security: allow only downstream nginx / reverse proxy
        # allow 192.168.1.50;
        # deny all;

        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # websocket headers
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header X-Scheme $scheme;

        proxy_buffering off;

        # send traffic to jupyter/docker
        proxy_pass http://127.0.0.1:{{ jupyter_port }};
    }
}

Step 3: Fleet Maintenance & Repairs (2.1_reconcile_cartolab.yml)

A self-healing maintenance playbook that scans all existing user accounts on a server, repairs directory binding issues, updates docker-compose.override.yml, and enforces container lifecycle states.

ansible-playbook -i inventories/hosts 2.1_reconcile_cartolab.yml \
    -l jupyter_server -K --vault-id shared@prompt

Managing Active vs. On-Hold Users

  • To put a user on hold: Create /srv/<user>/.disabled. Running the reconciliation playbook will gracefully stop their containers (docker compose down) and free internal ports.
  • To re-enable a user: Remove /srv/<user>/.disabled and run the reconciliation playbook.
Playbook (2.1_reconcile_cartolab.yml)

---
- name: Reconcile and fix all Carto-Lab user deployments
  hosts: all
  become: yes

  tasks:
    - name: Ensure /srv/base exists
      ansible.builtin.file:
        path: /srv/base
        state: directory
        mode: '0755'

    - name: Ensure login_v2.html template exists
      ansible.builtin.copy:
        dest: /srv/base/login_v2.html
        content: ""
        force: no
        mode: '0644'

    - name: Discover all user directories in /srv
      ansible.builtin.find:
        paths: /srv
        file_type: directory
        recurse: no
      register: srv_dirs

    - name: Filter directories containing a Carto-Lab deployment
      ansible.builtin.stat:
        path: "{{ item.path }}/jupyterlab"
      loop: "{{ srv_dirs.files }}"
      register: compose_checks

    - name: Build list of all Carto-Lab users
      ansible.builtin.set_fact:
        all_cartolab_users: "{{ compose_checks.results | selectattr('stat.exists') | map(attribute='item.path') | map('basename') | list }}"

    - name: Check for .disabled flag file in each user home
      ansible.builtin.stat:
        path: "/srv/{{ item }}/.disabled"
      loop: "{{ all_cartolab_users }}"
      register: disabled_checks

    - name: Separate active and disabled users
      ansible.builtin.set_fact:
        disabled_users: "{{ disabled_checks.results | selectattr('stat.exists') | map(attribute='item') | list }}"
        active_users: "{{ disabled_checks.results | rejectattr('stat.exists') | map(attribute='item') | list }}"

    - name: Gather user UIDs
      ansible.builtin.getent:
        database: passwd

    - name: Check for user SSH deploy keys
      ansible.builtin.stat:
        path: "/srv/{{ item }}/.ssh/jupyter_deploy_key"
      loop: "{{ all_cartolab_users }}"
      register: user_deploy_keys

    - name: Check for user SSH configs
      ansible.builtin.stat:
        path: "/srv/{{ item }}/.ssh/jupyter_ssh_config"
      loop: "{{ all_cartolab_users }}"
      register: user_ssh_configs

    - name: Map SSH key presence per user
      ansible.builtin.set_fact:
        user_deploy_key_map: "{{ user_deploy_key_map | default({}) | combine({item.item: item.stat.exists}) }}"
      loop: "{{ user_deploy_keys.results }}"

    - name: Map SSH config presence per user
      ansible.builtin.set_fact:
        user_ssh_config_map: "{{ user_ssh_config_map | default({}) | combine({item.item: item.stat.exists}) }}"
      loop: "{{ user_ssh_configs.results }}"

    - name: Check if telegraf.conf is mistakenly a directory
      ansible.builtin.stat:
        path: "/srv/{{ item }}/telegraf.conf"
      loop: "{{ all_cartolab_users }}"
      register: telegraf_conf_stats

    - name: Remove telegraf.conf if it was created as a directory
      ansible.builtin.file:
        path: "/srv/{{ item.item }}/telegraf.conf"
        state: absent
      loop: "{{ telegraf_conf_stats.results }}"
      when: item.stat.exists and item.stat.isdir

    - name: Ensure Telegraf configuration exists and is secured
      ansible.builtin.copy:
        dest: "/srv/{{ item }}/telegraf.conf"
        owner: "{{ item }}"
        group: "{{ item }}"
        mode: '0600'
        content: |
          [agent]
            skip_processors_after_aggregators = true

          [[outputs.influxdb_v2]]
            urls = ["{{ influxdb_url }}"]
            token = "{{ influxdb_token }}"
            organization = "{{ influxdb_org }}"
            bucket = "{{ influxdb_bucket }}"

          [[inputs.docker]]
            interval = "60s"
            endpoint = "unix:///var/run/docker.sock"
            [inputs.docker.tags]
              user = "${JUPYTER_USER}"

          [[inputs.exec]]
            interval = "60s"
            commands = ["/usr/bin/du -s /user_home/notebooks"]
            data_format = "grok"
            grok_patterns = ["%{NUMBER:size_bytes:int}\t%{PATH:directory}"]
            name_override = "home_dir_usage"
            [inputs.exec.tags]
              user = "${JUPYTER_USER}"
      loop: "{{ all_cartolab_users }}"

    - name: Update docker-compose.override.yml for all users
      ansible.builtin.copy:
        dest: "/srv/{{ item }}/jupyterlab/docker-compose.override.yml"
        owner: "{{ item }}"
        group: "{{ item }}"
        mode: '0644'
        content: |
          services:
            jupyterlab:
              restart: unless-stopped
              volumes:
                - /srv/base/login_v2.html:/etc/jupyter/templates/login.html:ro
          {% if user_deploy_key_map[item] %}
                - /srv/{{ item }}/.ssh/jupyter_deploy_key:/root/.ssh/id_ed25519:ro
          {% endif %}
          {% if user_ssh_config_map[item] %}
                - /srv/{{ item }}/.ssh/jupyter_ssh_config:/root/.ssh/config:ro
          {% endif %}
              environment:
                - JUPYTER_AUTOSHUTDOWN_TIMEOUT=0
                - GIT_USER_NAME=${GIT_USER_NAME:-Jupyter Container Bot}
                - GIT_USER_EMAIL=${GIT_USER_EMAIL:-bot@example.com}
            telegraf:
              image: telegraf:alpine
              container_name: telegraf-{{ item }}
              restart: always
              user: "0:0"
              entrypoint: ["telegraf"]
              volumes:
                - /srv/{{ item }}/telegraf.conf:/etc/telegraf/telegraf.conf:ro
                - /run/user/{{ ansible_facts.getent_passwd[item][1] }}/docker.sock:/var/run/docker.sock:ro
                - /srv/{{ item }}:/user_home:ro
              environment:
                - JUPYTER_USER={{ item }}
              networks:
                - lbsn-network
      loop: "{{ all_cartolab_users }}"

    - name: Ensure lbsn-network exists
      become: yes
      become_user: "{{ item }}"
      ansible.builtin.shell: docker network create lbsn-network || true
      environment:
        DOCKER_HOST: "unix:///run/user/{{ ansible_facts.getent_passwd[item][1] }}/docker.sock"
      loop: "{{ all_cartolab_users }}"

    - name: Recreate and start containers for ACTIVE users only
      become: yes
      become_user: "{{ item }}"
      ansible.builtin.shell: |
        docker compose up -d
      args:
        chdir: "/srv/{{ item }}/jupyterlab"
      environment:
        XDG_RUNTIME_DIR: "/run/user/{{ ansible_facts.getent_passwd[item][1] }}"
        DOCKER_HOST: "unix:///run/user/{{ ansible_facts.getent_passwd[item][1] }}/docker.sock"
      loop: "{{ active_users }}"

    - name: Ensure containers are STOPPED for DISABLED users
      become: yes
      become_user: "{{ item }}"
      ansible.builtin.shell: |
        docker compose down
      args:
        chdir: "/srv/{{ item }}/jupyterlab"
      environment:
        XDG_RUNTIME_DIR: "/run/user/{{ ansible_facts.getent_passwd[item][1] }}"
        DOCKER_HOST: "unix:///run/user/{{ ansible_facts.getent_passwd[item][1] }}/docker.sock"
      loop: "{{ disabled_users }}"

Step 4: Enabling SSH Access for Advanced Users

By default, accounts created by 1_setup_rootless_user.yml are locked system users without SSH access (admins interact via machinectl from host root).

When an advanced user is ready to self-manage their environment, execute these steps on the host VM (as root or using sudo) to unlock SSH public-key authentication:

1. Unlock Account with an Unusable Password Hash

Because OpenSSH and PAM treat accounts with locked passwords as inactive, you must replace the locked password entry with an unusable SHA-512 crypt hash. This unlocks public key authentication while keeping password authentication strictly disabled:

sudo usermod -p "$(openssl passwd -6 -salt $(openssl rand -hex 8) '!')" <username>

2. Configure SSH Access (AllowUsers)

If your SSH daemon restricts incoming logins using AllowUsers:

  1. Open the SSH daemon configuration:

    sudo nano /etc/ssh/sshd_config
    
  2. Append the new user to AllowUsers (space-separated):

    AllowUsers admin <username>
    
  3. Restart the SSH service:

    sudo systemctl restart ssh
    # (or: sudo systemctl restart sshd)
    
  4. Verify the active configuration:

    sudo sshd -T | grep -i allowusers
    

3. Install the User's Public Key & Set Strict Permissions

  1. Create the user's .ssh directory and add their public key:

    sudo mkdir -p /srv/<username>/.ssh
    sudo nano /srv/<username>/.ssh/authorized_keys
    
  2. Apply strict ownership and permissions:

    sudo chown -R <username>:<username> /srv/<username>/.ssh
    sudo chmod 755 /srv/<username>
    sudo chmod 700 /srv/<username>/.ssh
    sudo chmod 600 /srv/<username>/.ssh/authorized_keys
    

4. Optional: Fix MOTD APT Warning

If users see a warning like WARNING:root:could not open file '/etc/apt/sources.list.d/docker.list': Permission denied upon logging in, grant read permissions:

sudo chmod 644 /etc/apt/sources.list.d/docker.list

5. Verification & Troubleshooting

Test the connection from a client machine:

ssh -v <username>@<server-ip>

If multiple failed test connections trigger Fail2ban, unban your client IP on the host:

sudo fail2ban-client set sshd unbanip <your-client-ip>

What Advanced Users Can Do

Once connected via SSH to their rootless shell, users have full control over their own container namespace:

  • Manage Carto-Lab:

    cd ~/carto-lab
    docker compose ps
    docker compose restart
    
  • Deploy Companion Containers: Run dedicated databases (like PostGIS) or message queues on user-defined Docker bridge networks without needing sudo:

    docker run -d --name postgis -e POSTGRES_PASSWORD=secret -p 5432:5432 postgis/postgis
    
  • Code Remotely: Attach a local text editor or IDE using VS Code Remote Development.