Skip to content

Instantly share code, notes, and snippets.

@Incipiens
Created November 25, 2025 13:51
Show Gist options
  • Select an option

  • Save Incipiens/9817edf5c8bba53ef27bb705ce1cd89f to your computer and use it in GitHub Desktop.

Select an option

Save Incipiens/9817edf5c8bba53ef27bb705ce1cd89f to your computer and use it in GitHub Desktop.
#!/bin/sh
# Proxmox VE 9.1 - LanguageTool LXC creator and installer
# Written by Adam Conway 2025
set -u
# ============================================================================
# COLORS & OUTPUT HELPERS
# ============================================================================
GN="$(printf '\033[0;32m')" YW="$(printf '\033[0;33m')" CY="$(printf '\033[0;36m')"
RD="$(printf '\033[0;31m')" BD="$(printf '\033[1m')" CL="$(printf '\033[0m')"
OK="$(printf '\033[0;32m✔\033[0m')"
msg() { printf "%s\n" "$*"; }
info() { msg "${CY}[INFO]${CL} $*"; }
warn() { msg "${YW}[WARN]${CL} $*"; }
err() { msg "${RD}[ERROR]${CL} $*"; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || err "Missing command: $1"; }
# ============================================================================
# PREREQUISITE CHECKS
# ============================================================================
need pveversion
need pvesm
need pct
need pveam
need pvesh
need awk
need sed
# ============================================================================
# USER CONFIRMATION
# ============================================================================
printf "This will create a new LanguageTool LXC Container. Proceed (y/n)? "
read yn || exit 1
case "${yn:-}" in y|Y) : ;; n|N) exit 0 ;; *) err "Please answer y or n." ;; esac
# ============================================================================
# TEMP FILE SETUP
# ============================================================================
TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT INT TERM
SETUP="$TMPDIR/languagetool_setup.sh"
STFILE="$TMPDIR/stor.txt"
# ============================================================================
# STORAGE SELECTION
# ============================================================================
info "Scanning storage pools…"
pvesm status 2>/dev/null | awk 'NR>1' > "$STFILE"
[ -s "$STFILE" ] || err "No storages found. Add one in Datacenter → Storage."
FIRST_STORAGE=""
LINES="$(wc -l < "$STFILE" | tr -d ' ')"
while IFS= read -r line; do
set -- $line
tag="$1"; stype="$2"; freek="$6"
[ -n "$FIRST_STORAGE" ] || FIRST_STORAGE="$tag"
if [ -n "${freek:-}" ] && command -v numfmt >/dev/null 2>&1; then
freehr="$(printf "%s" "$freek" | numfmt --from=K --to=iec --format %.2f 2>/dev/null)B"
else
freehr="${freek:-?}KB"
fi
msg " - ${BD}$tag${CL} (type: $stype, free: $freehr)"
done < "$STFILE"
if [ "$LINES" -gt 1 ]; then
printf "Enter storage to use [%s]: " "$FIRST_STORAGE"
read STORAGE || exit 1
[ -n "${STORAGE:-}" ] || STORAGE="$FIRST_STORAGE"
else
STORAGE="$FIRST_STORAGE"
fi
info "Using storage: '$STORAGE'"
# Determine storage type
STYPE="$(pvesm status | awk -v S="$STORAGE" 'NR>1 && $1==S {print $2}')"
[ -n "$STYPE" ] || err "Could not determine type for storage '$STORAGE'."
# ============================================================================
# KERNEL MODULE (optional overlay for nesting)
# ============================================================================
if ! lsmod | grep -Fq overlay 2>/dev/null; then
modprobe overlay 2>/dev/null || true
if ! grep -Fxq overlay /etc/modules 2>/dev/null; then
printf "overlay\n" >> /etc/modules 2>/dev/null || true
fi
fi
# ============================================================================
# CONTAINER ID
# ============================================================================
CTID="$(pvesh get /cluster/nextid)" || err "Could not obtain next CTID."
info "Container ID: $CTID"
# ============================================================================
# TEMPLATE DOWNLOAD & PREPARATION
# ============================================================================
CACHE_DIR="/var/lib/vz/template/cache"
msg "${OK} ${BD}Updating LXC template list…${CL}"
pveam update >/dev/null 2>&1 || true
# Prefer Debian 12; accept zst/xz/tar
TEMPLATE="$(pveam available 2>/dev/null \
| awk '/debian-12-standard_.*_amd64\.tar\.(zst|xz)|debian-12-standard_.*_amd64\.tar$/ {print $2}' \
| sort -t_ -k2V | tail -n 1)"
[ -n "$TEMPLATE" ] || err "No Debian 12 template found."
msg "${OK} ${BD}Downloading LXC template (${TEMPLATE})…${CL}"
pveam download local "$TEMPLATE" >/dev/null || err "Template download failed."
# Convert compressed templates to plain .tar
DL_PATH="${CACHE_DIR}/${TEMPLATE}"
case "$DL_PATH" in
*.tar.zst)
PLAIN_TAR="${DL_PATH%.zst}"
msg "${OK} ${BD}Converting .zst → .tar…${CL}"
zstd -d -c "$DL_PATH" > "$PLAIN_TAR" || err "zstd decompress failed"
rm -f "$DL_PATH"
TFILE="$PLAIN_TAR"
;;
*.tar.xz)
PLAIN_TAR="${DL_PATH%.xz}"
msg "${OK} ${BD}Converting .xz → .tar…${CL}"
xz -dc "$DL_PATH" > "$PLAIN_TAR" || err "xz decompress failed"
rm -f "$DL_PATH"
TFILE="$PLAIN_TAR"
;;
*.tar)
TFILE="$DL_PATH"
;;
*)
err "Unknown template extension: $DL_PATH"
;;
esac
# Verify the tar is readable
tar -tf "$TFILE" >/dev/null || err "Resulting tar is unreadable: $TFILE"
# ============================================================================
# DISK ALLOCATION & FORMATTING
# ============================================================================
# Helper function: choose next free disk index for this CTID
next_disk_name() {
_ctid="$1"; _storage="$2"
i=0
while :; do
cand="vm-${_ctid}-disk-${i}"
if ! pvesm list "$_storage" --vmid "$_ctid" 2>/dev/null | awk '{print $1}' | grep -q "^${_storage}:${cand}\$"; then
printf "%s" "$cand"; return 0
fi
i=$((i+1))
done
}
DISK_SIZE="64G"
DISK_NAME="$(next_disk_name "$CTID" "$STORAGE")"
ARCH="$(dpkg --print-architecture)"
HOSTNAME="languagetool"
case "$STYPE" in
lvmthin|lvm)
msg "${OK} ${BD}Allocating ${STYPE} volume ${DISK_NAME} (${DISK_SIZE}) on ${STORAGE}${CL}"
pvesm alloc "$STORAGE" "$CTID" "$DISK_NAME" "$DISK_SIZE" >/dev/null \
|| err "pvesm alloc failed on $STORAGE"
# Format the LV before pct create (prevents mount failures)
DEV_PATH="$(pvesm path "${STORAGE}:${DISK_NAME}")"
[ -n "$DEV_PATH" ] || err "Failed to resolve device path for ${STORAGE}:${DISK_NAME}"
lvchange -ay "$DEV_PATH" >/dev/null 2>&1 || true
msg "${OK} ${BD}Formatting ${DEV_PATH} as ext4 (root_owner=100000:100000)…${CL}"
mkfs.ext4 -F -E root_owner=100000:100000 "$DEV_PATH" >/dev/null 2>&1 \
|| err "mkfs.ext4 failed on ${DEV_PATH}"
ROOTFS_SPEC="${STORAGE}:${DISK_NAME}"
;;
dir|nfs)
DISK_FILE="${DISK_NAME}.raw"
msg "${OK} ${BD}Allocating file image ${DISK_FILE} (${DISK_SIZE}) on ${STORAGE}${CL}"
pvesm alloc "$STORAGE" "$CTID" "$DISK_FILE" "$DISK_SIZE" --format raw >/dev/null \
|| err "pvesm alloc failed on $STORAGE"
IMG_PATH="$(pvesm path "${STORAGE}:${CTID}/${DISK_FILE}")"
[ -n "$IMG_PATH" ] || err "Failed to resolve image path"
msg "${OK} ${BD}Formatting ${IMG_PATH} as ext4…${CL}"
mkfs.ext4 -F "$IMG_PATH" >/dev/null 2>&1 || err "mkfs.ext4 failed"
ROOTFS_SPEC="${STORAGE}:${CTID}/${DISK_FILE}"
;;
zfspool|btrfs)
msg "${OK} ${BD}Allocating ${STYPE} subvol ${DISK_NAME} (${DISK_SIZE}) on ${STORAGE}${CL}"
pvesm alloc "$STORAGE" "$CTID" "$DISK_NAME" "$DISK_SIZE" >/dev/null \
|| err "pvesm alloc failed on $STORAGE"
ROOTFS_SPEC="${STORAGE}:${DISK_NAME}"
;;
*)
err "Unsupported storage type '$STYPE'."
;;
esac
# ============================================================================
# CONTAINER CREATION
# ============================================================================
msg "${OK} ${BD}Creating LXC container…${CL}"
pct create "$CTID" "$TFILE" \
-arch "$ARCH" \
-features nesting=1 \
-hostname "$HOSTNAME" \
-net0 name=eth0,bridge=vmbr0,ip=dhcp \
-onboot 1 \
-cores 2 \
-memory 4096 \
-unprivileged 1 \
-rootfs "$ROOTFS_SPEC" >/dev/null || err "pct create failed."
# ============================================================================
# TIMEZONE SYNC
# ============================================================================
MOUNT_LINE="$(pct mount "$CTID" 2>/dev/null || true)"
MOUNT_DIR="$(printf "%s" "$MOUNT_LINE" | awk -F"'" '/mounted/ {print $2}')"
if [ -n "$MOUNT_DIR" ] && [ -e /etc/localtime ]; then
ln -fs "$(readlink /etc/localtime)" "$MOUNT_DIR/etc/localtime" 2>/dev/null || true
pct unmount "$CTID" >/dev/null 2>&1 || true
fi
# ============================================================================
# START CONTAINER
# ============================================================================
msg "${OK} ${BD}Starting LXC container…${CL}"
pct start "$CTID" >/dev/null || err "pct start failed."
# ============================================================================
# IN-CONTAINER SETUP SCRIPT
# ============================================================================
cat > "$SETUP" <<'EOF'
#!/bin/sh
set -u
OK="$(printf '\033[0;32m✔\033[0m')"; BD="$(printf '\033[1m')"; CL="$(printf '\033[0m')"
msg(){ printf "%s\n" "$*"; }
# --- Locale setup ---
msg "${OK} ${BD}Setting up container OS…${CL}"
if ! grep -q '^en_US\.UTF-8 UTF-8' /etc/locale.gen 2>/dev/null; then
if grep -q '^# *en_US\.UTF-8 UTF-8' /etc/locale.gen 2>/dev/null; then
sed -i 's/^# *en_US\.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen || true
else
printf "en_US.UTF-8 UTF-8\n" >> /etc/locale.gen || true
fi
fi
locale-gen >/dev/null 2>&1 || true
update-locale LANG=en_US.UTF-8 >/dev/null 2>&1 || true
# --- System updates ---
apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive apt-get -y -qq upgrade || true
# --- Install prerequisites ---
msg "${OK} ${BD}Installing prerequisites…${CL}"
DEBIAN_FRONTEND=noninteractive apt-get -y -qq install \
default-jre-headless unzip wget ca-certificates apt-transport-https \
hunspell hunspell-de-de hunspell-en-us || exit 1
# --- Install LanguageTool ---
msg "${OK} ${BD}Installing LanguageTool Server…${CL}"
cd /tmp || exit 1
wget -q https://languagetool.org/download/LanguageTool-stable.zip || exit 1
unzip -q LanguageTool-stable.zip || exit 1
rm -rf /opt/LanguageTool
mv LanguageTool-*.*/ /opt/LanguageTool || exit 1
rm -f LanguageTool-stable.zip
# --- Download ngrams ---
# These are the best ngrams available, run from an SSD or with enough RAM, otherwise it'll be slow
mkdir -p /opt/LanguageTool/ngrams
wget -q https://languagetool.org/download/ngram-data/ngrams-de-20150819.zip
wget -q https://languagetool.org/download/ngram-data/ngrams-en-20150817.zip
unzip -q ngrams-de-20150819.zip -d /opt/LanguageTool/ngrams
unzip -q ngrams-en-20150817.zip -d /opt/LanguageTool/ngrams
rm -f ngrams-de-20150819.zip ngrams-en-20150817.zip
# --- Configure LanguageTool ---
cat > /opt/LanguageTool/languagetool.cfg <<'CFG'
languageModel=/opt/LanguageTool/ngrams
CFG
# --- Create systemd service ---
cat > /etc/systemd/system/languagetool.service <<'SVC'
[Unit]
Description=LanguageTool HTTP server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=JAVA_OPTS=-Xms512m -Xmx3g
WorkingDirectory=/opt/LanguageTool
ExecStart=/usr/bin/java $JAVA_OPTS -cp /opt/LanguageTool/languagetool-server.jar org.languagetool.server.HTTPServer --public --port 8081 --allow-origin "*" --config /opt/LanguageTool/languagetool.cfg
User=nobody
Group=nogroup
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
SVC
systemctl daemon-reload
systemctl enable --now languagetool
# --- Quiet login banner ---
rm -f /etc/motd /etc/update-motd.d/10-uname 2>/dev/null || true
touch /root/.hushlogin
# --- Autologin on console (cover both common getty units) ---
for UNIT in "container-getty@1.service" "getty@tty1.service"; do
DIR="/etc/systemd/system/${UNIT}.d"
mkdir -p "$DIR"
cat > "${DIR}/override.conf" <<'OVR'
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin root --noclear --keep-baud %I 115200,38400,9600 $TERM
OVR
done
# Ensure agetty path exists (fallback if not in /sbin)
if [ ! -x /sbin/agetty ] && command -v agetty >/dev/null 2>&1; then
AGETTY_PATH="$(command -v agetty | sed 's|/|\\/|g')"
sed -i "s|/sbin/agetty|${AGETTY_PATH}|g" \
/etc/systemd/system/container-getty@1.service.d/override.conf \
/etc/systemd/system/getty@tty1.service.d/override.conf 2>/dev/null || true
fi
systemctl daemon-reload
systemctl restart container-getty@1.service 2>/dev/null || true
systemctl restart getty@tty1.service 2>/dev/null || true
EOF
# ============================================================================
# EXECUTE IN-CONTAINER SETUP
# ============================================================================
chmod 755 "$SETUP"
pct push "$CTID" "$SETUP" /languagetool_setup.sh -perms 755 >/dev/null || err "pct push failed."
pct exec "$CTID" -- sh /languagetool_setup.sh || err "In-container setup failed."
# ============================================================================
# COMPLETION & IP DISPLAY
# ============================================================================
IP="$(pct exec "$CTID" -- sh -lc "ip -o -4 addr show dev eth0 2>/dev/null | awk '{print \$4}' | cut -d/ -f1" | tr -d '\r' || true)"
if [ -n "${IP:-}" ]; then
info "LanguageTool container $CTID is up at: http://$IP:8081/v2"
else
warn "Could not detect IP on eth0. Check DHCP/bridge config."
fi
msg "${OK} ${BD}All done!${CL}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment