Docker: The LaTeX Lover’s Best Friend
Ever found yourself going down the rabbit hole of installing a multitude of dependencies for a tool you’re uncertain is the right one? Or even worse, struggling to ensure all the dependencies are correct for a tool you certainly need?
Well, fret not! We’ve been through that arduous journey and discovered the ultimate solution: Docker.
Context
Imagine you want to compile some PDFs using LaTeX locally. You’d typically follow the instructions for the tool (TeX Live) based on your operating system.
But what if you hit a snag? Like, say, with the new ARM architecture on macOS making some programs a headache to compile. What then?
Enter Docker, our knight in shining armor. The game plan is to build a Docker image with all the necessary dependencies and run our program inside a container using a volume and a few other tricks. This way, it works on any OS, consistently.
We started by scouting for existing solutions and stumbled upon a few:
- https://hub.docker.com/r/blang/latex/tags
- https://hub.docker.com/r/kjarosh/latex/tags
- https://hub.docker.com/r/pandoc/latex/tags
The first one was a bit heavy and outdated. The second looked promising but wasn’t popular, focusing on CI/CD. The last one was just right—lightweight and maintained by pandoc, which adds a touch of class.
Here’s how I gave it a whirl:
$ docker run --rm -t pandoc/latex
^C%
NOTE: Including
--rm
is crucial to avoid unexpected errors. Once you’re comfortable with the command, you can consider removing--rm
to reuse the same container.
I found myself hitting C-c
to break off the process, just like an emergency
stop button! Why, you ask? A program had sprung into action and was eagerly
waiting for a stdin
input - like a dog sits, wagging its tail, waiting for a
“Great!“. The next step is to find the default command. For this, I used help
as an argument.
$ docker run --rm -t pandoc/latex help
[WARNING] Could not deduce format from file extension
Defaulting to markdown
pandoc: help: openBinaryFile: does not exist (No such file or directory)
Here’s a surprise - drumroll - guess which command pandoc was using as the
default? Yep, you guessed it. It was using the pandoc
command. I know,
mind-blowing!
So, I’m going to remove the entrypoint
next. And hey, bonus points if we
already have pdflatex
installed.
$ docker run --rm --entrypoint "" -t pandoc/latex /bin/sh -c "pdflatex --help"
Usage: pdftex [OPTION]... [TEXNAME[.tex]] [COMMANDS]
or: pdftex [OPTION]... \FIRST-LINE
or: pdftex [OPTION]... &FMT ARGS
Run pdfTeX on TEXNAME, usually creating TEXNAME.pdf.
...
Great! Everything is running smoothly, similar to a well-maintained machine. We need to tighten a few screws before we can fully use it.
Cooking our Dockerfile
The tweaks were simple: create a Docker image without an entrypoint to freely
use any binary inside the container, and install a LaTeX package called
texliveonfly
, which installs any missing packages on-the-fly during .tex
file compilation.
After a few iterations, we ended up with this Docker image:
# Alpine
FROM pandoc/latex
ENV DEBIAN_FRONTEND noninteractive
ENV TERM=xterm-256color
WORKDIR /data
# texlive needs python
ENV PYTHONUNBUFFERED=1
RUN apk add --update --no-cache python3 && ln -sf python3 /usr/bin/python
RUN python3 -m ensurepip && pip3 install --no-cache --upgrade pip setuptools
# extra packages
RUN tlmgr init-usertree && tlmgr install texliveonfly
# avoid zombie processes
# https://linuxconcept.com/reaping-a-zombie-inside-a-docker-container/
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
Here’s how I used it. First, I created a volume to the current directory
($PWD
), but there’s a catch: files created will be owned by the container’s
user, which is root
by default:
$ docker run --rm -v $PWD:/data -t mmngreco/dex pdflatex test.tex > /dev/null
$ ls -lah
total 132K
drwxrwxr-x 2 mgreco mgreco 4.0K Jan 20 19:02 .
drwxrwxr-x 5 mgreco mgreco 4.0K Jan 18 20:43 ..
-rw-r--r-- 1 mgreco mgreco 1.0K Jan 20 19:04 test.aux
-rw-r--r-- 1 mgreco mgreco 16K Jan 20 19:04 test.log
-rw-r--r-- 1 mgreco mgreco 395 Jan 20 19:04 test.out
-rw-r--r-- 1 root root 94K Jan 20 19:04 test.pdf
-rw-rw-r-- 1 mgreco mgreco 1.3K Jan 18 12:01 test.tex
To resolve the permission problem, we can provide our user and group id to
Docker by using the --user
parameter:
$ docker run --rm -t --user="$(id -u):$(id -g)" -v $PWD:/data mmngreco/dex
pdflatex test.tex > /dev/null
$ ls -lah
total 132K
drwxrwxr-x 2 mgreco mgreco 4.0K Jan 20 19:13 .
drwxrwxr-x 5 mgreco mgreco 4.0K Jan 18 20:43 ..
-rw-r--r-- 1 mgreco mgreco 1.0K Jan 20 19:13 test.aux
-rw-r--r-- 1 mgreco mgreco 16K Jan 20 19:13 test.log
-rw-r--r-- 1 mgreco mgreco 395 Jan 20 19:13 test.out
-rw-r--r-- 1 mgreco mgreco 94K Jan 20 19:13 test.pdf
-rw-rw-r-- 1 mgreco mgreco 1.3K Jan 18 12:01 test.tex
Now the PDF file has the correct permissions. We can also prevent Docker from
creating a network by adding the --net none
parameter:
$ docker run --rm -t --net none --user="$(id -u):$(id -g)" -v $PWD:/data
mmngreco/dex pdflatex test.tex > /dev/null
$ ls -lah
total 132K
drwxrwxr-x 2 mgreco mgreco 4.0K Jan 20 19:13 .
drwxrwxr-x 5 mgreco mgreco 4.0K Jan 18 20:43 ..
-rw-r--r-- 1 mgreco mgreco 1.0K Jan 20 19:13 test.aux
-rw-r--r-- 1 mgreco mgreco 16K Jan 20 19:13 test.log
-rw-r--r-- 1 mgreco mgreco 395 Jan 20 19:13 test.out
-rw-r--r-- 1 mgreco mgreco 94K Jan 20 19:13 test.pdf
-rw-rw-r-- 1 mgreco mgreco 1.3K Jan 18 12:01 test.tex
With our command ready, we need an easy way to use it without typing the whole thing every time we want to compile a LaTeX file. We can either create a shell alias or make an executable script that can be called like any other system command.
Creating an Alias
These days, bash and zsh are probably the most popular shells, but the process should be similar in others.
Depending on your shell, you’d modify .bashrc or .zshrc and add the following:
alias dex='docker run --rm -t --net none --user="$(id -u):$(id -g)" -v $PWD:/data mmngreco/dex'
After restarting the shell, you can use the alias:
$ dex pdflatex --help
...
The downside is that this alias is user-specific, not global. So, a program like VSCode won’t recognize dex unless it’s in the system path.
Creating an Executable
This is the most generic and recommended solution. Create a bash script with the command, add parameters, give it execution permissions, and move it to a system-accessible path like ~/.local/bin/.
Create a file named dex with the iterated command, appending $@ for user-provided parameters:
#!/usr/bin/env bash
docker run \
-t \
--rm \
--net none \
--user="$(id -u):$(id -g)" \
-v $PWD:/data \
mmngreco/dex $@
Give it execution permissions and move it to the PATH:
# execution permissions
sudo chmod +x dex
# move to path
mv dex ~/.local/bin/
If done correctly, our script should work:
$ dex pdflatex --help
...
Live Preview
At this stage, we have a docker image that is fully equipped to compile .tex
files. However, we can improve this by automating it from our preferred text
editor (neovim).
The end goal is to modify the .tex
file and have the editor compile it for
us, displaying the resulting PDF.
In the following examples, we are using the
dex
executable only, as it’s general enough for the purpose of this section.
VSCode
In VSCode, we achieved this using the [LaTeX-Workshop extension][tex-ext]. Then, add the following user configuration to settings.json (Ctrl+Shift+P and search for “settings json”):
Include this in the JSON:
"latex-workshop.latex.outDir": "./",
"latex-workshop.view.pdf.viewer": "tab",
"latex-workshop.synctex.afterBuild.enabled": true,
"latex-workshop.latex.recipes": [
{
"name": "dex",
"tools": [
"pdflatex"
]
},
],
"latex-workshop.latex.tools": [
{
"name": "pdflatex",
"command": "dex",
"args": [
"pdflatex",
"%DOC%",
"-output-directory=%OUTDIR%"
],
"env": {"PWD": "%OUTDIR%"}
},
{
"name": "bibtex",
"command": "dex",
"args": [
"bibtex",
"%DOCFILE%"
],
"env": {"PWD": "%OUTDIR%"}
}
],
After that, you should be able to use the extension and see a PDF preview in a new tab.
Vim/Neovim
For Neovim users such as myself, here’s a setup you can utilize. Feel free to adjust it according to your needs. Please note, this is just a small sample.
" vimscript
augroup Latex
au!
au BufWritePost *.tex silent !dex pdflatex % && firefox %:t:r.pdf
augroup end
Or in Lua:
-- lua
vim.cmd[[
augroup Latex
au!
au BufWritePost *.tex silent !dex pdflatex % && firefox %:t:r.pdf
augroup end
]]
Final Thoughts
Take note, this solution is like a swiss army knife, it can work for almost every Command Line Interface (CLI) program out there. It’s a versatile cross-platform solution you can rely on.
In this context, we are wrapping LaTeX, but truth be told, this approach can be applied to almost any python program too; be it an app, a service, or a CLI. The beauty of it all is that the underlying principles remain the same.
Based on your goals, you may need to invest more time into security or be a little more mindful of the final size. It’s like cooking, you always adjust the spices to your taste, right?