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 and isolated Carto-Lab (JupyterLab) instances for individual users.

This automation handles tasks such as subuid/subgid mappings, systemd lingering, dynamic reverse-proxy configuration via Nginx, and daily auto-reset cron jobs.

Directory Structure

You can find the Ansible playbooks in the ansible/ folder of the Carto-Lab repository. Set up your control machine to match this structure:

ansible/
├── 1_setup_rootless_user.yml
├── 2_setup_cartolab.yml
├── group_vars
│   └── all
│       └── vault.yml
├── inventories
│   └── hosts
├── nginx_jupyter.conf.j2
└── README.md

Prerequisites

Install Ansible on your control machine:

sudo apt-get update
sudo apt-get install ansible

Configure your target hosts: Define your target VM in the inventories/hosts file. Replace the IP and user with your actual server details:

[debian]
jupyter_server ansible_host=192.168.1.100 ansible_user=admin

Configure Ansible Vault (Recommended): To avoid typing sensitive tokens manually, save them in an encrypted vault:

mkdir -p group_vars/all
EDITOR=nano ansible-vault create group_vars/all/vault.yml

Add your variables (e.g., influxdb_token, influxdb_url) inside this file.


Step 1: Set up the Rootless Docker User

This playbook creates a dedicated system user, configures their sub-namespace mappings, installs Rootless Docker, and starts the user-level systemd daemon.

ansible-playbook -i inventories/hosts 1_setup_rootless_user.yml -K --ask-vault-pass

Playbook Source: 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
          cd ~/jupyterlab
          if [[ $- == *i* ]] && [ -d "$HOME/jupyterlab" ]; then
              cd ~/jupyterlab
          fi

Step 2: Deploy the Carto-Lab Environment

This playbook clones the Carto-Lab repository, generates the .env file, sets up local Nginx configurations for reverse proxying and WebSockets, configures Telegraf, and spins up the Jupyter containers.

ansible-playbook -i inventories/hosts 2_setup_cartolab.yml -K --ask-vault-pass

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

Playbook Source: 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://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-latest
        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: 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:
              image: quay.io/ioer-fdz/carto-lab-docker:${TAG:-latest}
              volumes:
                - /srv/base/login_v2.html:/etc/jupyter/templates/login.html:ro
              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"

          # --- 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 Source: nginx_jupyter.conf.j2

The second playbook automatically maps the user's container port to an Nginx configuration file for reverse proxying:

# 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 }};
    }
}