Introduction

If you are like me and prefer to develop in a complete TUI environment with neovim, you may encounter a situation where you need to reconfigure your development environment every time you face a new OS. You may need to install packages, copy dotfiles and do some other stuffs over and over again, which is really annoying.

Docker, which can provide OS-level virtualization, may be able to provide a solution to this problem.

In this post, I will briefly describe how to build a docker image for your dotfiles and explain some of the problems I encountered and solutions.

Getting Started

Let’s first install docker and run a linux container.

To install docker, you can either download the installer from the official website or use your package manager to install it.

If you installed docker with a package manager on linux, you may also need to launch docker daemon via this command before any operation:

1
$ sudo systemctl start docker.service

Then we can run a linux container like so:

1
$ docker run -it opensuse/tumbleweed:latest

-i option means we will launch the container in interactive mode, and -t option will allocate a pseudo-tty.

We use opensuse/tumbleweed:latest here, this is a tag for the latest build of openSUSE Tumbleweed image.

This command will first check if this image exists on local machine, if not, docker will pull it from docker hub and then launch it.

I choose openSUSE Tumbleweed here, this is a rolling release distro with support for multiple architectures. You can choose other distros based on your preference.

Now let’s write a Dockerfile to build our own image.

In Dockerfile:

1
2
3
4
5
6
# syntax=docker/dockerfile:experimental

FROM opensuse/tumbleweed:latest
RUN zypper ref && zypper up -y
RUN zypper in -y git
RUN zypper clean --all

The FROM command means our image is based on opensuse/tumbleweed:latest, and the RUN command is the shell command to be executed in openSUSE Tumbleweed.

OpenSUSE Tumbleweed uses zypper package manager. The zypper ref command will refresh the package databases, and the zypper up -y command will update all outdated packages without confirmation.

The zypper in -y git command will install git without confirmation.

The zypper clean --all command will clean all zypper caches.

Then we run this command in terminal to build our docker image:

1
$ docker build -t sainnhe/dotfiles .

-t sainnhe/dotfiles is the tag of this image, . is the directory where Dockerfile is stored.

Now let’s launch a container based on our image via:

1
$ docker run -it sainnhe/dotfiles

You can then clone your dotfiles repository, copy dotfiles, install some other packages via RUN command.

But if you want to access files on your local machine in a container, you will need -v option to specify the directory to be mounted.

First, let create a directory in our image:

1
RUN mkdir /root/work

Then rebuild the image and launch a container like this:

1
$ docker run -v <workdir-on-local-machine>:/root/work -it sainnhe/dotfiles

Where <workdir-on-local-machine> is the path of the directory you want to access on your local machine.

This directory will be mounted to /root/work so you can access it through this path in docker.

Some Problems and Solutions

All shell commands passed to RUN command should be non-interactive, that’s why we need to pass -y option to zypper command.

Copying dotfiles and installing packages in non-interactive mode doesn’t have many problems, but when it comes to install vim plugins and zsh plugins, many problems arise.

Here I’ll provide my solutions on how to do these things in non-interactive mode.

The complete Dockerfile for my dotfiles can be found here.

Install Zsh Plugins

I use zdharma-continuum/zinit to manage my zsh plugins, this is a super fast zsh plugin manager (benchmarks).

After installing zsh and other dependencies via zypper, I use this command to install zsh plugins:

1
2
3
SHELL ["/usr/bin/zsh", "-c"]
RUN source ~/.zshrc
RUN zsh -i -c -- 'zinit module build; @zinit-scheduler burst || true '

Install Tmux Plugins

First, install tmux and other dependencies via zypper, copy .tmux.conf to $HOME and install tpm via:

1
RUN git clone --depth=1 https://github.com/tmux-plugins/tpm.git ~/.tmux/plugins/tpm

Then install plugins via:

1
2
3
4
5
6
RUN \
        tmux start-server && \
        tmux new-session -d && \
        sleep 1 && \
        ~/.tmux/plugins/tpm/scripts/install_plugins.sh && \
        tmux kill-server

Install Vim Plugins

I use vim-plug to manage my plugins.

1
2
3
4
RUN \
        nvim -es --cmd 'call custom#plug#install()' --cmd 'qa' && \
        nvim --headless +PlugInstall +qall && \
        nvim --headless +"helptags ALL" +qall

Where custom#plug#install() is a function to install vim-plug, replace this with your own command instead.

Install Coc Extensions

This hack is a bit dirty.

First of all, I get a full list of coc extensions via grep and sed:

1
2
3
$ cat ~/.config/nvim/features/full.vim |\
    grep "\\\ 'coc-" |\
    sed -E -e 's/^.*coc//' -e "s/',//" -e 's/^/coc/'

The final output of these commands is something like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
coc-calc
coc-clangd
coc-cmake
coc-css
coc-diagnostic
coc-dictionary
coc-docker
coc-emmet
coc-emoji
coc-explorer
coc-git
coc-gitignore
coc-highlight
coc-html
coc-htmlhint
coc-imselect
coc-json
coc-julia
coc-lists
coc-lua
coc-markdown-preview-enhanced
coc-markdownlint
coc-marketplace
coc-prettier
coc-project
coc-pyright
coc-rust-analyzer
coc-sh
coc-snippets
coc-spell-checker
coc-sql
coc-syntax
coc-tag
coc-terminal
coc-texlab
coc-toml
coc-tsserver
coc-vimlsp
coc-webview
coc-xml
coc-yaml
coc-yank

Then use pipeline to pass the output to xargs and install the extensions via npm:

1
2
3
4
$ cat ~/.config/nvim/features/full.vim |\
    grep "\\\ 'coc-" |\
    sed -E -e 's/^.*coc//' -e "s/',//" -e 's/^/coc/' |\
    xargs -I{} npm install --ignore-scripts --no-lockfile --production --no-global --legacy-peer-deps {}; exit 0

But the dependencies of coc extensions will not be installed, we need to use another command to install their dependencies respectively:

1
2
3
4
$ cat ~/.config/nvim/features/full.vim |\
    grep "\\\ 'coc-" |\
    sed -E -e 's/^.*coc//' -e "s/',//" -e 's/^/coc/' |\
    xargs -I{} sh -c "cd ~/.local/share/nvim/coc/extensions/node_modules/{}; npm install --ignore-scripts --no-lockfile --production --no-global --legacy-peer-deps"; exit 0

So the complete docker command should be like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
RUN \
        mkdir -p ~/.local/share/nvim/coc/extensions && \
        cd ~/.local/share/nvim/coc/extensions && \
        cat ~/.config/nvim/features/full.vim |\
        grep "\\\ 'coc-" |\
        sed -E -e 's/^.*coc//' -e "s/',//" -e 's/^/coc/' |\
        xargs -I{} npm install --ignore-scripts --no-lockfile --production --no-global --legacy-peer-deps {}; exit 0
RUN \
        cat ~/.config/nvim/features/full.vim |\
        grep "\\\ 'coc-" |\
        sed -E -e 's/^.*coc//' -e "s/',//" -e 's/^/coc/' |\
        xargs -I{} sh -c "cd ~/.local/share/nvim/coc/extensions/node_modules/{}; npm install --ignore-scripts --no-lockfile --production --no-global --legacy-peer-deps"; exit 0

GitHub Action

Instead of manually build and push docker image, I prefer to use github action to build and push on schedule.

First, we need to create a repository in docker hub sainnhe/dotfiles, then create a personal access token and add it to repository secrets.

My github workflow is like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
name: Build and Push Docker Image

on:
  push:
    paths:
      - 'Dockerfile'
  schedule:
    - cron: "0 0 */3 * *"

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - name: Set up QEMU
        uses: docker/[email protected]
      - name: Set up Docker Buildx
        uses: docker/[email protected]
      - name: Login to DockerHub
        uses: docker/[email protected]
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Build and push
        uses: docker/[email protected]
        with:
          push: true
          tags: sainnhe/dotfiles:latest