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