Automated Debian 12 (Bookworm) development VM with Docker, Java 21, build tools, and SSH key authentication.
- Tart — virtual machine manager for Apple Silicon
- Cirrus Labs Debian Base Image — upstream Tart VM images
- Packer — automated machine image builds
- Packer Tart Plugin — Tart builder for Packer
- Ansible — infrastructure automation
- Eclipse Temurin — OpenJDK distribution
- GraalPy — GraalVM-based Python implementation
This project includes content generated with the assistance of artificial intelligence tools. Significant portions of the code, documentation, or other materials may have been created or refined using AI. While efforts have been made to review and validate all outputs, the accuracy and correctness of AI-generated content cannot be guaranteed. Users are encouraged to review and verify the code before use.
- Base: Debian 12 Bookworm (ARM64) from
ghcr.io/cirruslabs/debian:bookworm - Users:
admin+deploy(both with SSH key auth, sudo access) - Docker: Docker CE + Compose plugin (latest from official Debian repo)
- Java: OpenJDK (Adoptium/Temurin) at
/opt/jdk— version configured viajava_buildvariable - Build Tools: Gradle + Maven — versions configured via
gradle_versionandmaven_versionvariables - Python: GraalPy — version configured via
graalpy_versionvariable - CLI Tools: git, curl, vim, jq, htop, tree, unzip, build-essential
Customize versions: Edit variables in
build/ansible/playbook.yml(see Software Versions section)
Packer orchestrates the build: clones base image → boots VM → runs shell + Ansible → saves image
Ansible provisions via paramiko (password auth during build) then switches to key-only SSH
Variables control versions, paths, and SSH credentials — all customizable in build/vars.pkrvars.hcl
Install order: Docker → GraalPy → Java → Gradle → Maven
Directory sharing: Linux VMs use virtiofs — all shares are exposed under a single mount tag (com.apple.virtio-fs.automount) and must be manually mounted (not auto-mounted like macOS guests). Each --dir=NAME:PATH becomes a subdirectory under the mount point.
# 1. Prerequisites (macOS)
brew install hashicorp/tap/packer ansible
# 2. Build (run from project root)
./build/provision.shOr run the steps manually from the build/ directory:
cd build/
packer init debian-ssh.pkr.hcl
packer validate -var-file="vars.pkrvars.hcl" debian-ssh.pkr.hcl
packer build -var-file="vars.pkrvars.hcl" debian-ssh.pkr.hclBuild takes ~10-15 minutes. Result: local Tart image named debian-ssh
Edit build/vars.pkrvars.hcl:
vm_name = "my-dev-vm" # Change final image name
ssh_user = "admin" # Build-time SSH user
ssh_password = "admin" # Build-time password
ssh_key_path = "~/.ssh/id_ed25519.pub" # Your public key to injectEdit build/debian-ssh.pkr.hcl source block for resources:
source "tart-cli" "debian" {
cpu_count = 4 # Increase CPU cores
memory_gb = 8 # Increase RAM
# ...
}Edit build/ansible/playbook.yml vars section:
vars:
java_version: "21" # Java major version
java_build: "21.0.5+11" # Specific Adoptium build
gradle_version: "8.12" # Gradle version
maven_version: "3.9.9" # Maven version
graalpy_version: "25.0.2" # GraalPy versionChange versions, re-run packer build — idempotent (skips if already installed)
Map a local folder from your Mac to the VM using Tart's virtiofs sharing:
# Start VM with directory mounted (headless, no GUI)
./bin/run.sh # shares $PWD as hostshare
./bin/run.sh debian-ssh /path/to/dir # custom share directory
# Or run tart directly
tart run --no-graphics --dir=hostshare:$PWD debian-sshMount the shared directory inside the VM:
The shared directory is NOT auto-mounted in Debian. Tart exposes all --dir shares under a single virtiofs tag (com.apple.virtio-fs.automount). Each share name becomes a subdirectory under the mount point.
# SSH into the VM
ssh -i ~/.ssh/id_ed25519_tart admin@$(tart ip debian-ssh)
# Mount the virtiofs filesystem
sudo mkdir -p /mnt/shared
sudo mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
# Your files are in a subdirectory matching the share name
ls -la /mnt/shared/hostshare/Make it persistent across reboots (add to /etc/fstab):
# Inside the VM
echo "com.apple.virtio-fs.automount /mnt/shared virtiofs defaults,nofail 0 0" | sudo tee -a /etc/fstab
# Test the fstab entry
sudo mount -a
ls -la /mnt/shared/hostshare/Note: The
nofailoption allows the VM to boot normally even when started without--dir. The--dir=hostshare:...flag makes the share available to the VM; you must always pass it when starting the VM for the mount to work.
# Get VM IP
tart ip debian-ssh
# SSH with key (password auth disabled after build)
ssh -i ~/.ssh/id_ed25519_tart admin@192.168.64.X
# Or use deploy user
ssh -i ~/.ssh/id_ed25519_tart deploy@192.168.64.X
# Then simply:
ssh debian-sshAdd or update ~/.ssh/config automatically (resolves current VM IP):
./bin/update-ssh-config.shThe script creates a Host debian-ssh block in ~/.ssh/config if it doesn't exist, or updates the HostName if it does. Run it after each tart run since the VM IP may change.
ssh debian-dev
# First login — docker group not yet active
docker ps # permission denied
# Reboot or re-login
logout
ssh debian-dev
# Now docker works
docker ps
docker compose version- IntelliJ IDEA → File → Remote Development → SSH
- New Connection:
- Host:
192.168.64.X(fromtart ip debian-ssh) - Port:
22 - User:
adminordeploy - Authentication: Key Pair
- Private key:
~/.ssh/id_ed25519_tart
- Host:
- IDE Version: Select latest available
- Project Directory: Choose project on VM or create new
- Click Connect — IntelliJ downloads IDE backend to VM, opens remote session
Benefits:
- Full IDE runs locally, only project files on VM
- Low latency, UI feels native
- VM resources (Java, Maven, Gradle, Docker) available to IDE
# Start VM with project mounted (headless)
tart run --no-graphics --dir=project:/path/to/local/project debian-sshThen mount it inside the VM:
# SSH into VM
ssh -i ~/.ssh/id_ed25519_tart admin@$(tart ip debian-ssh)
# Mount all Tart shares
sudo mkdir -p /mnt/shared
sudo mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
# Project files are at /mnt/shared/project/
ls -la /mnt/shared/project/
# Make persistent (add to /etc/fstab)
echo "com.apple.virtio-fs.automount /mnt/shared virtiofs defaults,nofail 0 0" | sudo tee -a /etc/fstabThen configure IntelliJ to use remote SDK:
- File → Project Structure → SDKs → + → Add SSH SDK
- Configure SSH to
admin@192.168.64.X - Point to
/opt/jdk/jdk-21.0.5+11on the VM
Benefits:
- Files stay on host (easier backup, local git)
- Builds/tests run on VM resources
Port forwarding for web apps running on VM:
ssh -L 8080:localhost:8080 debian-dev
# Access VM's port 8080 at http://localhost:8080 on your Macrsync for fast bi-directional sync:
# Sync local → VM
rsync -avz --exclude='.git' /local/project/ debian-dev:/home/admin/project/
# Continuous watch sync (requires fswatch on Mac)
brew install fswatch
fswatch -o /local/project | xargs -n1 -I{} rsync -avz /local/project/ debian-dev:/home/admin/project/VS Code Remote SSH also works:
- Install Remote - SSH extension
- Connect to
debian-dev(via~/.ssh/config) - Open folder on VM
- Extensions install automatically on remote
If you see ansible_env.PATH warnings, ensure playbook uses:
environment:
PATH: "{{ ansible_facts['env']['PATH'] }}:{{ java_home }}/bin"Not ansible_env.PATH (deprecated in Ansible 2.24+)
Tools installed to /opt/* only in login shells. Use:
# Wrong (non-login)
ssh debian-dev 'java -version' # command not found
# Right (login shell)
ssh debian-dev 'bash -l -c "java -version"'
# Or just login interactively
ssh debian-dev
java -version # worksDocker group changes require logout/login or reboot:
ssh debian-dev
sudo reboot
# Wait ~30s, then reconnect
ssh debian-dev
docker ps # now works# List VMs
tart list
# Delete and rebuild
tart delete debian-ssh
./build/provision.shIf you started the VM with --dir=hostshare:... but can't see files:
# 1. Verify the virtiofs is supported
ssh debian-dev
cat /proc/filesystems | grep virtiofs
# Should show: nodev virtiofs
# 2. Check if already mounted
mount | grep virtiofs
findmnt -t virtiofs
# 3. If not mounted — use the fixed Tart tag (NOT the share name)
sudo mkdir -p /mnt/shared
sudo mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
# 4. Your files are in a subdirectory matching the --dir name
ls -la /mnt/shared/hostshare/
# 5. Make permanent with nofail (survives reboot, safe without --dir)
echo "com.apple.virtio-fs.automount /mnt/shared virtiofs defaults,nofail 0 0" | sudo tee -a /etc/fstabCommon issues:
- Wrong mount tag: Do NOT use the share name as the mount device. Tart uses a single fixed tag
com.apple.virtio-fs.automountfor all shares. - Missing
--dirflag: The--dir=NAME:PATHflag must be passed every time you start the VM withtart run. Without it, the virtiofs device doesn't exist and mounting fails. dmesgshowstag <name> not found: This confirms the wrong tag is being used — switch tocom.apple.virtio-fs.automount.
Software version variables — customize tool versions without editing tasks:
vars:
java_version: "21"
java_build: "21.0.5+11"
gradle_version: "8.12"
maven_version: "3.9.9"
graalpy_version: "25.0.2"Installation tasks (excerpt):
# Java — downloaded from Adoptium, not apt
- name: Download OpenJDK {{ java_version }}
get_url:
url: "{{ java_download_url }}"
dest: "/tmp/openjdk-{{ java_version }}.tar.gz"
when: not java_bin.stat.exists
# Gradle/Maven verification requires JAVA_HOME
- name: Verify Gradle
command: "{{ gradle_install_dir }}/gradle-{{ gradle_version }}/bin/gradle --version"
environment:
JAVA_HOME: "{{ java_home }}"
PATH: "{{ ansible_facts['env']['PATH'] }}:{{ java_home }}/bin"
# GraalPy uses dynamic directory detection
- name: Find actual GraalPy directory
find:
paths: "{{ graalpy_install_dir }}"
patterns: "graalpy*"
file_type: directory
register: graalpy_dirsKey implementation details:
- Java 21: Downloaded from Adoptium (Debian repos only have Java 17)
- Environment variables: Uses
ansible_facts['env']['PATH'](not deprecatedansible_env.PATH) - GraalPy: Installed before Java, uses
findmodule for directory detection - Docker: Official Debian bookworm repository
- SSH: Paramiko connection during build, key-only after
- Idempotency: All tasks check if already installed, safe to re-run
provisioner "ansible" {
playbook_file = "./ansible/playbook.yml"
use_proxy = false # Required for paramiko
extra_arguments = [
"--connection=paramiko", # Password auth during build
"--extra-vars", "admin_ssh_key_path=${var.ssh_key_path}"
]
}build/ # Image provisioning
debian-ssh.pkr.hcl # Packer template (plugins, source, provisioners)
vars.pkrvars.hcl # Variable values (VM name, SSH credentials)
provision.sh # One-command build script
ansible/ # Ansible provisioning
playbook.yml # Complete provisioning playbook
roles/ssh-setup/ # SSH hardening role (sshd_config)
bin/ # Runtime scripts
run.sh # Start VM with host folder share
update-ssh-config.sh # Add/update SSH config for VM
README.md # This file