Dockerizing My IDE part 1: Neovim, Zsh, and GitHub Actions

Why I am building this?

I embarked on this project with a clear vision: to create a consistent and highly portable development environment that I could seamlessly replicate across various machines. I want to eliminate the need to clutter my local system with numerous dependencies just to test a language or framework once.. Moreover, as I'm transitioning to Neovim as my primary editor, I desire a portable development setup with a fully configured Neovim environment, complete with my preferred plugins and settings, ensuring a consistent coding experience regardless of the machine I'm using, especially important when I am getting used to Vim with it’s all shortcuts, commands and all other things to remember. Additionally, I am aiming to have the flexibility to work with multiple programming languages without encountering dependency conflicts, achieving this by running each language within its own isolated Docker container. I hope this approach will not only streamline my workflow but also will provide a safe and controlled environment for experimentation and development.

Project Setup: Laying the Foundation

I started with creating empty repo in GitHub with only Read.me file and created it's structure. Crucial to this setup were the creation of empty directories named 'neovim' and 'zsh'. These directories are intended to house my respective configuration files, which I plan to populate and synchronize automatically using a Makefile. This automation ensures that any changes made to my local configurations are seamlessly reflected in the repository, and subsequently, can be easily applied across different machines. Following this, I established an empty Makefile and an empty Dockerfile. The Makefile serves the already described consistency between my local configuration files and those within the repository. It facilitates a streamlined synchronization process, enabling me to propagate changes across my development environments efficiently. Conversely, the Dockerfile is designed to define and build the container image that will host my editor, Neovim. I intend to leverage mounted volumes, allowing me to edit files directly from my local machine within the container's environment. I bet this setup will provide the flexibility to clone repositories and immediately begin editing them within a consistent and isolated container environment.
After this step my folder tree looked like this:

container-nvim-ide/
├── Dockerfile
├── Makefile
├── neovim/
└── zsh/

Makefile: Automating Configuration Sync

To streamline the synchronization between my local configuration directories and the remote GitHub repository, I found Makefile to be an ideal solution. Within my implementation, the Makefile is structured into three primary segments.

#setting configuration commands for config folders
SYNC_NEOVIM    = cp -r ~/.config/nvim/* neovim/
SYNC_ZSH = cp ~/.zshrc zsh/

#syncing local configuration to repository
sync-to-repo:
    $(SYNC_NEOVIM)
    $(SYNC_ZSH)
    git add neovim/ zsh/
    git commit -m "zsh and neovim config sync"
    git push

#grtting configuration from repository and applying it to local machine
sync-from-repo:
    cp neovim/* ~/.config/nvim/
    cp zsh/* ~/.zshrc/
  • Variable (Macro) Definitions:

    I begin by defining variables (or macros, to be more precise) that encapsulate the commands necessary for specific tasks. These include copying the Neovim configuration to its designated repository directory and similarly, copying the Zsh configuration.

    It's crucial to note the inclusion of the '-r' parameter for the 'cp' command, ensuring recursive copying of the '~/.config/nvim/' directory, which contains sub-directories.

  • 'sync-to-repo' Target:

    This target orchestrates the execution of the previously defined copy commands. Following the copy operations, it executes Git commands to add, commit, and push the changes to the repository.

  • 'sync-from-repo' Target:

    This target performs the reverse operation, copying configurations from the repository back to the local configuration folders. It utilizes straightforward 'cp' commands to accomplish this task.

This setup effectively automates the synchronization process, ensuring consistency across all my development environments.

Docker Image: Creating the Development Environment

The Docker image is constructed from the ubuntu:latest base image, providing a stable and up-to-date foundation. Subsequently, I install my core development tools: Neovim, Zsh, Git, and Curl. I prioritize keeping the container as lean as possible, opting to configure it further during runtime to ensure versatility across various projects.

In the following steps, I set Zsh as the default shell and copy the configuration files from the repository for both the editor and the console. While the Dockerfile is expected to evolve with the project's complexity, I aim to avoid unnecessary bloat. Finally, I define the working directory and create a non-root user for enhanced security.

FROM ubuntu:latest

#install neovim, zsh and curl
RUN apt-get update && apt-get install -y neovim zsh git curl

#set zsh as default shell
RUN chsh -s /usr/bin/zsh

#copy neovim config
COPY neovim/ /home/dev/.config

#copy zsh config
COPY zsh/ /home/dev/.zshrc

#set workdir
WORKDIR /home/dev/workspace

#setting user
RUN useradd -ms /bin/zsh dev
USER dev

#run zsh shell
CMD ["zsh"]

Once all configurations are in place, Zsh is set as the default command, providing a ready-to-use development environment.

GitHub Actions: Automating Deployment

Embarking on this project, I decided to integrate GitHub Actions for the first time, aiming to automate the build and deployment process of my Docker image. I must say, that at least at such small piece of code I'm genuinely impressed by the platform's simplicity, readability, and overall clarity. My objective was to establish a seamless CI/CD pipeline, ensuring that any changes pushed to the repository's main branch would automatically trigger an update to the Docker image on Docker Hub.

name: Neovim IDE Docker Image CI

on:
  push:
    branches: ["main"]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Build the Docker image
      run: docker build -t tourdeprzem/neovim-ide:${{ github.sha }} .
    - name: Log in to Docker Hub
      run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
    - name: Push the Docker Image
      run: docker push tourdeprzem/neovim-ide:${{ github.sha }}
    - name: Remove Docker config.json file
      run: rm /home/runner/.docker/config.json

The GitHub Actions workflow is designed to execute upon any push to the main branch. It begins by initiating a Docker build process, creating the container image as defined in the Dockerfile. Subsequently, it logs into Docker Hub using the provided credentials and proceeds to push the newly built image to my Docker Hub repository. Finally, to mitigate potential security concerns, the workflow includes a step to remove the 'config.json' file, which is generated during the Docker login process.

The removal of the 'config.json' file is not a crucial but nice to have security measure. Initially, I used the --password flag directly within the docker login command, which, while functional, potentially exposed the password in the workflow logs and saved it in config.json file. Switching to --password-stdin was a small improvement, as it securely passes the password through standard input. However, even with this method, the Docker client still stores authentication credentials in the config.json file. Although the risk is minimal due to the ephemeral nature of GitHub Actions runners, which are discarded after each run, removing the file altogether provides an additional layer of security, ensuring that no sensitive information persists beyond the workflow's execution.

It's important to note that the YAML file defining the GitHub Actions workflow must be placed within the .github/workflows/ directory in your repository. This directory is where GitHub Actions looks for workflow definitions. Any file with a .yml or .yaml extension within this directory will be recognized as a workflow and executed based on the triggers defined within it.

container-nvim-ide/
├── .github/
│   └── workflows/
│       └── docker-image.yml
├── Dockerfile
├── Makefile
├── neovim/
└── zsh/

To securely store sensitive information such as Docker Hub credentials and to be able to get them by pipeline, GitHub provides a feature called repository secrets:

  • To add a secret, navigate to your repository's settings.

  • Then go to the 'Secrets and variables' section.

  • Finally go to 'Actions'.

Here, you can create new secrets by providing a name and value. These secrets can then be accessed within your workflow files using the ${{ secrets.SECRET_NAME }} syntax, ensuring that sensitive data is not exposed in your codebase. This kind of secrets you can add to Repository Secrets section as those will remain the same for whole repo, there is no differentiation by environment type.

After having this done, your pipeline should be triggered every time changes are merged to main branch and keep your docker image on Docker Hub up to date.

Conclusion: Lessons learned and Future Improvements

This tiny project was a valuable learning experience, teaching me the power of Docker for portable development environments and the importance of configuration consistency. GitHub Actions proved to be an excellent tool for automating Docker image builds and secure secret management. Future improvements include expanding language support and enhancing dependency management. Docker and GitHub Actions are essential for efficient and consistent development workflows. Find code snippets and the full repository here: przemyslaw-koz/container-nvim-ide

0
Subscribe to my newsletter

Read articles from Przemysław Kozłowski directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Przemysław Kozłowski
Przemysław Kozłowski