# Coding using a devcontainer A series of 7 article(s), in reading order. # PHP development in a devcontainer with preinstalled code quality tools > Stop fighting code quality issues. Set up a consistent PHP development environment instantly! This step-by-step guide shows you how to configure a VSCode Devcontainer with preinstalled quality and analysis tools. --- ![PHP development in a devcontainer with preinstalled code quality tools](/img/v2/devcontainer.webp) **TL;DR** This long, step-by-step article builds a VSCode devcontainer for PHP with a full set of preinstalled code-quality tools: PHP-CS-Fixer and PHPCBF/PHPCS for auto-formatting on save, SonarLint and Intelephense for real-time bug detection, and Rector for automated codebase upgrades and refactoring suggestions β€” all configured once in `devcontainer.json` and `Dockerfile` so any team member gets the identical environment. Let's imagine one of the following situations: 1. You are working with several people on the same PHP project and you notice that one person or another does not comply with your quality criteria when it comes to the layout of the code. You like to use four spaces for indentation, you want the brace that marks the start of a function to be on the line, you don't want to see any more useless spaces at the end of the line, ... and damned! you notice that some people don't care about this. 2. You are working alone on a project and don't want to lose time to configure your VSCode experience. You wish to start very quickly with a lot of tools already installed in VSCode so you can put your focus to the code, not the editor. The ultimate solution: using a `devcontainer` in VSCode. By using a **devcontainer**, you (and your team colleague) will use a preinstalled environment and everyone will have exactly the same. You'll save yourself a lot of time by not having to configure your system, and you'll be able to start coding straight away, supported by a range of quality analysis tools. ## What a devcontainer does for you Here is a PHP file as I saved it, with my worst habits on display β€” random indentation, trailing spaces (shown as dots), braces wherever they landed: ![Index php with poor written code](./images/index_php_bad_formatting.webp) Now the same file, after a single `CTRL`+`S` inside the devcontainer. I changed nothing; I only saved: ![Your script has been correctly formatted this time](./images/index_php_correctly_formatted.webp) Nothing was installed on my machine to make that happen β€” no PHP, no Composer, no formatter binary. And the colleague who clones the repository gets the exact same behavior, without being asked to configure anything. ## Why it works * **The Docker image carries the tools.** PHP-CS-Fixer, PHPCS/PHPCBF and Composer are downloaded when the image is built, so they exist inside the container and nowhere else. * **`devcontainer.json` carries the editor.** It lists the VSCode extensions to install and the settings to apply β€” including "format this file on save, with that binary, using that config file". * **VSCode attaches itself to the container.** Your editor keeps running on your machine, but everything it executes happens inside the container. That's why a path like `/usr/local/bin/php-cs-fixer.phar` is perfectly valid even though it doesn't exist on your disk. > **You just want the ready-made environment?:** > > Then skip this whole tutorial and go to [Install a PHP Docker environment in a matter of seconds](/blog/php-devcontainer): same result, three commands, no explanations. ## Get the files This article is written in the form of a step-by-step tutorial. If you don't want to take the time to create the configuration files yourself and download them directly, please run the commands below in a Linux console. You'll then get all the files and every time the tutorial below tells you to create a file, you'll already have it. ```bash $ mkdir /tmp/devcontainer_php && cd $_ curl -LOJ --silent https://github.com/cavo789/php_devcontainer/archive/refs/tags/1.0.0.tar.gz tar -xzvf php_devcontainer-1.0.0.tar.gz --strip-components 1 && rm -f php_devcontainer-1.0.0.tar.gz ``` > **Download the very latest version:** > > The php\_devcontainer repository will evolve over time. If you wish to download the latest version and not the one frozen for this article, please use the following commands and not the ones above: > > ```bash > $ mkdir /tmp/devcontainer_php && cd $_ > > $ curl -LOJ --silent https://github.com/cavo789/php_devcontainer/archive/refs/heads/main.tar.gz > > $ tar -xzvf php_devcontainer-main.tar.gz --strip-components 1 && rm -f php_devcontainer-main.tar.gz > ``` ## 1. Create the project Let's start immediately a new project by creating a temporary directory by running `mkdir /tmp/devcontainer_php && cd $_`. Since we'll create a few files, please run `code .` to start Visual Studio Code (aka *VSCode*). Create a new file called `index.php` with the PHP code below: **index.php** ```php **Stop reading here if ...:** > > ... you're one of those people who thinks *The script works, doesn't it? So why all the noise?*, **please turn off your computer and promise never to touch a single line of code again**. Even if the code *work* ... it stinks. In this article we'll learn how to configure Visual Studio Code to come with preinstalled extensions and configured for your targeting version of PHP (would be 8.2 but you can change this very easily). > **The technique we'll use is called devcontainer.:** > > **A devcontainer is a pre-configured, isolated development environment running in a Docker container, offering consistent tools and settings across different machines.** ## 2. Install first the ms-azuretools.vscode-docker VSCode extension In VSCode, please press `CTRL`+`SHIFT`+`X` to open the `Extensions` pane and search for the Microsoft Docker extension called `ms-azuretools.vscode-docker` and if you don't have it yet, please install and enable it. ## 3. Create the devcontainer.json file The next step is to create a folder called `.devcontainer` and, there, a file called `devcontainer.json` with the content below. In the rest of this article, we'll come back to this file. **.devcontainer/devcontainer.json** ```json { "name": "php_devcontainer", "build": { "dockerfile": "Dockerfile", "args": { "PHP_VERSION": "8.2", "COMPOSER_VERSION": "2.5.5", "PHPCSFIXER_INSTALL": "true", "PHPCSFIXER_VERSION": "3.46.0", "PHPCBF_INSTALL": "true", "PHPCBF_VERSION": "3.7.2" } }, "workspaceMount": "source=${localWorkspaceFolder},target=/var/www/html,type=bind", "workspaceFolder": "/var/www/html", "remoteUser": "docker", "customizations": { "vscode": { "settings": { "[php]": { "editor.formatOnSave": true, "editor.defaultFormatter": "junstyle.php-cs-fixer" }, "editor.codeActionsOnSave": { "source.fixAll": true }, "editor.formatOnSave": true, "editor.renderWhitespace": "all", "files.autoSave": "onFocusChange", "files.eol": "\n", "files.insertFinalNewline": true, "files.trimTrailingWhitespace": true, "intelephense.environment.phpVersion": "8.2", "intelephense.telemetry.enabled": false, "php-cs-fixer.config": "/var/www/html/.config/.php-cs-fixer.php", "php-cs-fixer.executablePath": "/usr/local/bin/php-cs-fixer.phar", "php-cs-fixer.onsave": true, "php-cs-fixer.rules": "@PSR12", "php.validate.executablePath": "/usr/local/bin/php", "phpsab.executablePathCBF": "/usr/local/bin/phpcbf.phar", "phpsab.executablePathCS": "/usr/local/bin/phpcs.phar", "phpsab.fixerEnable": true, "phpsab.snifferShowSources": true, "phpsab.standard": "/var/www/html/.config/phpcs.xml", "telemetry.telemetryLevel": "off", "terminal.integrated.profiles.linux": { "bash": { "path": "/bin/bash", "icon": "terminal-bash" } }, "terminal.integrated.defaultProfile.linux": "bash" }, "extensions": [ "bmewburn.vscode-intelephense-client", "junstyle.php-cs-fixer", "ms-azuretools.vscode-docker", "ValeryanM.vscode-phpsab", "zobo.php-intellisense" ] } }, "postCreateCommand": "composer require rector/rector --dev" } ``` *VS Code gives such a container a random name like `serene_ganguly`. To force a stable name and group it in Docker Desktop, see [Devcontainer - Name Your Container and Group It in Docker Desktop](/blog/devcontainer-container-name).* ## 4. Create the Dockerfile file Please continue and create a second file called `Dockerfile` with this content: **Dockerfile** ```docker ARG PHP_VERSION=8.2 ARG COMPOSER_HOME="/var/cache/composer" ARG COMPOSER_VERSION=2.5.5 ARG PHPCSFIXER_INSTALL=true ARG PHPCSFIXER_VERSION=3.46.0 ARG PHPCBF_INSTALL=true ARG PHPCBF_VERSION=3.7.2 ARG TIMEZONE="Europe/Brussels" ARG OS_USERID=1000 ARG OS_USERNAME="docker" FROM composer:${COMPOSER_VERSION} AS composer_base FROM php:${PHP_VERSION}-fpm ARG TIMEZONE ENV TZ=${TIMEZONE} ARG PHPCSFIXER_INSTALL ARG PHPCSFIXER_VERSION RUN set -e -x \ && if [ "$PHPCSFIXER_INSTALL" = "true" ] ; then \ printf "\e[0;105m%s\e[0;0m\n" "Install php-cs-fixer.phar ${PHPCSFIXER_VERSION}" \ && curl -L https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases/download/v${PHPCSFIXER_VERSION}/php-cs-fixer.phar -o /usr/local/bin/php-cs-fixer.phar \ && chmod +x /usr/local/bin/php-cs-fixer.phar ; \ fi ARG PHPCBF_INSTALL ARG PHPCBF_VERSION RUN set -e -x \ && if [ "$PHPCBF_INSTALL" = "true" ] ; then \ printf "\e[0;105m%s\e[0;0m\n" "Install phpcbf.phar ${PHPCBF_VERSION}" \ && curl -L https://github.com/squizlabs/PHP_CodeSniffer/releases/download/${PHPCBF_VERSION}/phpcbf.phar -o /usr/local/bin/phpcbf.phar \ && chmod +x /usr/local/bin/phpcbf.phar \ && curl -L https://github.com/squizlabs/PHP_CodeSniffer/releases/download/${PHPCBF_VERSION}/phpcs.phar -o /usr/local/bin/phpcs.phar \ && chmod +x /usr/local/bin/phpcs.phar ; \ fi ARG COMPOSER_HOME ENV COMPOSER_HOME=${COMPOSER_HOME} RUN set -e -x \ && mkdir -p "${COMPOSER_HOME}/cache/files" \ && mkdir -p "${COMPOSER_HOME}/cache/vcs" \ && chmod -R 777 "${COMPOSER_HOME}" COPY --from=composer_base /usr/bin/composer /usr/bin/composer ARG OS_USERID ARG OS_USERNAME RUN set -e -x \ && useradd --password '' -G www-data,root -u ${OS_USERID} -l -d "/home/${OS_USERNAME}" "${OS_USERNAME}" \ && mkdir -p "/home/${OS_USERNAME}" \ && chown -R "${OS_USERNAME}":"${OS_USERNAME}" "/home/${OS_USERNAME}" RUN set -e -x \ && printf "\e[0;105m%s\e[0;0m\n" "Install Linux binaries" \ && apt-get update -yqq \ && apt-get install -y --no-install-recommends curl libzip-dev zip unzip \ && docker-php-ext-install zip \ && docker-php-source delete \ && apt-get clean \ && rm -rf /tmp/* /var/list/apt/* WORKDIR /var/www/html ``` ## 5. Reopen in the container So, we've now three files: a disgusting `index.php`, `.devcontainer/devcontainer.json` and `.devcontainer/Dockerfile`. What we will do now is to *jump* in a Docker container. Take a look at the bottom left of your screen: ![Running in WSL](./images/running_in_wsl.webp) You'll see something like, in my case, `WSL: Ubuntu-20.04`. *It can be different on your computer depending on your operating system.* What VSCode says here is *I'm coding on my machine*. Click on that status or, as an alternative way, press `CTRL`+`SHIFT`+`P` to open the *Command Palette* and then search for `Reopen in Container` and press `Enter`. ![Reopen in Container](./images/reopen_in_container.webp) Visual Studio Code will do a lot of stuff and, the first time, it can take a few minutes. ![Now, you're in the container](./images/dev_container_vscode.webp) ## 6. Working in the container Now the magic happens: please reopen the so badly formatted `index.php` file and save the file **without any changes**. Just press `CTRL`+`S` and tadaaa πŸŽ‰πŸŽ‰πŸŽ‰ β€” you get the reformatted file shown at the beginning of this article. > **How is this possible?:** > > This is because we taught VSCode to use a specific formatter for our PHP file and we told him to format the file each time it's saved. And do you know where we did it? In our `.devcontainer/devcontainer.json` and `.devcontainer/Dockerfile` files, of course! > > **This is why we're working in a Devcontainer!** We've done the configuration once and for all, so all we have to do is reuse it, whatever the PHP project. ## The tools (pick the ones you need) The five sections below are independent from each other: each one covers a tool that is already installed in the container, what it catches, and where it was declared. Read the ones that matter to you and skip the rest β€” the environment works with all of them enabled anyway. ### The first tool we've used here is PHP-CS-Fixer When we save a file in VSCode, we have configured a *formatter* which will adapt the layout of the file according to the rules we have determined. For example, the number of spaces for indentation or the fact that braces must be aligned (and many other things). That tool is [PHP-CS-Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer). But let's understand when `PHP-CS-Fixer` was installed... Please reopen your `.devcontainer/devcontainer.json` file and look at the highlighted lines below: **.devcontainer/devcontainer.json** ```json { // ... "build": { "dockerfile": "Dockerfile" }, // ... "customizations": { "vscode": { "settings": { // highlight-next-line "[php]": { // highlight-next-line "editor.formatOnSave": true, // highlight-next-line "editor.defaultFormatter": "junstyle.php-cs-fixer" }, // ... // highlight-next-line "files.autoSave": "onFocusChange", // ... // highlight-next-line "php-cs-fixer.config": "/var/www/html/.config/.php-cs-fixer.php", // highlight-next-line "php-cs-fixer.executablePath": "/usr/local/bin/php-cs-fixer.phar", // highlight-next-line "php-cs-fixer.onsave": true, // highlight-next-line "php-cs-fixer.rules": "@PSR12", //... }, "extensions": [ // ... // highlight-next-line "junstyle.php-cs-fixer", ] } } } ``` So, we're asking VSCode to, for PHP files, run a `formatter` when the file is saved and the tool to use for the formatting is `junstyle.php-cs-fixer`. Look directly at the end, in the `extensions` section, we instruct VSCode to install that extension. We also instruct VSCode to automatically save the file as soon as the focus changes i.e. for instance, if we select another file, if we click elsewhere (no more in the editor), ... Finally, `junstyle.php-cs-fixer` requires some settings that we need to initialize like f.i. `php-cs-fixer.executablePath`. Right now, you can think: *Wait a minute, I don't have the `/usr/local/bin/php-cs-fixer.phar` file on my computer.* And you are probably right but think again: are you coding *on your computer* or are you *inside a Docker container*? By running VSCode in a container, you're *no more* on your machine. And now, you need to take a look at the third file we've created: `Dockerfile`. Please reopen the `.devcontainer/Dockerfile` file and look at the highlighted lines below: **.devcontainer/Dockerfile** ```docker # highlight-next-line ARG PHP_VERSION=8.2 # ... # highlight-next-line ARG PHPCSFIXER_INSTALL=true # highlight-next-line ARG PHPCSFIXER_VERSION=3.46.0 # ... # highlight-next-line FROM php:${PHP_VERSION}-fpm # ... # highlight-next-line ARG PHPCSFIXER_INSTALL # highlight-next-line ARG PHPCSFIXER_VERSION # highlight-next-line RUN set -e -x \ # highlight-next-line && if [ "$PHPCSFIXER_INSTALL" = "true" ] ; then \ # highlight-next-line printf "\e[0;105m%s\e[0;0m\n" "Install php-cs-fixer.phar ${PHPCSFIXER_VERSION}" \ # highlight-next-line && curl -L https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/releases/download/v${PHPCSFIXER_VERSION}/php-cs-fixer.phar -o /usr/local/bin/php-cs-fixer.phar \ # highlight-next-line && chmod +x /usr/local/bin/php-cs-fixer.phar ; \ # highlight-next-line fi # ... ``` That file will instruct VSCode to download a `php:8.2-fpm` Docker image and to install `PHP-CS-Fixer` (since `PHPCSFIXER_INSTALL` has been set to `true`). The version `3.46.0` of the `PHP-CS-Fixer` executable will be downloaded and stored, in the Docker image, as `usr/local/bin/php-cs-fixer.phar`. This is why, in our `devcontainer.json` we can write `"php-cs-fixer.executablePath": "/usr/local/bin/php-cs-fixer.phar",`, because we've already downloaded and installed the executable. > **Need to check?:** > > Still in VSCode, open a terminal by pressing `CTRL`+`Β΄` and, in the prompt, type `ls -l /usr/local/bin`. As you can see, `php-cs-fixer.phar` is well there. *It was downloaded and installed by VSCode when you jumped into the container.* > > ![The php-cs-fixer.phar binary is well there](./images/php-cs-fixer-phar.webp) #### PHP-CS-FIXER - Configuration file The `.devcontainer/devcontainer.json` file also contains this line: `"php-cs-fixer.config": "/var/www/html/.config/.php-cs-fixer.php"`. So, we can have our own configuration file for `PHP-CS-Fixer` (with our own rules) and that file has to be created in a folder called `.config` and should be named `.php-cs-fixer.php`. Let's try and this time we'll make sure all our PHP files will have a header block with our copyright or license information or anything else. In VSCode, please create the `.config` folder and there the `.php-cs-fixer.php` file with this content: **.config/.php-cs-fixer.php** ```php in('.') ->exclude([ '.config', '.devcontainer', '.git', 'node_modules', 'vendor' ]); $header = file_get_contents(__DIR__ . '/licenseHeader.txt'); $config = new PhpCsFixer\Config(); return $config->setRules( [ '@PSR12' => true, 'header_comment' => [ 'header' => rtrim($header, "\r\n"), 'location' => 'after_declare_strict', 'comment_type' => 'PHPDoc', ], ] ) ->setCacheFile('/tmp/.php-cs-fixer.cache') ->setIndent(' ') ->setLineEnding("\n") ->setFinder($finder); ``` Please also create a second file called `licenseHeader.txt` file with the content you wish, f.i.: **.config/licenseHeader.txt** ```txt This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . ``` Now, please reopen your `index.php` file and just press `CTRL`+`S` to save the file again and tadaaa... ![A license block is automatically added by php-cs-fixer](./images/license.gif) As you can see, when formatting the file, `PHP-CS-Fixer` will also, now, injects our header. > **Lots of options are explained here:** > > Please go to or to learn more about the php-cs-fixer configuration file options. #### PHP-CS-FIXER - Fix all files at once One step further: is it possible to run `PHP-CS-Fixer` not just on the file being edited in VSCode but on all files in your repository. **Yes, of course.** In VSCode, please press `CTRL`+`Β΄` (or click on the `View` menu then `Terminal`) to open a terminal. Make sure you're in the `/var/www/html` folder (run `cd /var/www/html` if needed) and run the following command: ```bash $ /usr/local/bin/php-cs-fixer.phar fix --config /var/www/html/.config/.php-cs-fixer.php . ``` This will run `PHP-CS-Fixer` on all your codebase (`.`). If you want to exclude some folders, open the `.config/.php-cs-fixer.php` file and update the `exclude` array. ### The second set of tools is PHPCBF and PHPCS Our `Dockerfile` container was configured to download and install [PHP\_CodeSniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer) tools called `phpcbf` and `phpcs`. `phpcbf` is a `Code beautifier` tool: just like `PHP-CS-Fixer`, `phpcbf` will make some minor changes to your files accordingly to enabled rules. For instance, please update your `index.php` script with this content: **index.php** ```php **PHP-CS-Fixer will be called when you save the file:** > > Did you've noticed? You have copied/pasted the script below where there were no spaces before and after the `else` keyword. But as soon as you save the file, boum, the file is refactored by `PHP-CS-Fixer` and, among other things, spaces have been added before and after `else` but not for `if`. This is because, actually, there is not ONE MAGIC TOOL for everything. Sometimes `PHP-CS-Fixer` can't make some changes but `PHPCBF` well or the opposite. Like for `PHP-CS-Fixer`, we already have installed `PHPCBF` and `PHPCS` in our container. See your `.devcontainer/devcontainer.json` file: **.devcontainer/devcontainer.json** ```json { // .. "customizations": { "vscode": { "settings": { // ... // highlight-next-line "phpsab.executablePathCBF": "/usr/local/bin/phpcbf.phar", // highlight-next-line "phpsab.executablePathCS": "/usr/local/bin/phpcs.phar", // highlight-next-line "phpsab.fixerEnable": true, // highlight-next-line "phpsab.snifferShowSources": true, // highlight-next-line "phpsab.standard": "/var/www/html/.config/phpcs.xml", // ... }, "extensions": [ // ... // highlight-next-line "ValeryanM.vscode-phpsab", // ... ] } } } ``` And the two executables `/usr/local/bin/phpcbf.phar` and `/usr/local/bin/phpcs.phar` have been downloaded thanks to your `Dockerfile`. Just take a look if needed. #### PHPCS and PHPCBF - Configuration file In VSCode, please create in the `.config` folder a file called `phpcs.xml` file with this content: **.config/phpcs.xml** ```xml ``` Now, since we have defined our coding standard (`PSR12` here), just display the `index.php` script again: ![PHPCS in VSCode](./images/vscode_phpcs.webp) Open a terminal by pressing `CTRL`+`Β΄` (or by clicking on the `View` menu then `Terminal`) and run this command: ```bash $ /usr/local/bin/phpcs.phar --standard=/var/www/html/.config/phpcs.xml /var/www/html/index.php ``` And you'll get this output: ```bash FILE: /var/www/html/index.php ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ FOUND 1 ERROR AND 1 WARNING AFFECTING 2 LINES ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ 1 | WARNING | [ ] A file should declare new symbols (classes, functions, constants, etc.) and cause no other side effects, or it should execute logic with side effects, but should not do | | both. The first symbol is defined on line 18 and the first side effect is on line 29. (PSR1.Files.SideEffects.FoundWithSymbols) 20 | ERROR | [x] Expected 1 space(s) after IF keyword; 0 found (Squiz.ControlStructures.ControlSignature.SpaceAfterKeyword) ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ ``` There is one warning and one error concerning *coding convention violation* and the nice thing is **phpcbf can fix some**. Now, run almost the same command but no more `phpcs` (to detect violations) but `phpcbf` (to fix them -the ones that can be fixed automatically-). ```bash $ /usr/local/bin/phpcbf.phar --standard=/var/www/html/.config/phpcs.xml /var/www/html/index.php ``` You'll get this: ```bash PHPCBF RESULT SUMMARY ---------------------------------------------------------------------- FILE FIXED REMAINING ---------------------------------------------------------------------- /var/www/html/index.php 1 1 ---------------------------------------------------------------------- A TOTAL OF 1 ERROR WERE FIXED IN 1 FILE ---------------------------------------------------------------------- ``` And, indeed, if you look at your PHP script, now, there is a space after the `if` keyword. #### PHPCS and PHPCBF - All files at once You've probably already understood the syntax. Just use the `.` to scan the entire codebase: 1. To fix violations, run `/usr/local/bin/phpcbf.phar --standard=/var/www/html/.config/phpcs.xml .` 2. To scan remaining violations, run `/usr/local/bin/phpcs.phar --standard=/var/www/html/.config/phpcs.xml .`. ### The third tool is SonarLint Here too, SonarLint was already installed in our Docker container thanks our `.devcontainer/devcontainer.json` file: **.devcontainer/devcontainer.json** ```json { // ... "customizations": { "vscode": { "extensions": [ // ... // highlight-next-line "sonarsource.sonarlint-vscode", // ... ] } } } ``` > **It's only a VSCode extension:** > > SonarLint doesn't need an external tool; it's a stand-alone VSCode extension so no need to foresee something in our `Dockerfile`. So, please reopen the `index.php` file you have, and now pay attention to the `echo sayHello()` line: **index.php** ```php **SonarLint prevents bugs!:** > > Unlike the tools we've seen here above, it's no longer about formatting, **but about alerting the developer to potential bugs and fatal errors as they write their code.** ### The fourth tool is Intelephense Next to `SonarLint`, there is also `Intelephense` who will also detect a certain number of potential bugs. In the previous example; when the prototype was `function sayHello($firstname)`, `Intelephense` was also alerting the developer about the missing parameter. **index.php** ```php **We need several tools:** > > This example illustrates this point: to date, in February 2024, we still have to juggle with several extensions and tools to achieve clean, bug-free code. ### The best for last, the fifth tool is Rector Rector is a tremendous application to scan and automatically upgrade your codebase to a given version of PHP and this means, too, to inspect how you're coding. Perhaps you're still using a way of programming that's worthy of PHP 5.4 but unworthy of a modern developer who's up to date with the latest developments. > **Rector is my private coach:** > > I really LOVE Rector since he'll help me to learn new features of PHP. When I run it on any of my codebase, I can see where I can improve my code by refactoring some part and do better. > > **I REALLY LOVE RECTOR.** This time, we'll need to install Rector for our project and for this, we'll follow the official documentation: but ... it's already done in our `.devcontainer/devcontainer.json` file. If you reopen it, take a look at the `postCreateCommand` node: **.devcontainer/devcontainer.json** ```json { // ... "customizations": { // ... }, // highlight-next-line "postCreateCommand": "composer require rector/rector --dev" } ``` Rector will be added to the `composer.json` file (and the file will be created if not yet present). > **`Composer` has been installed in our `Dockerfile`:** > > If you're thinking *Yes, but I haven't installed composer...*, well, it's wrong. We installed it in our container. See again your `.devcontainer/Dockerfile` if needed. #### Rector - configuration file > **Skip this chapter if you have already downloaded the configuration files:** The second step in the installation guide asks us to run the command below. When prompted, please answer `yes` to create your `rector.php` configuration file. ```bash $ vendor/bin/rector ``` Finally, open the `rector.php` configuration file and update it like this: **rector.php** ```php withSkip( [ '.config', '.devcontainer', '.git', 'node_modules','vendor' ] ) ->withPhpSets(php82: true) ->withPreparedSets(codeQuality: true, deadCode: true, typeDeclarations: true); ``` > **Tell to Rector which PHP version you use:** > > Rector will suggest you changes but to do this, he needs to know which version of PHP you're running. Indeed, Rector won't suggest PHP 8.2 syntax f.i. if you're still running on an older version (see [PHP Version Features](https://getrector.com/documentation/php-version-features)). > **On my side, I prefer to put all configuration files in a .config folder:** > > You've noticed that Rector has created his configuration file in the root directory of your project. I prefer to move it to the `.config` folder. #### Run Rector To illustrate what Rector can do, please edit the `index.php` with this content, once again: **index.php** ```php ). Rector suggests removing completely the `if .. else ...` expression and to use a [ternary expression](https://github.com/rectorphp/rector/blob/main/docs/rector_rules_overview.md#simplifyifelsetoternaryrector). And, also, to directly return the value instead of using a dummy `$text` variable. And that's correct, we can write `return $firstname == "" ? "Hello World!" : "Hello " . $firstname;` to get the exact same thing. And if you think it's over, you're wrong. Rector now looks at our function parameter and understands it's a string so he'll suggest [adding a type hint](https://github.com/rectorphp/rector/blob/main/docs/rector_rules_overview.md#strictstringparamconcatrector) and, too, to [add a return type](https://github.com/rectorphp/rector/blob/main/docs/rector_rules_overview.md#returntypefromstrictscalarreturnexprrector) for the same reason. Since we've analyzed the suggestions of Rector and we agree with, please rerun the same command but, this time, without, the `--dry-run` flag. ```bash $ vendor/bin/rector process index.php --config .config/rector.php ``` Rector has updated our `index.php` file! **Now, our function is just one line and no more seven. Nice improvement!** Our new, improved, code is now: **index.php** ```php . > **The programmer has the last word, but for how much longer?:** > > We can do one more improvement by extracting the `Hello` prefix. Rector has not yet seen this, but for how long? > > **index.php** > > ```php > > function sayHello(string $firstname = ""): string > { > return "Hello " . ($firstname == "" ? "World!" : $firstname); > } > > echo sayHello(); > ``` #### Rector - All files at once Back in your terminal and run the command below to scan your entire codebase. ```bash $ vendor/bin/rector process . --dry-run --config .config/rector.php ``` ##### Dry-run means no changes are made, just displayed When running Rector using `vendor/bin/rector process . --dry-run --config .config/rector.php`, no files will be rewritten. Rector will only suggest changes. If you allow Rector to make changes to your files, run `vendor/bin/rector process . --config .config/rector.php` instead but, here, you need to be sure it's OK. > **Code versioning using f.i. GitHub:** > > If you're using a code versioning system, you can push your actual codebase to GitHub, create a new branch like for instance `refactoring` then run Rector safely on your disk. If something should be broken, you can always retrieve your source before any changes. ### And we can add more tools There are still several code analysis tools for PHP: * [phpstan](https://phpstan.org/), * [psalm](https://psalm.dev/), * [PHP Copy/Paste Detector](https://github.com/sebastianbergmann/phpcpd), * [PHP Magic Number Detector](https://github.com/povils/phpmnd), * ... You can certainly also add them to your container. ### Extensions Some extensions will be automatically installed: * [bmewburn.vscode-intelephense-client](https://marketplace.visualstudio.com/items?itemName=bmewburn.vscode-intelephense-client) - PHP code intelligence for Visual Studio Code, * [junstyle.php-cs-fixer](https://marketplace.visualstudio.com/items?itemName=junstyle.php-cs-fixer) - PHP CS Fixer extension for VS Code, php formatter, php code beautify tool, format html, * [ms-azuretools.vscode-docker](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker) - Makes it easy to create, manage, and debug containerized applications, * [sonarsource.sonarlint-vscode](https://marketplace.visualstudio.com/items?itemName=sonarsource.sonarlint-vscode) - Linter to detect & fix coding issues locally in JS/TS, Python, PHP, Java, C, C++, C#, Go, IaC. Use with SonarQube & SonarCloud for optimal team performance., * [streetsidesoftware.code-spell-checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker) - Spelling checker for source code, * [usernamehw.errorlens](https://marketplace.visualstudio.com/items?itemName=usernamehw.errorlens) - Improve highlighting of errors, warnings and other language diagnostics, * [ValeryanM.vscode-phpsab](https://marketplace.visualstudio.com/items?itemName=ValeryanM.vscode-phpsab) - PHP Sniffer & Beautifier for Visual Studio Code and * [zobo.php-intellisense](https://marketplace.visualstudio.com/items?itemName=zobo.php-intellisense) - Advanced Autocompletion and Refactoring support for PHP ## Conclusion Remember the colleague from the beginning of this article, the one who doesn't indent the way you do? There's nothing left to ask them. The rules aren't in a wiki page or in a code-review comment anymore, they're in two versioned files β€” `devcontainer.json` and `Dockerfile` β€” and they apply the moment the project is opened, on every machine, identically. That's really the shift: the environment stops being something each developer builds, and becomes something the repository provides. If you'd rather run these tools on demand instead of baking them into an image, see [jakzal/phpqa](/blog/php-jakzal-phpqa). And to make sure none of this development tooling ever ends up in your production image, read [One Docker Image for Production and Devcontainers - The Clean Way](/blog/docker-prod-devcontainer). --- # Install a PHP Docker environment in a matter of seconds > Quickly set up a PHP Docker Dev Container environment for VSCode. Get Composer and pre-installed static analysis tools like PHP-CS-Fixer and Rector instantly. --- ![Install a PHP Docker environment in a matter of seconds](/img/v2/devcontainer.webp) **TL;DR** This is the short, quick-start version of the full devcontainer article: download the `php_devcontainer` skeleton with `curl`, open the folder in VSCode, and click "Reopen in Container" to get a ready-to-use PHP environment with Composer, PHP-CS-Fixer, PHPCS/PHPCBF, SonarLint and Rector preinstalled β€” for both brand-new projects and existing codebases. This article is the very short and straightforward version of [PHP development in a devcontainer with preinstalled code quality tools](/blog/vscode-devcontainer). If you just want to follow a very few steps and get your PHP environment, this article is for you. **By following the steps described in this article, you'll obtain** 1. A Docker PHP environment so you can code using vscode, 2. The PHP `composer` package manager installed, 3. A few static analysis tool like `php-cs-fixer`, `phpcs`, `phpcbf`, `sonarlint` and `Rector` installed 4. A devcontainer ready-to-use environment. Just follow the guide... > **The aim of this article is to create a development environment (aka devcontainer):** > > Please refers to my other articles about [Docker](/blog/tags/docker) to see how to Dockerize the application and be able to run the application using [Apache](/blog/tags/apache) and a database service like [PostgreSQL](/blog/tags/postgresql). ## What the environment gives you Here is PHP-CS-Fixer reformatting a file, inside the container, a couple of minutes after the download β€” nothing installed on my host machine, no PHP, no Composer, no extension to configure: ![PHP-CS-Fixer](./images/php-cs-fixer.webp) The same is true for [PHP\_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer) (`phpcbf.phar`), [Rector](https://github.com/rectorphp/rector) and SonarLint: they're already in the image, already configured through the `.config` folder shipped with the skeleton. You'll find the exact commands in the *You are ready to use tools* section below. Two ways to get there, depending on whether you're starting from scratch or adding this to an existing codebase. ## You have nothing and wish to create a fully new project ### 1. Install the php\_devcontainer skeleton For this blog article, I'll create a temporary folder in `/tmp/devcontainer_php`. Don't hesitate to update the path to something like `~/my_project` to match your need. ```bash $ mkdir /tmp/devcontainer_php && cd $_ $ curl -LOJ --silent https://github.com/cavo789/php_devcontainer/archive/refs/heads/main.tar.gz $ tar -xzvf php_devcontainer-main.tar.gz --strip-components 1 && rm -f php_devcontainer-main.tar.gz ``` > **Download a specific version:** > > If you don't want to download the latest version but a given one, specify the tags like: `curl -LOJ --silent https://github.com/cavo789/php_devcontainer/archive/refs/tags/1.0.0.tar.gz`. You've thus downloaded code from my repo into your folder. ### 2. Open VSCode Start VSCode on your machine: ```bash $ cd /tmp/devcontainer_php $ code . ``` ### 3. Switch to the Dev Container VSCode will automatically suggest opening a Dev Container. Click on the `Reopen in Container` button. ![VSCode automatically suggests opening a Dev Container](./images/vscode_starting.webp) If you don't have this popup, just press `CTRL`+`SHIFT`+`P` to open the **Command Palette** and search for `Dev Containers: Rebuild and Reopen in Dev Container` and click on it. VSCode will now take a while to build the Docker image then start a Docker container before you can work. It can take a few minutes depending on the speed of your machine. ### You are ready to use tools Press `CTRL`+`ΓΉ` to open the **Terminal** pane and type there `/usr/local/bin/php-cs-fixer.phar fix --config /var/www/html/.config/.php-cs-fixer.php index.php` to run [PHP-CS-Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) β€” that's the command that produced the screenshot at the top of this article. You can also run `/usr/local/bin/phpcbf.phar --standard=/var/www/html/.config/phpcs.xml /var/www/html/index.php` for reformatting your code using PHP\_CodeSniffer. You can run Rector too by running `vendor/bin/rector process index.php --config .config/rector.php`. As you can see, in just three actions you've downloaded and installed a functional PHP environment using Docker and VSCode. ## You already have an existing project In a real-life use case, you already have a PHP project. Start a terminal and open the folder where you've your codebase. For this blog post, I'll download a very old learning Laravel codebase I've done years ago: ```bash $ cd /tmp $ git clone https://github.com/cavo789/laravel_todos.git ``` So, my project will be in folder `/tmp/laravel_todos`. In my project folder, now, I'll run: ```bash $ cd /tmp/laravel_todos $ curl -LOJ --silent https://github.com/cavo789/php_devcontainer/archive/refs/tags/1.0.0.tar.gz $ tar -xzvf php_devcontainer-1.0.0.tar.gz --strip-components 1 && rm -f php_devcontainer-1.0.0.tar.gz ``` This will download the PHP devcontainer files in my project. I will run `code .` to open VSCode and, like before, VSCode will suggest opening the folder in a Dev Container. Let's do this. And voilΓ , I've Dockerized my PHP coding environment. In my case, I've made `Laravel Todos` in 2018, using PHP 7.1.3 and Laravel 5.x; an eternity then. And like in the previous chapter, I can run the commands below to fix coding style issues and fixes all files in my repo: ```bash $ /usr/local/bin/php-cs-fixer.phar fix --config /var/www/html/.config/.php-cs-fixer.php . $ /usr/local/bin/phpcbf.phar --standard=/var/www/html/.config/phpcs.xml . ``` And I **will be able** to run Rector to refactor my entire codebase: ```bash $ composer require rector/rector --dev $ vendor/bin/rector process . --config .config/rector.php ``` but in my case, I can't right now because my Laravel Todos is an old one and I need first to make some changes to my `composer.json` file (upgrading PHP and Laravel versions first). ## Conclusion A `curl`, a `tar` and a click on `Reopen in Container`: that's the whole distance between an empty folder and a PHP environment where the quality tools are already there, at the same version for everyone who clones the repository. Nothing has been installed on your machine, and deleting the folder deletes the environment. Same recipe, other stacks: [Docker - Python devcontainer](/blog/docker-python-devcontainer). And when the time comes to ship, [One Docker Image for Production and Devcontainers - The Clean Way](/blog/docker-prod-devcontainer) shows how to keep all this tooling out of your production image. --- # Docker - Python devcontainer > Learn how to quickly set up a VSCode devcontainer for Python development using Docker. Get the full Dockerfile, compose.yaml, and .docker.env configurations. --- ![Docker - Python devcontainer](/img/v2/devcontainer.webp) **TL;DR** This article builds a reusable Python devcontainer for VSCode: a `Dockerfile`, `compose.yaml`, `.docker.env` (for per-project settings like the Python version), and a `makefile` wrapping common commands (`make up`, `make bash`), then a `.devcontainer/devcontainer.json` preconfigured with the Python extension and settings so `make devcontainer` opens VSCode running fully inside the container with files synced to the host. As you know, VSCode is a superb editor that lets you program in probably any programming language. An editor, not an IDE, because VSCode is basically a Notepad in its ultimate version. If you want to program in Python, you'll need to install a few extensions in VSCode to be really comfortable, i.e. syntax highlighting, code navigation, code refactoring (like renaming a variable or a class), etc. There are β€˜ready-to-use’ editors like PyCharm but 1. they cost money and 2. they are specific (you won't be able to program in PHP with PyCharm; or even work easily with HTML/CSS files). In this new article, we'll look at how to get a VSCode environment ready to use straight away for coding in Python, and as it's VSCode it's 1. free, 2. multi-purpose and 3. insanely powerful. ## Let's see it running Here's what you get, before you create a single file: run `make up` in your console and you'll get this screen: ![Makefile](./images/make.webp) As you can see, we've a lot of commands like `make up` to start our Docker container. Let's try, and ouch, we're missing a file called `/src/requirements.txt`. ![requirements.txt is missing](./images/requirements_txt.webp) ### src/requirements.txt Please create an empty file called `src/requirements.txt`. The file can stay empty (you'll add your own requirements later on). By running `make up` again, yes!, this time we can build our images and create our container: ![Docker up](./images/docker-up.webp) This time we can, if you need to, enter our container by running `make bash` and, for instance, check what's inside our current folder and which version of Python has been installed: ![Inside the container](./images/container-python.webp) > **Note:** > > As you see, Python 3.13 is used. Why that specific version? Just go back to your `.docker.env` file and take a look at the `DOCKER_PYTHON_VERSION` variable. If you need another one, just update the `.docker.env` file and run `make up` again. ## Let's start creating our first script If you're still inside your container, please type `exit` so your prompt will be the one of your machine (your *host*), no longer the one of your running container. Please create your first script: create a new file in the `src` folder and call that file `hello.py`. Be creative and type a simple `print` statement: ```python print("I'm your Python code") ``` ![Hello](./images/hello.webp) To be able to run the code, start `make bash` again (to jump in the container) and start `python hello.py` ![Run hello](./images/run-hello.webp) > **Information:** > > It will work because, in the container, the working directory is `/app/src`. If this had not been the case, we would have had to write, for example, `python /app/src/hello.py` i.e. the absolute path to the script. As you can see, files on your machine are synchronized with your host. If VSCode is still open, you can change your script to f.i. ```python print("Hey! It's synchronized; cool!") ``` ![It's synchronized](./images/it-is-synchronized.webp) The files behind all this β€” `Dockerfile`, `compose.yaml`, `.docker.env` and the `makefile` β€” are shown next. ## Let's create the files for our Docker environment In this article, we'll create the devcontainer i.e. the development environment based on Docker that you just saw running. The creation of the devcontainer will take a few minutes but it's just a question of copy/paste from this article to your system. Once the files have been created, you can reuse the devcontainer for all your Python projects. *Two natural extensions: add the tools from [Python - Code Quality tools](/blog/python-qa) to the image so every project inherits them, and read [One Docker Image for Production and Devcontainers - The Clean Way](/blog/docker-prod-devcontainer) to keep that tooling out of the image you deploy.* Like always, we'll create a new folder and create files there. Please run `mkdir /tmp/python && cd $_` to create that folder and jump in it. Start VSCode from there i.e. run `code .` in the console when you're located in the folder. This will open VSCode and you'll be able to create as many files you want. ### Dockerfile The first file to create will be used to build our Docker image. Please create a file called `Dockerfile` with the following content: **Dockerfile** ```docker # syntax=docker/dockerfile:1 # cspell:ignore PYTHONDONTWRITEBYTECODE,PYTHONUNBUFFERED,HISTFILE # cspell:ignore addgroup,adduser,keyscan,hadolint,gecos,endregion # Those variables are initialized in the .docker.env file ARG DOCKER_APP_HOME="/app" ARG DOCKER_CONTAINER_NAME="python" ARG DOCKER_OS_GROUPID=1000 ARG DOCKER_OS_USERID=1000 ARG DOCKER_OS_USERNAME="python" ARG DOCKER_PYTHON_VERSION=3.10-slim # region - Our Python base image. We'll install Linux and Python dependencies here # and do some other configuration work FROM python:${DOCKER_PYTHON_VERSION} AS base # Prevents Python from writing pyc files. ENV PYTHONDONTWRITEBYTECODE=1 # Keeps Python from buffering stdout and stderr to avoid situations where # the application crashes without emitting any logs due to buffering. ENV PYTHONUNBUFFERED=1 ARG DOCKER_APP_HOME WORKDIR "${DOCKER_APP_HOME}/src" # hadolint ignore=DL3008 RUN --mount=type=cache,target=/var/cache/apk,rw \ set -e -x \ && printf "\e[0;105m%s\e[0;0m\n" "Install required Linux binaries..." \ && apt-get update -yqq \ && apt-get install -y --no-install-recommends bash git openssh-client tree \ && apt-get clean \ && rm -rf /tmp/* /var/lib/apt/lists/* # Install Python dependencies # # Download dependencies as a separate step to take advantage of Docker's caching. # Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. # Leverage a bind mount to requirements.txt to avoid having to copy them into # into this layer. RUN --mount=type=cache,target=/root/.cache/pip \ --mount=type=bind,source=src/requirements.txt,target=requirements.txt \ printf "\e[0;105m%s\e[0;0m\n" "Install Python dependencies" \ && python -m pip install --no-cache-dir -r requirements.txt # Keep the container running ENTRYPOINT ["tail", "-f", "/dev/null"] # endregion # region - Define our development image FROM base AS development ENV SHELL /bin/bash # Our user will be part of the root group since we're building the development image ARG DOCKER_OS_GROUPID ARG DOCKER_OS_USERID ARG DOCKER_OS_USERNAME RUN set -e -x \ && mkdir -p "/home/.vscode-server/bin" \ && mkdir -p "/home/.vscode-server/extensions" \ && mkdir -p "/home/.vscode-server/extensionsCache" \ && printf "\e[0;105m%s\e[0;0m\n" "Create our ${DOCKER_OS_USERNAME} application user" \ && mkdir -p "/home/${DOCKER_OS_USERNAME}/.vscode-server/bin" \ && mkdir -p "/home/${DOCKER_OS_USERNAME}/.vscode-server/extensions" \ && mkdir -p "/home/${DOCKER_OS_USERNAME}/.vscode-server/extensionsCache" \ # Create the application user home directory && mkdir -p "/home/${DOCKER_OS_USERNAME}" \ # Create our application user group && addgroup "${DOCKER_OS_USERNAME}" --gid "${DOCKER_OS_GROUPID}" \ # Create our application user && adduser \ --system \ --disabled-password \ --gecos "" \ --home "/home/${DOCKER_OS_USERNAME}" \ --uid "${DOCKER_OS_USERID}" \ "${DOCKER_OS_USERNAME}" \ # And, finally, set the correct permissions to the home folder of our user && chown -R "${DOCKER_OS_USERNAME}:${DOCKER_OS_USERNAME}" "/home/${DOCKER_OS_USERNAME}" USER "${DOCKER_OS_USERNAME}" ARG DOCKER_CONTAINER_NAME RUN /bin/bash -c "echo \"PS1='\n\e[0;33m🐳 ${DOCKER_CONTAINER_NAME} \e[0;32mDEV\e[0m - \e[0;36m$(whoami)\e[0m \w # '\" >> /home/${DOCKER_OS_USERNAME}/.bashrc" # Save the bash history in file /home/${OS_USERNAME}/commandhistory/.bash_history # Like this we'll be able to map that folder using a volume in our # compose.yaml file and then make the history persistent. RUN set -e -x \ && SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/home/${DOCKER_OS_USERNAME}/commandhistory/.bash_history" \ && mkdir -p "/home/${DOCKER_OS_USERNAME}/commandhistory" \ && touch "/home/${DOCKER_OS_USERNAME}/commandhistory/.bash_history" \ && echo "${SNIPPET}" >> "/home/${DOCKER_OS_USERNAME}/.bashrc" \ && echo "${SNIPPET}" >> "/home/${DOCKER_OS_USERNAME}/.bashrc" # endregion ``` ### compose.yaml Next to the `Dockerfile`, we'll create our `compose.yaml` one. Please create that file with the following content: **compose.yaml** ```yaml name: app_python services: app_python: build: context: . target: development args: # Version of Python to use - DOCKER_PYTHON_VERSION=${DOCKER_PYTHON_VERSION} # Application root directory in the container (PHP or NGINX) (--app-home) - DOCKER_APP_HOME=${DOCKER_APP_HOME:-/app} # Name of the container to show in the bash prompt - DOCKER_CONTAINER_NAME=${DOCKER_CONTAINER_NAME:-python} # Set OS groupid in your Docker Linux containers (1000 = root) (--os-groupid) - DOCKER_OS_GROUPID=${DOCKER_OS_GROUPID:-1000} # Set OS userid in your Docker Linux containers (1000 = root) (--os-userid) - DOCKER_OS_USERID=${DOCKER_OS_USERID:-1000} # Set OS username in your Docker Linux containers (--os-username) - DOCKER_OS_USERNAME=${DOCKER_OS_USERNAME:-python} user: ${DOCKER_OS_USERID:-1000}:${DOCKER_OS_GROUPID:-1000} env_file: - .docker.env container_name: app_python volumes: # Our codebase on our host - .:${DOCKER_APP_HOME} # Keep installed VSCode extensions in a volume to avoid to reinstall them - vscode-extensions:/home/${DOCKER_OS_USERNAME}/.vscode-server/extensions # Remember the bash history - bashhistory:/home/${DOCKER_OS_USERNAME}/commandhistory volumes: # Use a Docker volume self-managed volume to store vscode's cache vscode-extensions: # Use a Docker volume self-managed volume to store bash history bashhistory: ``` ### .docker.env The third file to create will be called `.docker.env` where we'll initialize some values. Please create that file with the content below: **.docker.env** ```env # Application root directory in the container (PHP or NGINX) (--app-home) DOCKER_APP_HOME=/app # Name of the container to show in the bash prompt DOCKER_CONTAINER_NAME=app # Set OS groupid in your Docker Linux containers (1000 = root) (--os-groupid) DOCKER_OS_GROUPID=1000 # Set OS userid in your Docker Linux containers (1000 = root) (--os-userid) DOCKER_OS_USERID=1000 # Set OS username in your Docker Linux containers (--os-username) DOCKER_OS_USERNAME="python" # Version of Python to use DOCKER_PYTHON_VERSION=3.13-slim ``` > **Information:** > > All you have to do is duplicate the other files we've created for each of your projects and the settings for your project will be made here, in the `.docker.env` file. ### makefile To make life easier, we're going to group together a set of commands in a file called `makefile`. Please create a `makefile` file with the contents below: **makefile** ``` # cspell:ignore ifdef,pydocstyle,isort,mypy default: help # Folder where Python binaries are installed BIN=/usr/local/bin # Name of our Docker container CONTAINER=app_python # The project type DOCKER_PROJECT_TYPE=Python # Name of our Docker environment file DOCKER_ENV_FILE=.docker.env # The environment file we'll use ENV=--env-file ${DOCKER_ENV_FILE} # The list of Docker compose yaml file we'll use YAML=COMPOSE_FILE=compose.yaml COLOR_CYAN:=36 _CYAN := "\033[1;${COLOR_CYAN}m%s\033[0m %s\n" # Required for `make devcontainer`: we need to convert a string like "python" to # an hexadecimal string. (printf "python" | xxd -p will return 63325f66646d5f706870) DOCKER_VSCODE:=$(shell printf "${CONTAINER}" | xxd -p) .PHONY: bash bash: ## App - Start an interactive shell inside your application container ${YAML} docker compose ${ENV} exec -it ${CONTAINER} /bin/bash .PHONY: build build: ## App - Build a fresh Docker image for this project ${YAML} docker compose ${ENV} build ${ARGS} .PHONY: config config: ## App - Show the docker configuration ${YAML} docker compose ${ENV} config .PHONY: devcontainer devcontainer: ## App - Open the PHP container in vscode (you can f.i. directly start your debugging session) code --folder-uri vscode-remote://attached-container+${DOCKER_VSCODE}/app .PHONY: help help: ## App - Show the help with the list of commands @clear # Parse this file, search for `##` followed by a description @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-21s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[0;33m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) @echo "" .PHONY: remove remove: ## App - Stop containers and remove them (you will lose updated files if you do not have locally) ${YAML} docker compose ${ENV} down --remove-orphans --rmi all --volumes .PHONY: up up: ## App - Based on the created Docker images (made with `make build`), create containers and start them ${YAML} docker compose ${ENV} up --detach ##@ Quality assurance .PHONY: doc-style doc-style: ## QA - pydocstyle is a static analysis tool for checking compliance with Python docstring conventions @clear ${YAML} docker compose ${ENV} exec ${CONTAINER} ${BIN}/pydocstyle /app/src .PHONY: format format: ## QA - Format the script using Black @clear @${YAML} docker compose ${ENV} exec ${CONTAINER} ${BIN}/black /app/src .PHONY: lint lint: ## QA - Lint the script using Pylint - Run analyses your code without actually running it @clear ${YAML} docker compose ${ENV} exec ${CONTAINER} ${BIN}/pylint /app/src .PHONY: mypy mypy: ## QA - Mypy is a program that will type check your Python code @clear ${YAML} docker compose ${ENV} exec ${CONTAINER} ${BIN}/mypy --cache-dir /tmp/mypy ``` > **Caution:** > > If you don't know if you already have `GNU make`, just run `which make` in the console. If you see `make not found` then please run `sudo apt-get update && sudo apt-get install make` to proceed with the installation. ## What have we done so far? We have created the minimal structure for our future Python projects. We've defined our Docker image (thanks to the `Dockerfile` file) and how to use it (`compose.yaml` file). We've also configured our project (`.docker.env` file) and set up a number of useful commands (`makefile` file). All these files can be used across all your Python projects. ## Now, let's create our devcontainer environment We have to create an additional file. Please create a new folder called `.devcontainer` and, there, a file called `devcontainer.json`. Copy/paste the content below: **.devcontainer/devcontainer.json** ```json { "name": "app_python", "dockerComposeFile": [ "./../compose.yaml", ], "service": "app_python", "remoteUser": "python", "workspaceFolder": "/app", "customizations": { "vscode": { "extensions": [ "aaron-bond.better-comments", "DavidAnson.vscode-markdownlint", "eamodio.gitlens", "EricSia.pythonsnippets3", "foxundermoon.shell-format", "gruntfuggly.todo-tree", "mde.select-highlight-minimap", "mgesbert.python-path", "mikestead.dotenv", "mrmlnc.vscode-duplicate", "ms-azuretools.vscode-docker", "ms-python.black-formatter", "ms-python.debugpy", "ms-python.isort", "ms-python.mypy-type-checker", "ms-python.pylint", "ms-python.python", "ms-python.vscode-pylance", "ms-vscode-remote.remote-containers", "ms-vscode.makefile-tools", "njpwerner.autodocstring", "redhat.vscode-xml", "redhat.vscode-yaml", "sirtori.indenticator", "sonarsource.sonarlint-vscode", "streetsidesoftware.code-spell-checker-dutch", "streetsidesoftware.code-spell-checker-french", "streetsidesoftware.code-spell-checker", "tomoki1207.pdf", "tyriar.sort-lines" ], "settings": { "[dockerfile]": { "files.eol": "\n", "editor.defaultFormatter": "ms-azuretools.vscode-docker" }, "[json]": { "editor.defaultFormatter": "vscode.json-language-features" }, "[jsonc]": { "editor.defaultFormatter": "vscode.json-language-features", "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 80, "editor.wrappingIndent": "indent" }, "[markdown]": { "editor.defaultFormatter": "DavidAnson.vscode-markdownlint", "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 80, "editor.wrappingIndent": "indent" }, "[python]": { "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll": "always", "source.organizeImports": "always" } }, "[shellscript]": { "editor.defaultFormatter": "foxundermoon.shell-format" }, "[xml]": { "editor.defaultFormatter": "redhat.vscode-xml", "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 80, "editor.wrappingIndent": "indent" }, "[yaml]": { "editor.defaultFormatter": "redhat.vscode-yaml", "editor.insertSpaces": true, "editor.tabSize": 2 }, "cSpell.language": "en,fr,nl", "docker-explorer.enableTelemetry": false, "editor.bracketPairColorization.enabled": true, "editor.codeActionsOnSave": { "source.fixAll": "explicit" }, "editor.defaultFoldingRangeProvider": null, "editor.detectIndentation": false, "editor.folding": true, "editor.foldingStrategy": "auto", "editor.formatOnSave": true, "editor.guides.bracketPairs": "active", "editor.guides.bracketPairsHorizontal": "active", "editor.guides.highlightActiveIndentation": true, "editor.guides.indentation": true, "editor.multiCursorModifier": "ctrlCmd", "editor.renderWhitespace": "all", "editor.rulers": [ 120, 70 ], "editor.stickyScroll.enabled": true, "editor.tabCompletion": "on", "editor.tabSize": 4, "editor.wordBasedSuggestionsMode": "allDocuments", "editor.wordWrapColumn": 120, "explorer.compactFolders": false, "explorer.confirmDelete": true, "explorer.confirmDragAndDrop": true, "extensions.autoCheckUpdates": false, "files.autoSave": "onFocusChange", "files.defaultLanguage": "${activeEditorLanguage}", "files.eol": "\n", "files.exclude": { "**/.cache": true, "**/.git": true }, "files.insertFinalNewline": true, "files.trimTrailingWhitespace": true, "markdown.extension.toc.levels": "2..6", "markdownlint.config": { "MD033": false, "MD036": false }, "mypy-type-checker.args": [ "--config-file=.config/.mypy.ini" ], "python.analysis.autoFormatStrings": true, "python.analysis.autoImportCompletions": true, "python.analysis.autoIndent": true, "python.analysis.autoSearchPaths": true, "python.analysis.fixAll": [ "source.convertImportFormat" ], "python.analysis.typeCheckingMode": "strict", "python.defaultInterpreterPath": "/usr/local/bin/python3", "python.formatting.provider": "black", "python.languageServer": "Pylance", "python.linting.enabled": true, "python.linting.flake8Enabled": true, "python.linting.pylintEnabled": true, "python.sortImports.args": [ "--profile", "black" ], "redhat.telemetry.enabled": false, "sonarlint.output.showAnalyzerLogs": true, "sonarlint.disableTelemetry": true, "sonarlint.rules": { "docker:S7031": { "level": "off" } }, "telemetry.telemetryLevel": "off", "terminal.integrated.profiles.linux": { "bash": { "path": "/bin/bash", "icon": "terminal-bash" } }, "terminal.integrated.defaultProfile.linux": "bash", "terminal.integrated.fontFamily": "MesloLGS NF", "update.enableWindowsBackgroundUpdates": false, "update.mode": "none", "yaml.format.enable": true, "yaml.completion": true, "yaml.validate": true, "workbench.editor.enablePreview": false, "workbench.editor.wrapTabs": true } } } } ``` This file is very long; it can be shorter, but the idea is to configure VSCode, so we've specified all the extensions we want in our coding environment, along with a bunch of settings. We've finished our set-up. Here is our final project's structure: ![Our final project's structure](./images/final-structure.webp) ## Start the project in a devcontainer If VSCode is still open, please close it. If you're still in the container's console, please type `exit` to go back to your host console. Now, please run `make devcontainer` (on your host machine thus), which is one of the commands we already have in our `makefile`. VSCode will start and open the project directly *inside* the container: ![VSCode - Devcontainer](./images/vscode_devcontainer.webp) VSCode will ask (see bottom right) to install the Microsoft Python extension; just allow this and click on `Install`. VSCode will also ask if you want to install recommended extensions; please do it. ![Install recommended extensions](./images/vscode_install_extensions.webp) ## Conclusion You now have a fully working coding environment for Python. Thanks to our Docker image, Python has been installed and configured to run in a Docker container (understand: nothing was installed on your machine) and thanks to the Devcontainer, you're sure that VSCode is properly configured with all required extensions to work with ease. Running Windows and want the exact same files adapted for it? See [Docker - Easy setup of Python under Windows](/blog/docker-python-devcontainer-windows). Prefer to skip creating files by hand entirely? [Docker - Even easier setup of Python](/blog/docker-python-devcontainer-microsoft) uses VSCode's built-in wizard instead. --- # Docker - Easy setup of Python under Windows > Easily set up a Python development environment on Windows using Docker and VSCode devcontainers. Start coding instantly without installing Python locally. --- ![Docker - Easy setup of Python under Windows](/img/v2/devcontainer.webp) **TL;DR** This article ports the earlier Linux Python devcontainer setup to Windows: the same `Dockerfile`, `compose.yaml`, `.docker.env` and `.devcontainer/devcontainer.json` files are reused unchanged, but GNU Make is replaced with a `make.bat` DOS batch script so commands like `make build`, `make up`, `make bash`, and `make devcontainer` work identically from an MS-DOS prompt β€” giving a Python coding environment with no local Python install. In a [previous article](/blog/docker-python-devcontainer), I've provided some files to be able to quickly create a Python environment under Linux. Today, let's play and use the exact same files but this time under Windows. Today's challenge is easy: create a Python environment on my Windows machine without having to install Python, of course, and without having to configure VSCode. Just run some magic and, voilΓ , as a Python newcomer, I can start coding without first losing time configuring my computer. *As soon as you write real code in it, add the tools from [Python - Code Quality tools](/blog/python-qa): they run inside the container too, so your Windows machine stays untouched.* So, in the [Docker - Python devcontainer](/blog/docker-python-devcontainer) blog post, I've provided a few files and we'll reuse them, unchanged, in this article. Assume they're already sitting in a project folder on your machine and you've opened an MS-DOS console positioned there β€” here's what happens when you run the same commands as under Linux, straight from DOS. ## Time to start All the commands below should be started from a MS-DOS command prompt and you should be located in your project's folder (`cd %USERPROFILE%\Documents\Python`). ### Build the Python Docker image The first thing to do, only once, is to create the Docker image. This is done by running `make build`: ![Building the Docker image](./images/make_build.webp) ### Create a Docker container Then, once a day, you have to run `make up`: ![Create a Docker container](./images/make_up.webp) You have to do this just once a day, i.e. most probably the container will be terminated when you shut down your computer. Just run `make up` the next morning to *wake* it back up. ### Entering the container If you need to enter your Docker container (started by `make up`), just run `make bash`. You'll get a different console like the one illustrated below (see the blue whale f.i.). The screenshot below illustrates displaying Python's version number: ![Entering the container](./images/make_bash.webp) Remember the files we've created in the previous chapter. One file was called `main.py` with a straightforward Python script; let's run it: ![Running main.py](./images/make_bash_main.webp) You simply need to run the `python` binary followed by the script name to start. > **Information:** > > So, you're actually inside a running container (see the blue whale). You should type `exit` to quit the container (the container will still keep running) and return to your MS-DOS console. ## Starting VSCode and start your developer journey Just run `make devcontainer` (in your MS-DOS console; not in a container), you'll start Visual Studio Code inside your Docker container. That feature is called *devcontainer*. ![Running VSCode - Devcontainer](./images/make_devcontainer.webp) In VSCode, you can, if you like this way of working, press `CTRL`+`` ` `` to start a terminal (you can also click on the `View` menu then click on `Terminal` menu entry; same thing). ![Using the VSCode Terminal](./images/vscode_terminal.webp) So, here, if you want to run the `main.py` script, just type `python src/main.py` and press `Enter`. ![Running the main.py script from the terminal](./images/vscode_terminal_running.webp) > **Information:** > > For Windows users, please note that the container (and thus VSCode) is running in Linux. There are a lot of differences for sure. What you should know is that Linux use `/` as directory separator and not `\` like DOS/Windows. This is why we've typed `src/main.py` and not `src\main.py`. ### Can I add Python's addons in VSCode? For sure, just go to the list of addons (press `CTRL`+`SHIFT`+`X`) and ... as you can see, some are already installed. This is one of the advantages of using a Devcontainer like you do right now (see the `.devcontainer/devcontainer.json` file you've created earlier). ![VScode Addons](./images/vscode_addons.webp) I'm speaking about magic, but let's go into detail now and see how that file/folder structure is actually created. I could provide you with a ZIP archive, for example, with all the files already created and the structure, but that would be less ... fun, wouldn't it? ## Create a file/folder structure Please create a folder on your Windows machine, f.i. let's create a folder called `Python` in your documents (in MS-DOS, here is the command to run `mkdir %USERPROFILE%\Documents\Python` and jump in it: `cd %USERPROFILE%\Documents\Python`). Of course, you can start Windows Explorer, go to your *Documents* folder and create the folder and all the files like that: ![Windows Explorer](./images/explorer.webp) Please create all required files (the ones from the previous blog post). We have NOT modified these files: we can thus use the exact same files whether we're running under Linux or Windows. Just add a new file on your Windows folder and copy/paste the content from here below. Copy/paste the content below in a file on your system called `.devcontainer/devcontainer.json`. **.devcontainer/devcontainer.json** ```json { "name": "app_python", "dockerComposeFile": [ "./../compose.yaml", ], "service": "app_python", "remoteUser": "python", "workspaceFolder": "/app", "customizations": { "vscode": { "extensions": [ "aaron-bond.better-comments", "DavidAnson.vscode-markdownlint", "eamodio.gitlens", "EricSia.pythonsnippets3", "foxundermoon.shell-format", "gruntfuggly.todo-tree", "mde.select-highlight-minimap", "mgesbert.python-path", "mikestead.dotenv", "mrmlnc.vscode-duplicate", "ms-azuretools.vscode-docker", "ms-python.black-formatter", "ms-python.debugpy", "ms-python.isort", "ms-python.mypy-type-checker", "ms-python.pylint", "ms-python.python", "ms-python.vscode-pylance", "ms-vscode-remote.remote-containers", "ms-vscode.makefile-tools", "njpwerner.autodocstring", "redhat.vscode-xml", "redhat.vscode-yaml", "sirtori.indenticator", "sonarsource.sonarlint-vscode", "streetsidesoftware.code-spell-checker-dutch", "streetsidesoftware.code-spell-checker-french", "streetsidesoftware.code-spell-checker", "tomoki1207.pdf", "tyriar.sort-lines" ], "settings": { "[dockerfile]": { "files.eol": "\n", "editor.defaultFormatter": "ms-azuretools.vscode-docker" }, "[json]": { "editor.defaultFormatter": "vscode.json-language-features" }, "[jsonc]": { "editor.defaultFormatter": "vscode.json-language-features", "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 80, "editor.wrappingIndent": "indent" }, "[markdown]": { "editor.defaultFormatter": "DavidAnson.vscode-markdownlint", "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 80, "editor.wrappingIndent": "indent" }, "[python]": { "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.fixAll": "always", "source.organizeImports": "always" } }, "[shellscript]": { "editor.defaultFormatter": "foxundermoon.shell-format" }, "[xml]": { "editor.defaultFormatter": "redhat.vscode-xml", "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 80, "editor.wrappingIndent": "indent" }, "[yaml]": { "editor.defaultFormatter": "redhat.vscode-yaml", "editor.insertSpaces": true, "editor.tabSize": 2 }, "cSpell.language": "en,fr,nl", "docker-explorer.enableTelemetry": false, "editor.bracketPairColorization.enabled": true, "editor.codeActionsOnSave": { "source.fixAll": "explicit" }, "editor.defaultFoldingRangeProvider": null, "editor.detectIndentation": false, "editor.folding": true, "editor.foldingStrategy": "auto", "editor.formatOnSave": true, "editor.guides.bracketPairs": "active", "editor.guides.bracketPairsHorizontal": "active", "editor.guides.highlightActiveIndentation": true, "editor.guides.indentation": true, "editor.multiCursorModifier": "ctrlCmd", "editor.renderWhitespace": "all", "editor.rulers": [ 120, 70 ], "editor.stickyScroll.enabled": true, "editor.tabCompletion": "on", "editor.tabSize": 4, "editor.wordBasedSuggestionsMode": "allDocuments", "editor.wordWrapColumn": 120, "explorer.compactFolders": false, "explorer.confirmDelete": true, "explorer.confirmDragAndDrop": true, "extensions.autoCheckUpdates": false, "files.autoSave": "onFocusChange", "files.defaultLanguage": "${activeEditorLanguage}", "files.eol": "\n", "files.exclude": { "**/.cache": true, "**/.git": true }, "files.insertFinalNewline": true, "files.trimTrailingWhitespace": true, "markdown.extension.toc.levels": "2..6", "markdownlint.config": { "MD033": false, "MD036": false }, "mypy-type-checker.args": [ "--config-file=.config/.mypy.ini" ], "python.analysis.autoFormatStrings": true, "python.analysis.autoImportCompletions": true, "python.analysis.autoIndent": true, "python.analysis.autoSearchPaths": true, "python.analysis.fixAll": [ "source.convertImportFormat" ], "python.analysis.typeCheckingMode": "strict", "python.defaultInterpreterPath": "/usr/local/bin/python3", "python.formatting.provider": "black", "python.languageServer": "Pylance", "python.linting.enabled": true, "python.linting.flake8Enabled": true, "python.linting.pylintEnabled": true, "python.sortImports.args": [ "--profile", "black" ], "redhat.telemetry.enabled": false, "sonarlint.output.showAnalyzerLogs": true, "sonarlint.disableTelemetry": true, "sonarlint.rules": { "docker:S7031": { "level": "off" } }, "telemetry.telemetryLevel": "off", "terminal.integrated.profiles.linux": { "bash": { "path": "/bin/bash", "icon": "terminal-bash" } }, "terminal.integrated.defaultProfile.linux": "bash", "terminal.integrated.fontFamily": "MesloLGS NF", "update.enableWindowsBackgroundUpdates": false, "update.mode": "none", "yaml.format.enable": true, "yaml.completion": true, "yaml.validate": true, "workbench.editor.enablePreview": false, "workbench.editor.wrapTabs": true } } } } ``` Copy/paste the content below in a file on your system called `src/main.py`. **src/main.py** ```python print("I'm your Python code") ``` **src/requirements.txt** ```txt Yes, an empty file... The file has to be present but right now, we don't need to put any dependencies. ``` Copy/paste the content below in a file on your system called `.docker.env` (in Linux world; such files are hidden ones since they start with a dot). **.docker.env** ```env # Application root directory in the container (PHP or NGINX) (--app-home) DOCKER_APP_HOME=/app # Name of the container to show in the bash prompt DOCKER_CONTAINER_NAME=app # Set OS groupid in your Docker Linux containers (1000 = root) (--os-groupid) DOCKER_OS_GROUPID=1000 # Set OS userid in your Docker Linux containers (1000 = root) (--os-userid) DOCKER_OS_USERID=1000 # Set OS username in your Docker Linux containers (--os-username) DOCKER_OS_USERNAME="python" # Version of Python to use DOCKER_PYTHON_VERSION=3.13-slim ``` Copy/paste the content below in a file on your system called `compose.yaml`. **compose.yaml** ```yaml name: app_python services: app_python: build: context: . target: development args: # Version of Python to use - DOCKER_PYTHON_VERSION=${DOCKER_PYTHON_VERSION} # Application root directory in the container (PHP or NGINX) (--app-home) - DOCKER_APP_HOME=${DOCKER_APP_HOME:-/app} # Name of the container to show in the bash prompt - DOCKER_CONTAINER_NAME=${DOCKER_CONTAINER_NAME:-python} # Set OS groupid in your Docker Linux containers (1000 = root) (--os-groupid) - DOCKER_OS_GROUPID=${DOCKER_OS_GROUPID:-1000} # Set OS userid in your Docker Linux containers (1000 = root) (--os-userid) - DOCKER_OS_USERID=${DOCKER_OS_USERID:-1000} # Set OS username in your Docker Linux containers (--os-username) - DOCKER_OS_USERNAME=${DOCKER_OS_USERNAME:-python} user: ${DOCKER_OS_USERID:-1000}:${DOCKER_OS_GROUPID:-1000} env_file: - .docker.env container_name: app_python volumes: # Our codebase on our host - .:${DOCKER_APP_HOME} # Keep installed VSCode extensions in a volume to avoid to reinstall them - vscode-extensions:/home/${DOCKER_OS_USERNAME}/.vscode-server/extensions # Remember the bash history - bashhistory:/home/${DOCKER_OS_USERNAME}/commandhistory volumes: # Use a Docker volume self-managed volume to store vscode's cache vscode-extensions: # Use a Docker volume self-managed volume to store bash history bashhistory: ``` Copy/paste the content below in a file on your system called `Dockerfile`. **Dockerfile** ```docker # syntax=docker/dockerfile:1 # cspell:ignore PYTHONDONTWRITEBYTECODE,PYTHONUNBUFFERED,HISTFILE # cspell:ignore addgroup,adduser,keyscan,hadolint,gecos,endregion # Those variables are initialized in the .docker.env file ARG DOCKER_APP_HOME="/app" ARG DOCKER_CONTAINER_NAME="python" ARG DOCKER_OS_GROUPID=1000 ARG DOCKER_OS_USERID=1000 ARG DOCKER_OS_USERNAME="python" ARG DOCKER_PYTHON_VERSION=3.10-slim # region - Our Python base image. We'll install Linux and Python dependencies here # and do some other configuration work FROM python:${DOCKER_PYTHON_VERSION} AS base # Prevents Python from writing pyc files. ENV PYTHONDONTWRITEBYTECODE=1 # Keeps Python from buffering stdout and stderr to avoid situations where # the application crashes without emitting any logs due to buffering. ENV PYTHONUNBUFFERED=1 ARG DOCKER_APP_HOME WORKDIR "${DOCKER_APP_HOME}/src" # hadolint ignore=DL3008 RUN --mount=type=cache,target=/var/cache/apk,rw \ set -e -x \ && printf "\e[0;105m%s\e[0;0m\n" "Install required Linux binaries..." \ && apt-get update -yqq \ && apt-get install -y --no-install-recommends bash git openssh-client tree \ && apt-get clean \ && rm -rf /tmp/* /var/lib/apt/lists/* # Install Python dependencies # # Download dependencies as a separate step to take advantage of Docker's caching. # Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. # Leverage a bind mount to requirements.txt to avoid having to copy them into # into this layer. RUN --mount=type=cache,target=/root/.cache/pip \ --mount=type=bind,source=src/requirements.txt,target=requirements.txt \ printf "\e[0;105m%s\e[0;0m\n" "Install Python dependencies" \ && python -m pip install --no-cache-dir -r requirements.txt # Keep the container running ENTRYPOINT ["tail", "-f", "/dev/null"] # endregion # region - Define our development image FROM base AS development ENV SHELL /bin/bash # Our user will be part of the root group since we're building the development image ARG DOCKER_OS_GROUPID ARG DOCKER_OS_USERID ARG DOCKER_OS_USERNAME RUN set -e -x \ && mkdir -p "/home/.vscode-server/bin" \ && mkdir -p "/home/.vscode-server/extensions" \ && mkdir -p "/home/.vscode-server/extensionsCache" \ && printf "\e[0;105m%s\e[0;0m\n" "Create our ${DOCKER_OS_USERNAME} application user" \ && mkdir -p "/home/${DOCKER_OS_USERNAME}/.vscode-server/bin" \ && mkdir -p "/home/${DOCKER_OS_USERNAME}/.vscode-server/extensions" \ && mkdir -p "/home/${DOCKER_OS_USERNAME}/.vscode-server/extensionsCache" \ # Create the application user home directory && mkdir -p "/home/${DOCKER_OS_USERNAME}" \ # Create our application user group && addgroup "${DOCKER_OS_USERNAME}" --gid "${DOCKER_OS_GROUPID}" \ # Create our application user && adduser \ --system \ --disabled-password \ --gecos "" \ --home "/home/${DOCKER_OS_USERNAME}" \ --uid "${DOCKER_OS_USERID}" \ "${DOCKER_OS_USERNAME}" \ # And, finally, set the correct permissions to the home folder of our user && chown -R "${DOCKER_OS_USERNAME}:${DOCKER_OS_USERNAME}" "/home/${DOCKER_OS_USERNAME}" USER "${DOCKER_OS_USERNAME}" ARG DOCKER_CONTAINER_NAME RUN /bin/bash -c "echo \"PS1='\n\e[0;33m🐳 ${DOCKER_CONTAINER_NAME} \e[0;32mDEV\e[0m - \e[0;36m$(whoami)\e[0m \w # '\" >> /home/${DOCKER_OS_USERNAME}/.bashrc" # Save the bash history in file /home/${OS_USERNAME}/commandhistory/.bash_history # Like this we'll be able to map that folder using a volume in our # compose.yaml file and then make the history persistent. RUN set -e -x \ && SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/home/${DOCKER_OS_USERNAME}/commandhistory/.bash_history" \ && mkdir -p "/home/${DOCKER_OS_USERNAME}/commandhistory" \ && touch "/home/${DOCKER_OS_USERNAME}/commandhistory/.bash_history" \ && echo "${SNIPPET}" >> "/home/${DOCKER_OS_USERNAME}/.bashrc" \ && echo "${SNIPPET}" >> "/home/${DOCKER_OS_USERNAME}/.bashrc" # endregion ``` ## Create our DOS batch file Here the difference with Linux: instead of using GNU Make, we'll simply use a MS-DOS batch script. We'll call it `make.bat` so we can use actions like `make build` or `make up` as we are used to under Linux; but this time, under DOS. Please create this additional file: **make.bat** ```bat @echo off cls if "%1"=="" goto :help set APP_NAME=app_python REM The code below is the hex representation of the APP_NAME here above i.e. "app_python" REM You can get that value by running "make bash" then 'python -c "print('app_python'.encode().hex())"' REM Needed by the "make devcontainer" action set DEV_CODE=6170705f707974686f6e if "%1"=="bash" goto bash if "%1"=="build" goto build if "%1"=="devcontainer" goto devcontainer if "%1"=="up" goto up if "%1"=="start" goto start "%2" call :error "Invalid action." goto :help :help echo Usage: %0 command echo. echo Available Commands: echo. echo bash Enter the Docker Python container echo build Build the Python Docker image (to be done only once) echo devcontainer Start VSCode and open the Docker container echo up Create and start the Docker container (to be done once a day if you've turned off your computer) echo start Start a Python script in the Docker container (call it like 'make start main.py') goto :eof :bash echo Entering in our %APP_NAME% Docker Python container... Type exit to quit the session. docker compose --env-file .docker.env exec -it %APP_NAME% /bin/bash goto :eof :build echo Building our Python Docker image docker compose --env-file .docker.env build goto :eof :devcontainer echo Start vscode and open the Docker container code --folder-uri vscode-remote://attached-container+%DEV_CODE%/app goto :eof :up echo Create our container... docker compose --env-file .docker.env up --detach goto :eof :start if "%2"=="" ( call :error "Please specify the name of the script to execute, f.i. 'make start main.py'" goto :eof ) docker compose --env-file .docker.env exec -it %APP_NAME% python %2 goto :eof :error REM Trim double quotes from the beginning and end of the message set "ERROR_MESSAGE=%*" set "ERROR_MESSAGE=%ERROR_MESSAGE:~1,-1%" echo ERROR - %ERROR_MESSAGE% echo. goto :eof :eof ``` ## Our configuration right now At this stage, here is our project in VSCode: ![The project in VSCode](./images/vscode_setup.webp) And here's how we can see it in Windows Explorer: ![Windows Explorer](./images/explorer.webp) Start a MS-DOS console (i.e. press the `Windows` key on your keyboard or click on the Start Menu, then start to type `cmd`) ![Running MS DOS](./images/cmd.webp) Open the console and go to your project's folder i.e. run `cd %USERPROFILE%\Documents\Python`. Below is how the project looks right now: ![How the project looks like in DOS](./images/msdos.webp) ## Can I use another version of Python? For sure, just edit the file called `.docker.env` and you'll find this line: `DOCKER_PYTHON_VERSION=3.13-slim`. Just change the version number, save the file and run `make build` then `make up` and, finally, `make devcontainer` to rebuild everything and use the newer version. --- # Docker - Even easier setup of Python > Streamline your Python development. This guide shows you how to use Docker and Microsoft Devcontainers for an incredibly easy and reproducible Python environment setup. --- ![Docker - Even easier setup of Python](/img/v2/devcontainer.webp) **TL;DR** This article shows the fastest way yet to get a Python devcontainer: with Docker and the VSCode Docker extension installed, use the Command Palette's built-in "Dev Containers: Add Dev Container Configuration files..." wizard to auto-generate `.devcontainer/devcontainer.json` for Python, then click "Reopen in Container" β€” no manual file creation needed at all. Nothing to install, nothing to configure, nothing to create first. In [a previous article](/blog/docker-python-devcontainer) (and its [Windows-specific follow-up](/blog/docker-python-devcontainer-windows)), I built the `Dockerfile`, `compose.yaml` and `devcontainer.json` files by hand. Turns out VSCode can generate all of that for you. Here is how to use VSCode and Docker to create a ready-to-use Python environment, whether you're under Windows, Linux, or Mac. *The wizard is the fastest route, but writing the files yourself is what lets you control exactly what goes into the image β€” and keep development tooling out of production, as explained in [One Docker Image for Production and Devcontainers - The Clean Way](/blog/docker-prod-devcontainer).* **The only prerequisites are:** 1. You should have Docker installed on your system 2. You should have Visual Studio Code 3. You've installed the Docker for Visual Studio Code addon in VSCode Here's the payoff. Once the devcontainer is up, create a new file called `main.py` with this line `print("Hello from your Python Devcontainer!")`. Save the file. Click in the terminal and type `python main.py` to execute the script and tadaaa: ![Running the script](./images/running_the_script.webp) A working Python environment, terminal and interpreter ready, and not a single file created by hand. Here's how VSCode builds it for you. **If you have these three things, just do this:** 1. Start Visual Studio Code 2. Press CTRL+SHIFT+P to open the Command Palette. 3. Select **Dev Containers: Add Dev Container Configuration files...** 4. And follow the wizard: * Search for **Python** * Select the most recent version of Python, right now it's `3.13-bookworm` * No need to install additional features, just press Enter * Same for optional files; not needed, just press Enter This done, VSCode will create a file called `.devcontainer/devcontainer.json`. ![VSCode has created the .devcontainer/devcontainer.json file](./images/devcontainer_created.webp) See bottom right, please click on the `Reopen in Container` button. Depending on the speed of your computer and if things were already downloaded, you'll get this screen: ![VSCode and its terminal](./images/terminal.webp) See the bottom part, a terminal window has been displayed and you've a prompt showing `vscode -> /workspaces/python $`. Click in the terminal and type `python --version`: ![Version](./images/version.webp) --- # One Docker Image for Production and Devcontainers - The Clean Way > Learn how to design a Docker setup that delivers a secure, minimal production image while enabling effortless local development through VS Code Devcontainers with clean overrides and environment isolation --- ![One Docker Image for Production and Devcontainers - The Clean Way](/img/v2/docker_workflow_prod_devcontainer.webp) **TL;DR** This article presents a clean method for using a single Docker image for both production deployments and VS Code Devcontainers. The approach involves building a minimal, secure production image first, then extending it with development tools, specific user configurations, and overrides for a separate devcontainer image. This ensures a lightweight production environment with a reduced attack surface, while providing a rich and isolated development experience. This article is the result of intense reflection: how to define a Docker image for deployment in production as cleanly as possible and, while avoiding copy/paste and other repetitions as much as possible, create an image for development in devcontainer. In other words: I want to create the lightest and most secure image possible for deployment in production, but of course, I need to be able to develop this image in my VSCode environment, and for that, I need my development tools, a specific Linux user to synchronize files with my host and avoid permissions problems, etc. After multiple attempts, here is what I came up with, which seems to meet this need. > **A few core concepts to keep in mind while reading this article:** > > 1. We must completely separate future production from our development (devcontainer). > 2. We will need to create the production Docker image before we can use the devcontainer. ## What does a perfect separation of environments mean? Our first instinct should always be: do we need this (utility, configuration, user, etc.) in the Docker image that we are going to deploy on the server? Some examples: * Development tools such as a linter, formatter, [code quality tool](/blog/php-jakzal-phpqa), debugger, etc. have no place in the image that will be deployed. * Creating a user that will allow file synchronization with the host has no place in the image that will be deployed. * ... Our image must be clean, as efficient and lightweight as possible... We must minimize what is known as "attack surface" i.e. to reduce the number of tools we'll install in order to reduce security risks. Everything we need for our development will then have to be part of a second Docker image, which we will use when we activate the devcontainer feature in VSCode. This is where we will create our user, add development tools, and activate certain configurations such as debugging, etc. If you have never set one up before, [PHP development in a devcontainer with preinstalled code quality tools](/blog/vscode-devcontainer) walks through a complete devcontainer from scratch. > **So, in short, we'll have two Docker images:** > > One for the deploy and a second one to extend the first with additional tools and configuration items. That one will never leave our host and we'll never build it ourselves but let VSCode do it for us. ![Core concept - Two images](./images/production_devcontainer.webp) ## The Result One Dockerfile, no copy-paste, and both images build from the exact same instructions: a minimal, rootless production container, and a devcontainer that extends it with the tools you need to develop. Here's the production container answering on `http://localhost:`8000: > Screenshot β€” http\://localhost:8000 > > {"message":"Hello, FastAPI - PRODUCTION!"} ![The production image has been created](./images/prod_image_created.webp) The steps below build exactly this, from an empty folder, then extend it into a devcontainer. ## Step 1 - We have to create the production docker image Please create a new folder in your temporary folder for our example. ```bash $ mkdir -p /tmp/docker-prod-devcontainer && cd $_ ``` ### Let's create our production files Let's create a few files (remember, for the production Docker image). #### The production .env file For this article, we'll create a Python script so we'll have to specify the version of Python we want. This is just for the example but, in your case, this `.env` will be used to put there all the settings you want for your own production image. So, even if you don't see yet why you have to use this file in your own use case, just keep it even if you have no variables to put inside. **/tmp/docker-prod-devcontainer/.env** ``` APP_HOME="/app" PYTHON_VERSION=3.13 TIMEZONE="Europe/Brussels" ``` #### The production Dockerfile file The provided file is an example you can reuse. During this article, as an example, we'll create a small Python script to illustrate some things. **/tmp/docker-prod-devcontainer/Dockerfile** ```docker # cspell:disable # Production Dockerfile ARG DOCKER_APP_HOME="/app" ARG PYTHON_VERSION=3.13 ARG TIMEZONE="Europe/Brussels" FROM python:${PYTHON_VERSION}-slim ARG TIMEZONE ENV TZ=${TIMEZONE} RUN --mount=type=cache,target=/var/cache/apt,rw \ set -eux \ && printf "\e[0;105m%s\e[0;0m\n" "Install required Linux binaries..." \ && apt-get update -yqq \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ locales \ tini \ && echo "en_US.UTF-8 UTF-8" > /etc/locale.gen \ && locale-gen en_US.UTF-8 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* ENV LANG=en_US.UTF-8 \ LANGUAGE=en_US:en \ LC_ALL=en_US.UTF-8 ARG DOCKER_APP_HOME WORKDIR "${DOCKER_APP_HOME}" COPY requirements.txt . RUN --mount=type=cache,target=/root/.cache/pip \ set -eux \ && printf "\e[0;105m%s\e[0;0m\n" "Install Python prod dependencies..." \ && pip install -r requirements.txt # Make sure installed dependencies are in the PATH ENV PATH="/root/.local/bin:$PATH" COPY . . ENTRYPOINT ["/usr/bin/tini", "--"] ENV PYTHONPATH="${DOCKER_APP_HOME}/src" CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "--worker-class", "uvicorn.workers.UvicornWorker", "main:app"] ``` Look at the provided script: 1. We'll define the version to use for Python 2. We'll configure some environment variables like the time zone, ... 3. We'll install Linux dependencies we need for the production (**but make sure to not add here dependencies we just need for the local development**) 4. Because it's a Python example, we'll copy the `requirements.txt` file in the Docker image and install packages 5. We'll copy all our files inside the Docker image (make sure to correctly configure your `.dockerignore` file) 6. And, finally, for the production image, we'll initialize the entry point and the command to keep > **Caution:** > > As you can see, we've not created here, in the Docker image, an unprivileged user. Our production image is thus violating a rule we all have read: **Your Docker image should be rootless**. > > This is a key concept here: > > * The user will be configured while deploying the image using tools like Kubernetes f.i., > * When we'll use the Docker image for local development, the user UID/GID has to match the one of the developers (and we don't know right now if it's `1000:1000` or something else), > * The user can be easily specified in the `compose.yaml` file. #### The production compose.yaml file The `compose.yaml` file is used by Docker compose (which is a part of Docker) to know how to build your image and how to create your container. This is a very important file since we'll define here all the required configurations, we'll specify our `.env` file so Docker can translate variables to their values, ... We'll also tell Docker which port of the running container has to be shared with our host, ... **/tmp/docker-prod-devcontainer/compose.yaml** ```yaml name: my_sample services: app: build: context: . dockerfile: Dockerfile args: - DOCKER_APP_HOME=${APP_HOME:-/app} - PYTHON_VERSION=${PYTHON_VERSION:-3.13} - TIMEZONE=${TIMEZONE:-Europe/Brussels} image: my_sample_prod:latest container_name: app env_file: .env ports: - "8000:8000" user: "1001:1001" security_opt: - no-new-privileges:true ``` > **Caution:** > > Pay attention to the `user:` and `security_opt:` parts. > > It tells Docker to use a user having UID/GID `1001` when creating a container based on our image. This user ... didn't exist in the image so why? > > This is a trick to make sure the container is running rootless i.e. the user in the container is an unprivileged one. > > As extra security measure, we have the `security_opt: - no-new-privileges:true` line to prevent the container from gaining any additional privileges. > > In short: when a container will be created based on our image, the user will be able to just run what we've decided for him, nothing more. #### The production Python requirements file We've seen a mention to this file in our `Dockerfile`: because we're using Python as an example for this blog post, we need a `requirements.txt` file to declare which dependencies our script needs. In our example, we need FastAPI dependencies: **/tmp/docker-prod-devcontainer/requirements.txt** ```txt fastapi gunicorn uvicorn[standard] ``` > **Information:** > > Look at this file once more: we only have, strictly speaking, the dependencies we need for production. If your own use case isn't a Python script, for sure, you should not create this `requirements.txt` file (but, then, also update the `Dockerfile` and remove that part). #### The production Python script And finally, we'll create, for the demo, a FastAPI Python script that will return a simple JSON answer; pretty straightforward: **/tmp/docker-prod-devcontainer/src/main.py** ```python from fastapi import FastAPI app = FastAPI() @app.get("/") def read_root(): return {"message": "Hello, FastAPI - PRODUCTION!"} ``` #### Summary for the production files So at this point, our project looks like this i.e. we have five files. ```tree expanded=true showJSX=false debug=false title="docker-prod-devcontainer" β”œβ”€ .env β”œβ”€β”€ Dockerfile β”œβ”€β”€ compose.yaml β”œβ”€β”€ requirements.txt └── src └── main.py ``` ### Creating the Docker production image Jump in a Linux console and run the command below to see if everything is fine i.e. if there are no error and f.i. our `.env` file where we've specified the version to use for Python is well retrieved and used. ```bash $ docker compose config ``` You'll get something like this: **console output** ``` name: my_sample services: app: build: context: /tmp/docker-prod-devcontainer dockerfile: Dockerfile args: APP_HOME: /ap PYTHON_VERSION: "3.13" TIMEZONE: Europe/Brussels container_name: app environment: APP_HOME: /ap PYTHON_VERSION: "3.13" TIMEZONE: Europe/Brussels image: my_sample_prod:latest networks: default: null ports: - mode: ingress target: 8000 published: "8000" protocol: tcp security_opt: - no-new-privileges:true user: 1001:1001 networks: default: name: my_sample_default ``` > **Remember our user 1001:** > > Look here above on the `user: 1001:1001` line. We've seen this in a previous chapter: when the container will be created, the active user will be `1001:1001` and that one didn't exist (we haven't created it in our `Dockerfile`): our container will be rootless; just what we want. Now, we'll build and create the container by running the command below. > **EXTREMELY IMPORTANT - WE HAVE TO BUILD THE IMAGE FIRST:** > > Because our future devcontainer will be derived from the production image, we MUST build it first: > > ```bash > $ docker compose build > ``` > > This ensures that VSCode will retrieve a local version of your image. If you don't do this, if you've not yet built the image, Docker will try to pull it from Docker hub (from internet) and you'll probably get an error. > > So, once again, before going further in this tutorial, please run `docker compose build` first in a console. ### Running a production container Now, we can create a container if we want by running the command below: ```bash $ docker compose up --detach ``` Since our demo project is a FastAPI Python application, simply start a browser and go to the `http://localhost : 8000` location to see the message from FastAPI. The output is a JSON one: > Screenshot β€” http\://localhost:8000 > > {"message":"Hello, FastAPI - PRODUCTION!"} ### And, just to be sure, checking who is the current user for PROD Optional, let's run a parametrized command into the container to see who is the current user: ```bash $ docker compose exec app /bin/sh -c 'echo "Container user ID is $(id -u) and his group ID is $(id -g)"' Container user ID is 1001 and his group ID is 1001 ``` This, just to confirm: when running our container, it'll run using a not-privileged user thanks to our `compose.yaml` orchestration file. ![The production image has been created](./images/prod_image_created.webp) ## Step 2 - Override (or extend) the production image and get a devcontainer ready one ### Let's create our devcontainer files Please now create a subfolder called `.devcontainer` with the files below. #### The .devcontainer/.env file In case we need some more configuration items, let's create the `.devcontainer/.env` file (even if empty). **/tmp/docker-prod-devcontainer/.devcontainer/.env** ``` ``` #### The .devcontainer/Dockerfile file One of the most important file is the `.devcontainer/Dockerfile` where we'll add extra things (Linux dependencies, Python libraries, ...) to allow us to have a better development experience. **/tmp/docker-prod-devcontainer/.devcontainer/Dockerfile** ```docker # syntax=docker/dockerfile:1.6 # .devcontainer/Dockerfile ARG OS_USERID=1000 ARG OS_GROUPID=1000 ARG OS_USERNAME="vscode" ARG DOCKER_APP_HOME="/app" FROM my_sample_prod:latest ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 USER root #! If you get an error during the execution of this block, make sure the "my_sample_prod" #! image isn't running with a non-root user. It should be "root". Rebuild the image to be sure. RUN --mount=type=cache,target=/var/cache/apt,rw \ set -eux \ && printf "\e[0;105m%s\e[0;0m\n" "Install required Linux binaries for devcontainer..." \ && apt-get update -yqq \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ bash \ bash-completion \ git \ curl \ ca-certificates \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # --- Create non-root user --- ARG OS_USERID ARG OS_GROUPID ARG OS_USERNAME RUN set -eux \ && if ! id -u "${OS_USERNAME}" >/dev/null 2>&1; then \ printf "\e[0;105m%s\e[0;0m\n" "Create devcontainer user ${OS_USERNAME} (${OS_USERID}:${OS_GROUPID})..." \ && groupadd -g ${OS_GROUPID} "${OS_USERNAME}" \ && useradd -u ${OS_USERID} -g ${OS_GROUPID} -m --shell /bin/bash --create-home "${OS_USERNAME}" \ && chown -R "${OS_USERNAME}":"${OS_USERNAME}" "/home/${OS_USERNAME}"; \ fi # Ensure vscode home folders exist and are writable. The .vscode-server folder is used by # VSCode while installing extensions in the container. That folder has to be owner by our user; not root. RUN set -eux \ && mkdir -p "/home/${OS_USERNAME}/.vscode-server/bin" \ && mkdir -p "/home/${OS_USERNAME}/.vscode-server/extensions" \ && mkdir -p "/home/${OS_USERNAME}/.vscode-server/data" \ && chown -R "${OS_USERNAME}":"${OS_USERNAME}" "/home/${OS_USERNAME}/.vscode-server" # ---------- Shell and history setup ----------------------------------------- # Configure bash to use this file RUN echo "export HISTFILE=/home/${OS_USERNAME}/.bash_history" >> "/home/${OS_USERNAME}/.bashrc" && \ echo 'export HISTSIZE=10000' >> "/home/${OS_USERNAME}/.bashrc" && \ echo 'export HISTFILESIZE=10000' >> "/home/${OS_USERNAME}/.bashrc" && \ echo 'shopt -s histappend' >> "/home/${OS_USERNAME}/.bashrc" # Set up the shell prompt RUN set -eux && \ echo "PS1='\n\e[0;33m🐳 \e[0;36m\$(whoami)\e[0m \w # '" >> "/home/${OS_USERNAME}/.bashrc" # Welcome message and tips (make sure the message isn't yet in the file) RUN set -eux; \ BASHRC="/home/${OS_USERNAME}/.bashrc"; \ MARKER="# DEV CONTAINER WELCOME MESSAGE"; \ grep -qxF "$MARKER" "$BASHRC" || cat <> "$BASHRC" $MARKER echo -e "\nπŸ“˜ Welcome to the Dev Container!" echo -e "Below are some useful commands:" echo -e "" echo -e " ✨ \e[32mpython -m debugpy --listen 0.0.0.0:5679 -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload\e[0m Start a hot-reloading preview in your browser." EOF ARG DOCKER_APP_HOME # Important: in the production image, the folder "DOCKER_APP_HOME" (/app most probably) is # owned by the root user. Here, for the devcontainer, the folder has to be owned by our # current user. RUN set -eux \ && chown -R "${OS_USERNAME}":"${OS_USERNAME}" "${DOCKER_APP_HOME}" USER "${OS_USERNAME}" COPY requirements.txt . RUN --mount=type=cache,target=/home/${OS_USERNAME}/.cache/pip \ set -eux \ && cat requirements.txt \ && printf "\e[0;105m%s\e[0;0m\n" "Install Python dev dependencies..." \ && python -m pip install -r requirements.txt WORKDIR "${DOCKER_APP_HOME}" ``` Look at the provided script once again: 1. We are reusing our previously created Docker image (see the `FROM my_sample_prod:latest` line) 2. We'll install some dev Linux tools like `bash`, `curl`, `git`, ... we don't need in prod 3. Now, we'll create a non-root user by using a few parameters like `OS_USERID`, `OS_GROUPID` and `OS_USERNAME`. We'll create some custom folders too for that user 4. We'll configure our Shell for things like the history file, to have a customized prompt, ... 5. We'll make sure the different folder we need to manipulate are owned by our user 6. We'll install some specific Python Dev dependencies (this is needed for our sample here) This last step is thus only needed for illustration of this blog post. Please use your own. > **Paying attention to the FROM statement:** > > Let me insist one more time on this point. Right now, our `Dockerfile` is extending an existing image called `my_sample_prod:latest`. > > Do you have that image right now on your host? If you don't have it, VSCode, when you'll use the *Open in Devcontainer* feature, will first try to download the image from Docker Hub. > > Ok, perhaps the image is hosted there and in that case, Docker will download it from the Hub. Perhaps that image doesn't exist elsewhere than on your host. Perhaps the image exists on both the Hub and your computer. > > So many questions, right? Just be very aware of this! > > Make sure to run `docker compose build` in your root folder if you need to: > > 1. Create the local image > 2. Refresh the local image because you've modified your production Docker files (as seen in the previous chapter). > > So, if not yet done, please run `docker compose build`! Make sure running `docker image ls | grep -i my_sample` in your console will well list your image. #### The .devcontainer/requirements.txt file This small file is there just for illustration purposes. In this blog post, we're creating a small FastAPI python application and, in the `.devcontainer/requirements.txt` we'll add some extra dev dependencies for Python, like installing the `Debugpy` tool i.e. the Microsoft Python Debugger. **/tmp/docker-prod-devcontainer/.devcontainer/requirements.txt** ```txt debugpy ``` Of course, if your project isn't a Python one, this file can be removed and, in that case, also remove the associated lines from the `.devcontainer/Dockerfile`. #### The .devcontainer/compose.yaml file As for the production image, here, we need to tell Docker how the image has to be built and how the container (the dev one) has to be created. **/tmp/docker-prod-devcontainer/.devcontainer/compose.yaml** ```yaml services: app: build: context: ./.devcontainer dockerfile: Dockerfile args: - DOCKER_APP_HOME=${APP_HOME:-/app} - OS_USERID=${LOCAL_UID:-1000} - OS_GROUPID=${LOCAL_GID:-1000} - OS_USERNAME=${LOCAL_USER:-vscode} container_name: my_sample_devcontainer_app ports: - 5679:5679 # Debugpy port env_file: - .env - .devcontainer/.env volumes: - .:${APP_HOME:-/app} - vscode-extensions:/home/${LOCAL_USER:-vscode}/.vscode-server/extensions - ./.devcontainer/history/.bash_history:/home/${LOCAL_USER:-vscode}/.bash_history:rw command: sleep infinity volumes: vscode-extensions: ``` If you look at this `.devcontainer/compose.yaml` file, we're configuring a few things: * The list of build arguments (`args`) should mention the user id, group id and name of our user. This is important: when we'll work inside the devcontainer, any files created in the container will be owned by this specific user. If we are using `johndoe` with `1001` for both user id and group id; then files will be synchronized on your host like that; owned by `johndoe` not you. If you are the user `1000:1000` locally, you understand that files won't be yours and you'll get permissions problems. It's something we have to avoid. For this, we have to correctly configure the `.devcontainer/compose.yaml` file. See the syntax: `${LOCAL_UID:-1000}`. By default, the file will assign `1000` (which is often the correct ID). But first, Docker will check if a variable called `LOCAL_UID` exists on the host. If this is the case, that value will be used. So, if running `id -u` (or `id -g` for the group id) doesn't give `1000` on your host; make sure to create the `LOCAL_UID` or `LOCAL_GID` variable in your `.bashrc` file and assign them to the correct value. * Because our current sample is about Python, we'll also expose the DebugPy standard port thanks to the line `5679:5679` so, when starting a debug session, the container will work. * We're also referring to two `.env` files, the one from the root folder (used for the Docker PROD image) and the one from the `.devcontainer` folder * We'll add some volumes: * `.:${APP_HOME:-/app}` will mount (=share) files from our project (inside VSCode) with the `${APP_HOME:-/app}` folder i.e. where the files are stored inside the Docker PROD image. Like this, files that we already copied in the PROD Docker image will just be overridden by the ones of our dev session (=> this is where the magic happens) * We'll also ask Docker to create a self-managed volume for the `vscode-extensions`. Thanks to this line, the first time we'll create the devcontainer, VSCode will install extensions but next time, thanks to this line, VSCode won't install extensions again, they're already present. This `vscode-extensions` is used for data persistence. * And the line for `./.devcontainer/history/.bash_history` will persist the history of your command lines (in the console) in your project. So, next time you'll work inside the devcontainer, the CLI history will be already populated with what you did in the past. #### The .devcontainer/devcontainer.json file And, finally, the configuration file for VSCode. In VSCode, to be able to run a `Devcontainer:` command (from the **Command Palette** (`CTRL`+`SHIFT`+`P`)), the `.devcontainer/devcontainer.json` file should be present and tells VSCode what to do. **/tmp/docker-prod-devcontainer/.devcontainer/devcontainer.json** ```json { "name": "MySample Devcontainer", "dockerComposeFile": [ "../compose.yaml", "compose.yaml" ], "service": "app", "workspaceFolder": "/app", "remoteUser": "vscode", "containerUser": "vscode", "containerEnv": { "GIT_SSH_COMMAND": "ssh -i /home/vscode/.ssh/id_ed25519 -o 'UserKnownHostsFile=/dev/null'" }, "initializeCommand": "mkdir -p .devcontainer/history && touch .devcontainer/history/.bash_history && echo '.bash_history' > .devcontainer/history/.gitignore", "customizations": { "vscode": { "settings": { "files.autoSave": "onFocusChange", "window.title": "${dirty}${rootName} ${separator} [DevContainer]", "workbench.colorTheme": "Visual Studio Light" }, "extensions": [ "ms-python.debugpy" ] } } } ``` 1. We have to use the `dockerComposeFile` item to load our two `compose.yaml` files i.e. the one of the root (the prod one) and the one from the `.devcontainer` folder. This is needed because we just need to extend the production `compose.yaml` file. We'll reuse all services and keys defined in `../compose.yaml` and just add (extend) or update (override) some 2. We need to tell VSCode which folder should be open in our VSCode Devcontainer session, it has to be `/app` (has to match the `APP_HOME` variable from `.devcontainer/compose.yaml`) 3. We'll define our remoteUser name, it'll be `vscode` (has to match the `OS_USERNAME` variable from `.devcontainer/compose.yaml`) 4. We'll also add a `GIT_SSH_COMMAND` environment variable. This is optional but it'll allow us to run a `git` command (like `git pull`) from inside our devcontainer 5. The `initializeCommand` key will create the `.devcontainer/history` folder on our host (with us as owner) just before creating the container. Like this, we'll be able to store our bash history file there (see the `volumes` entry in `.devcontainer/compose.yaml`) 6. And, finally, in the `customizations` part, we can add vscode settings and extensions we want to configure and install inside our Devcontainer. You can add a lot of settings / extensions here if you want, depending of your project's type #### Our project's structure right now So at this point, our project looks like this i.e. we have ten files. ```tree expanded=true showJSX=false debug=false title="docker-prod-devcontainer/" β”œβ”€β”€ .devcontainer β”‚Β Β  β”œβ”€β”€ .env β”‚Β Β  β”œβ”€β”€ Dockerfile β”‚Β Β  β”œβ”€β”€ compose.yaml β”‚Β Β  β”œβ”€β”€ devcontainer.json β”‚Β Β  └── requirements.txt β”œβ”€β”€ .env β”œβ”€β”€ Dockerfile β”œβ”€β”€ compose.yaml β”œβ”€β”€ requirements.txt └── src └── main.py ``` ### Creating the Devcontainer image This part is pure optional, it's just to check if everything is correctly filled in. Jump in the `.devcontainer` folder (`cd .devcontainer`) and run this command: ```bash $ docker compose -f ../compose.yaml -f compose.yaml config ``` You'll get something like this: **console output** ``` name: my_sample services: app: build: context: /tmp/docker-prod-devcontainer/.devcontainer dockerfile: Dockerfile args: APP_HOME: /ap OS_GROUPID: "1000" OS_USERID: "1000" OS_USERNAME: vscode PYTHON_VERSION: "3.13" TIMEZONE: Europe/Brussels command: - sleep - infinity container_name: my_sample_devcontainer_app environment: APP_HOME: /ap PYTHON_VERSION: "3.13" TIMEZONE: Europe/Brussels image: my_sample_prod:latest networks: default: null ports: - mode: ingress target: 8000 published: "8000" protocol: tcp - mode: ingress target: 5679 published: "5679" protocol: tcp security_opt: - no-new-privileges:true user: 1001:1001 volumes: - type: bind source: /tmp/docker-prod-devcontainer target: /ap bind: create_host_path: true - type: volume source: vscode-extensions target: /home/vscode/.vscode-server/extensions volume: {} - type: bind source: /tmp/docker-prod-devcontainer/.devcontainer/history/.bash_history target: /home/vscode/.bash_history bind: create_host_path: true networks: default: name: my_sample_default volumes: vscode-extensions: name: my_sample_vscode-extensions ``` Did you get an error? If so, you can more easily read/manage the error right now. You can look at the configuration to see if everything looks correct i.e. if your variables have well be replaced by the values coming from the `.env` files. ![Both prod and devcontainer have been created](./images/both_created.webp) ## Opening the container using vscode Time to open the project using the Devcontainer feature of VSCode. ```bash $ cd /tmp/docker-prod-devcontainer && code . ``` ![Opening VSCode](./images/opening_vscode.webp) If you look at the bottom right of your screen, you'll probably see this popup: ![The devcontainer popup](./images/popup_devcontainer.webp) Simply click on the `Reopen in Container` button or, second way, press `CTRL`+`SHIFT`+`P` to open the **Command palette** and select the "Dev Containers: Rebuild Without Cache and Reopen in Container" option. VSCode will close the project and reopen it. It'll take more time (the first time) because it needs to create the Docker container first. ![The DevContainer is ready](./images/devcontainer.webp) If you see the image here above, you're in the container. I've configured a light theme just to make the clear distinction between host (black theme) and devcontainer (light theme). Bottom right, you can create a new Terminal: ![Creating a new terminal](./images/add_new_terminal.webp) In this demo project, you can see this message; once: ![The welcome message](./images/welcome_terminal.webp) The idea is thus to provide contextual help to the developer and to tell him, here, how he can start the FastAPI server using hot reload and with Debugpy enabled. ### Let's test If we return to the `http://localhost : 8000` URL we can still get the JSON answer (so the container is well running). > Screenshot β€” http\://localhost:8000 > > {"message":"Hello, FastAPI - PRODUCTION!"} But now, in VSCode, if you edit the `src/main.py` script and update the Hello message like below: ![Updating the Hello message](./images/updated_main.webp) You just need to save the change, go back to the browser window and refresh the page: > Screenshot β€” http\://localhost:8000 > > {"message":"Hello, FastAPI - I'm running from the Devcontainer!"} ### Let's debug In this demo project, we've added the Debugpy dependency. We just need to add an extra file for VSCode. **/tmp/docker-prod-devcontainer/.vscode/launch.json** ```json { "version": "0.2.0", "configurations": [ { "name": "Python: FastAPI", "type": "debugpy", "request": "attach", "connect": { "host": "localhost", "port": 5679, }, "justMyCode": true, "pathMappings": [ { "localRoot": "${workspaceFolder}/src", "remoteRoot": "." } ] } ] } ``` To be sure the extension is correctly working, just close VSCode and open it again. Open the DevContainer again too (now, you can simply choose "Reopen in Devcontainer"). Once in your devcontainer, please open the `src/main.py` file. Click anywhere on the return line (line 7) and press `F9` to add a breakpoint. ![Adding a breakpoint](./images/adding_a_breakpoint.webp) Press `F5` to start the debugger session. Go back to the browser, refresh the page and VSCode will break on that specific line. ![Success](./images/success.webp) ## Going further The example above uses Python; the exact same two-image approach is used, language by language, in [Docker - Python devcontainer](/blog/docker-python-devcontainer) and [PHP development in a devcontainer with preinstalled code quality tools](/blog/php-devcontainer). And if a devcontainer is still an abstract notion for you, start with [Docker - Explain me like I'm five - What's Docker for?](/blog/docker-definition-like-im-five). --- # Level Up Your Presentations with Quarto, reveal.js, Decktape, Docker and DevContainers > Create reveal.js slideshow with Quarto, Decktape, Docker and DevContainers. Write your content in Markdown. Export as HTML and PDF (thanks to Decktape) --- ![Level Up Your Presentations with Quarto, reveal.js, Docker and DevContainers](/img/v2/quarto_reveal_docker.webp) **TL;DR** This article details how to create and manage reveal.js presentations using Markdown, Quarto, Docker, and VSCode DevContainers. It outlines setting up a Quarto project within a DevContainer to write slides in Markdown, preview them live, and automatically reflect updates. The workflow also covers exporting presentations as static HTML sites and high-quality PDFs using Decktape, offering a streamlined alternative to traditional tools. It's been years, perhaps eight years, since I last used PowerPoint for a presentation. Clicking, dragging, resizing, changing slide resolutions, damn it, starting all over again... Drawing text boxes, playing around with fonts and sizes, moving things around... Oh no, enough already! No more PowerPoint. I'm still shaking. I use Markdown for all my documentation and exports (PDF, DOCX, etc.), so it made perfect sense to choose [reveal.js](https://revealjs.com/), which allow me to write my slides without touching the mouse, just by writing Markdown code. **Quick Jump:** [See it in Action](#the-result) | [Installation](#installation) ## The Result Once the devcontainer is built and the preview server is running (covered under Installation below), your slideshow opens live in the browser β€” written in Markdown, previewed instantly: ![The slideshow is running](./images/first_slide.webp) So ... easy no? ## Why It Works reveal.js is beautiful, simple, and practical. It allows you to write HTML slideshows from a single `.md` file. Since I use [Quarto](https://quarto.org/) for my documentation and Quarto allows me to generate a reveal.js site, what could be more natural than to continue [one of my previous articles](/blog/quarto-devcontainer) and set myself this goal: with just VSCode and Docker on my computer, to program a devcontainer that will allow me to write and view my reveal.js presentation, but also to export it as a static HTML site so that I can, e.g., deploy my presentation on an SFTP server, but also generate a PDF version of the presentation where each slide corresponds to a page in landscape mode. * Quarto renders the same Markdown source as a reveal.js slideshow, a Word document, a PDF, or [a PowerPoint deck](/blog/quarto-powerpoint) β€” one source, several outputs. * The devcontainer isolates the whole toolchain (Quarto, the preview server, Decktape) β€” nothing to install on the host beyond VSCode and Docker. * Exporting to HTML or a print-quality PDF is built in via Decktape β€” no separate export tool to configure. Let's embark on this adventure. ## Installation So, make sure you've VSCode and Docker on your machine. ### We need a Quarto project For this blog post, the idea is to use Quarto and not to create a reveal.js project directly *by hand*. This is because Quarto will allow us to do a lot more things, like exporting our slideshow as a Word document, a PDF or even [a PowerPoint slideshow](/blog/quarto-powerpoint) or anything else supported by Quarto as targets. We'll use Quarto to, also, be able to render the project as a slideshow. #### You don't have one yet As illustration for this post, please download the [demo.zip](./files/demo.zip) file I've made. It's a ready-to-use slideshow project. Save the file in a new folder on your disk like `/tmp/revealjs` and unzip it (`unzip demo.zip`). Once done, jump in the newly created folder (`cd /tmp/revealjs/demo`) and start `code .` to open the project in VSCode. In this demo project, you'll already find a `.devcontainer` folder with the needed files I'm describing in the next chapter. #### You have one If you already have an existing Quarto project, just jump in it and run `code .` to open the project in VSCode. Then, add the following files to your project root: **Project: /tmp/revealjs/demo** > **Note:** > > Now, start 'code .' to open VScode. Press 'F1' and select 'Dev containers: Rebuild and Reopen in Devcontainer' to open the project as a devcontainer **\_quarto.yml** ```yaml project: type: default render: ["*.qmd"] format: revealjs: css: style.css author: You ratio: "16:9" code-line-numbers: false center: true footer: https://www.yoursite.com/ navigation-mode: linear transition: convex preview-links: auto scrollable: true show-notes: false slide-level: 6 slide-number: c/t theme: default title-slide-attributes: data-background-image: images/background.webp data-background-size: cover ``` **.devcontainer/compose.yaml** ```yaml --- # cspell:ignore tinytex services: quarto-doc-base: image: quarto-devcontainer:latest build: context: . dockerfile: Dockerfile args: # We use default values here. The actual UID/GID mapping is handled # by the VS Code Dev Container CLI at runtime, overriding these defaults # when the container starts via the user: vscode directive in the Dockerfile. USER_UID: 1000 USER_GID: 1000 # Flags you'll need to adjust depending on your project INSTALL_CHROMIUM: "false" INSTALL_CSPELL: "false" INSTALL_PRECOMMIT_HOOKS: "false" user: root working_dir: /workspace volumes: - ..:/workspace:rw - ./history/.bash_history:/home/vscode/.bash_history:rw - /var/run/docker.sock:/var/run/docker.sock - type: bind source: ${HOME}/.quarto target: /home/vscode/.quarto - type: bind source: ${HOME}/.ssh/id_ed25519 target: /home/vscode/.ssh/id_ed25519 read_only: true # Base command to keep the container running indefinitely command: /bin/sh -c "while sleep 1000; do :; done" ``` **.devcontainer/devcontainer.json** ```json { "name": "Quarto Docs Project - Demo", "dockerComposeFile": "compose.yaml", "service": "quarto-doc-base", "workspaceFolder": "/workspace", "remoteUser": "vscode", "containerEnv": { "LOCAL_WORKSPACE": "${localWorkspaceFolder}" }, "features": { "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { "version": "latest", "enableNonRootDocker": "true", "moby": "true" }, "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/paul-gilber/devcontainer-features/yamllint-homebrew:1": {} }, "initializeCommand": "mkdir -p .devcontainer/history && touch .devcontainer/history/.bash_history && echo '.bash_history' > .devcontainer/history/.gitignore", "postAttachCommand": "if which pre-commit >/dev/null 2>&1; then pre-commit install || true; fi", "forwardPorts": [8000, 5931], "customizations": { "vscode": { "settings": { "[dockerfile]": { "files.eol": "\n", "editor.defaultFormatter": "ms-azuretools.vscode-docker" }, "[json]": { "editor.defaultFormatter": "vscode.json-language-features" }, "[jsonc]": { "editor.defaultFormatter": "vscode.json-language-features", "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 80, "editor.wrappingIndent": "indent" }, "[markdown]": { "editor.defaultFormatter": "yzhang.markdown-all-in-one", "editor.wordWrap": "wordWrapColumn", "editor.wordWrapColumn": 80, "editor.wrappingIndent": "indent" }, "[yaml]": { "editor.defaultFormatter": "redhat.vscode-yaml" }, "cSpell.language": "en,fr,nl", "cSpell.words": [ "Quarto", "devcontainer", "frontmatter", "Pandoc", "Gitlens" ], "editor.formatOnSave": true, "editor.guides.bracketPairs": "active", "editor.guides.bracketPairsHorizontal": "active", "editor.guides.highlightActiveIndentation": true, "editor.guides.indentation": true, "editor.multiCursorModifier": "ctrlCmd", "editor.renderWhitespace": "all", "editor.rulers": [120, 70], "editor.stickyScroll.enabled": true, "editor.tabCompletion": "on", "editor.tabSize": 4, "editor.wordBasedSuggestionsMode": "allDocuments", "editor.wordWrapColumn": 120, "explorer.compactFolders": false, "explorer.confirmDelete": true, "explorer.confirmDragAndDrop": true, "extensions.autoCheckUpdates": false, "files.associations": { "resources/*.json": "jsonc" }, "files.autoSave": "onFocusChange", "files.defaultLanguage": "${activeEditorLanguage}", "files.eol": "\n", "files.exclude": { "**/.cache": true, "**/.git": true, "**/.quarto": true, "**/*.html": true, "**/readme_files/**": true, "**/site_libs/**": true }, "files.insertFinalNewline": true, "files.trimTrailingWhitespace": true, "maptz.regionfolder": { "[dockerfile]": { "foldEnd": "[\\S]*\\#[\\s]*endregion", "foldEndRegex": "[\\S]*\\#[\\s]*endregion[\\s]*(.*)$", "foldStart": "[\\S]*\\#[\\s]*region [NAME]", "foldStartRegex": "[\\S]*\\#[\\s]*region[\\s]*(.*)$" } }, "markdown.extension.toc.levels": "2..6", "markdown.extension.tableFormatter.enabled": true, "markdownlint.config": { "MD033": false, "MD036": false }, "quarto.preview.renderOnSave": true, "telemetry.telemetryLevel": "off", "terminal.integrated.profiles.linux": { "bash": { "path": "/bin/bash", "icon": "terminal-bash" } }, "terminal.integrated.defaultProfile.linux": "bash", "terminal.integrated.fontFamily": "MesloLGS NF", "terminal.integrated.tabs.defaultLocation": "editorGroup", "yaml.schemas": { "https://json.schemastore.org/json-schema-v7.json": [ "_quarto.yml", "_quarto-*.yml" ], "https://json.schemastore.org/github-workflow.json": ".github/workflows/*.yml" }, "window.title": "${dirty}${rootName} ${separator} [DevContainer]", "workbench.colorTheme": "Visual Studio Light" }, "extensions": [ "DavidAnson.vscode-markdownlint", "esbenp.prettier-vscode", "gitkraken.gitlens", "maptz.regionfolder", "mde.select-highlight-minimap", "mikestead.dotenv", "ms-vscode.makefile-tools", "quarto.quarto", "redhat.vscode-yaml", "sirtori.indenticator", "streetsidesoftware.code-spell-checker-dutch", "streetsidesoftware.code-spell-checker-french", "streetsidesoftware.code-spell-checker", "yzhang.markdown-all-in-one" ] } } } ``` **.devcontainer/Dockerfile** ```docker # syntax=docker/dockerfile:1.4 # cspell:disable FROM ghcr.io/quarto-dev/quarto:1.8.25 AS base ARG USERNAME=vscode ARG USER_UID=1000 ARG USER_GID=1000 ARG INSTALL_CHROMIUM="false" ARG INSTALL_CSPELL="false" ARG INSTALL_PRECOMMIT_HOOKS="false" # Yaml tooling ARG YQ_VERSION=v4.42.1 ENV LANG C.UTF-8 ENV LC_ALL C.UTF-8 # ---------- Stage 1: Install system dependencies ---------- # # texlive-* : to render as PDF using "quarto render . --to pdf" # texlive-luatex : to ensure lualatex is installed RUN --mount=type=cache,target=/var/cache/apt \ --mount=type=cache,target=/var/lib/apt \ set -eux; \ apt-get update && \ apt-get install -y --no-install-recommends \ ca-certificates \ curl \ libnss3 libfontconfig1 libgbm1 \ libsecret-1-dev \ libx11-dev \ libxkbfile-dev \ openssh-client \ sudo \ unzip \ # yamllint \ # --- PDF Rendering Packages (Revised) --- texlive-xetex \ texlive-fonts-recommended \ texlive-plain-generic \ texlive-luatex \ texlive-latex-extra \ texlive-fonts-extra \ texlive-science \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Pre-generate the LuaLaTeX font database to avoid runtime delay # This command must be run AFTER all texlive packages are installed. # RUN set -eux; \ # luaotfload-tool -f -u -v # Install yq (YAML validator) for validating YAML frontmatter RUN --mount=type=cache,target=/var/cache/yq \ set -eux; \ curl -L --fail --silent --show-error \ https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64 \ -o /var/cache/yq/yq_linux_amd64; \ install -m 0755 /var/cache/yq/yq_linux_amd64 /usr/local/bin/yq # ---------- Stage 2: Extras - pre-commit, Node.js + cspell ---------- FROM base AS extras ARG INSTALL_CSPELL ARG INSTALL_PRECOMMIT_HOOKS ARG USERNAME # Install pre-commit (without installing hooks at build time) RUN --mount=type=cache,target=/root/.cache/pip \ if [ "$INSTALL_PRECOMMIT_HOOKS" = "true" ]; then \ set -eux; \ echo "Installing Python and pre-commit..." && \ apt-get update && \ apt-get install -y --no-install-recommends python3-pip && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ pip3 install --no-cache-dir --upgrade pip && \ pip3 install --no-cache-dir pre-commit && \ pip cache purge || true; \ fi # Install Node.js + cspell (optional) RUN --mount=type=cache,target=/home/${USERNAME}/.npm \ if [ "${INSTALL_CSPELL}" = "true" ]; then \ set -eux; \ echo "Installing NPM and Code Spell Checker..." && \ curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ apt-get install -y --no-install-recommends nodejs && \ npm config set cache /home/${USERNAME}/.npm && \ npm install -g cspell && \ npm cache clean --force && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*; \ fi # ---------- Stage 3: Finalization ---------- FROM extras AS final ARG USERNAME ARG USER_UID ARG USER_GID ARG INSTALL_CHROMIUM # ---------- User and permissions -------------------------------------------- RUN set -eux && \ USER_UID=${USER_UID:-1000} && \ USER_GID=${USER_GID:-1000} && \ groupadd -g "${USER_GID}" "${USERNAME}" && \ useradd -m -u "${USER_UID}" -g "${USER_GID}" -s /bin/bash "${USERNAME}" && \ chown -R "${USERNAME}":"${USERNAME}" "/home/${USERNAME}" # Grant passwordless sudo access to the node user # Allow our node user to run "sudo xxx" in case of very specific use case RUN set -eux && \ echo "${USERNAME} ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/"${USERNAME}" && \ chmod 0440 /etc/sudoers.d/"${USERNAME}" # Switch to non-root user for Chromium and Quarto tools USER ${USERNAME} # Install Quarto Chromium tool (optional) RUN --mount=type=cache,target=/home/${USERNAME}/.quarto/tools \ if [ "${INSTALL_CHROMIUM}" = "true" ]; then \ set -eux; \ echo "Installing Quarto's Chromium tool..." && \ quarto install tool chromium; \ fi # ---------- Shell and history setup ----------------------------------------- # Note: Decktape is started on the host (using Docker-out-of-Docker). We need to # make sure the slides.pdf file is created with the right ownership and in the right place; our local absolute path. RUN set -eux \ && echo "alias decktape='docker run --rm --user root -v \${LOCAL_WORKSPACE}/:/slides ghcr.io/astefanutti/decktape reveal http://host.docker.internal:5931/ slides.pdf && sudo chown ${USERNAME}:${USERNAME} slides.pdf'" >> "/home/${USERNAME}/.bashrc" \ && echo "alias cspell='cspell lint . --config .vscode/cspell.json'" >> "/home/${USERNAME}/.bashrc" \ && echo "alias pre-commit='pre-commit run --all-files'" >> "/home/${USERNAME}/.bashrc" # Configure bash to use this file RUN echo "export HISTFILE=/home/${USERNAME}/.bash_history" >> "/home/${USERNAME}/.bashrc" && \ echo 'export HISTSIZE=10000' >> "/home/${USERNAME}/.bashrc" && \ echo 'export HISTFILESIZE=10000' >> "/home/${USERNAME}/.bashrc" && \ echo 'shopt -s histappend' >> "/home/${USERNAME}/.bashrc" # Set up the shell prompt RUN set -eux && \ echo "PS1='\n\e[0;33m🐳 \e[0;36m\$(whoami)\e[0m \w # '" >> "/home/${USERNAME}/.bashrc" # Welcome message and tips RUN set -eux && \ cat <> "/home/${USERNAME}/.bashrc" echo -e "\nπŸ“˜ Welcome to the Quarto Docs Dev Container!" echo -e "Below are some useful commands:" echo -e "" echo -e " \e[1mQuarto Commands\e[0m" echo -e " ───────────────" echo -e " ✨ \e[32mquarto preview . --port 5931 --host 0.0.0.0\e[0m Start a hot-reloading preview in your browser." echo -e " ✨ \e[32mquarto render . --output-dir build\e[0m Render a static HTML site in the folder build." echo -e "" echo -e " ✨ \e[32mdecktape\e[0m Export the slideshow as slides.pdf with full respect of the layout." echo -e "" EOF # Append conditional commands depending on optional tools RUN set -eux && \ if [ "${INSTALL_CSPELL}" = "true" ] || [ "${INSTALL_PRECOMMIT_HOOKS}" = "true" ]; then \ echo 'echo -e " \e[1mData quality checks\e[0m"' >> "/home/${USERNAME}/.bashrc" && \ echo 'echo -e " ───────────────────"' >> "/home/${USERNAME}/.bashrc"; \ fi && \ if [ "${INSTALL_CSPELL}" = "true" ]; then \ echo 'echo -e " ✍️ \e[32mcspell\e[0m Run spell checks.\n"' >> "/home/${USERNAME}/.bashrc"; \ fi && \ if [ "${INSTALL_PRECOMMIT_HOOKS}" = "true" ]; then \ echo 'echo -e " βš™οΈ \e[32mpre-commit\e[0m Run code quality checks on all files.\n"' >> "/home/${USERNAME}/.bashrc"; \ fi ``` > **Images below will be based on my project, for sure, some will be different for you.:** ### Open the Devcontainer In VSCode, please press `F1` to open the **Command Palette** then select **Dev containers: Rebuild and Reopen in Devcontainer**. *If you don't have this command, please make sure to install the VSCode [Dev Container from Microsoft](https://marketplace.visualstudio.com/publishers/Microsoft).* VSCode will thus *rebuild and reopen the project as a Devcontainer*: in other words, VSCode will first build a special Docker image (as described in the `.devcontainer/Dockerfile` file) and probably first download the Quarto Docker image, then, once the image has been created, VSCode will create a container based on that image and finally, will reopen the project in a special environment called *a Devcontainer*. The container will build (this takes 1–2 minutes the first time). After the build completes, VS Code displays a welcome screen like this: ![Building the Devcontainer](./images/building_devcontainer.webp) By clicking on the `Connecting to Dev Container (show log)` link, you'll get more details and be able to follow what is being done. After one or two minutes (this happens only the first time), you'll obtain a screen like this: ![List of commands](./images/cheatsheet.webp) > **Click on the `+` button:** > > If you don't have the **Terminal** screen like on the image above, look at the right side of the screen and click on the `+` button to create a new, bash, terminal. ## More Demos ### Preview the slideshow So, look at the list of commands you can see on the Terminal, there is one for the preview. Just run it as suggested so please run `quarto preview . --port 5931 --host 0.0.0.0`. ```bash quarto preview . --port 5931 --host 0.0.0.0 ``` Press `Enter` to start the preview server. VS Code will ask for permission to open the site in your browser. ![Opening the site](./images/external_website.webp) And, almost immediately, VSCode will launch your browser and create a new tab with your slideshow already active β€” the same result shown at the top of this article. #### Updating the slideshow As you can see, my slideshow is in French. Let's translate it. Use any translation tool you wish, I'll use [DeepL](https://www.deepl.com/). Copy some content and translate to English. Go back to your running slideshow and refresh the page and ... tadaaaaa, your content is now in English. ![Translated slide](./images/translated_slide.webp) As we've seen, updates in your content will be automatically reflected in the browser. > **Going further with reveal.js:** > > Once your slideshow runs, have a look at [Some tips and tricks for Quarto when rendering as a reveal.js slideshow](/blog/quarto-revealjs-tips) to fine-tune the rendering. ### Export a PDF Now that your slideshow is running, just go back in your Terminal (press `CTRL`+`ΓΉ` to open it) and create a new Bash session (click on the `+` button). There, run `decktape` in the Terminal to generate a file called `slides.pdf`: ```bash $ decktape ``` This will generate a nice PDF file (better than when using the PDF export feature of reveal.js). ### Export as static HTML site By running `quarto render . --output-dir build` you'll create a folder called `build` with your slideshow there: ```bash $ quarto render . --output-dir build ``` > **Want to try?:** > > In your host, just jump in the `build` folder and run `docker run -d --name static-site -p 8080:80 -v .:/usr/local/apache2/htdocs/ httpd:alpine` to [run the slideshow](/blog/docker-html-site) outside your Devcontainer. > > It's an easy way to validate it's working. Now, you can start your SFTP client f.i. and upload the `build` folder to your webserver if it was your objective. ## Conclusion Markdown in, a live-previewed slideshow out, and a PDF or a static HTML site whenever you actually need to ship it β€” the whole PowerPoint ritual (clicking, dragging, resizing) is gone, replaced by the same devcontainer workflow used for everything else on this blog. If you haven't set up a Quarto devcontainer before, [Make your Quarto project Devcontainer-Ready β€” No More Setup Headaches](/blog/quarto-devcontainer) covers that foundation in more detail. ---