markjgsmith

The new container based project structure

The past few days, I have been writing about the recent project rebuild I carried out, as well as some thoughts on the modern web based stack as it relates to AI/LLMs. I wanted to spend a bit of time reviewing my new project structure, highlighting the major features.

Develop in a single devcontainer, deploy to service containers

Initially I made the mistake of trying to containerise everything into service containers, both in development and in production. What I didn't realise was that the vscode devcontainer does much more than just pass your settings to the container management tool. It does a whole load of things that prepare the file system so you can edit the files on your host directly in the devcontainer. I thought it would be great to have a complete replica of production in development, with each workspace running in a separate service container. What ended up happening was a never ending cascade of impossible to debug file permission errors, because especially on a mac, your host file system has to go through the podman vm's filesystem and then to the container filesystem.

Just go with a much simpler strategy, in development use 1 single devcontainer, that contains all your project's code, don't mess around with service containers and podman-compose. It's not worth it. But for production, do spend the time to create separate service containers for each workspace. In production you don't need to worry about file permissions in the same way because you don't need to edit the files. I have a top level project folder called containers where all the Containerfiles (dev and prod) live.

Self contained npm workspaces

I was already using npm workspaces for the frontend, backend and shared modules of the app, but they were not very self contained. Each module was also scattered around the project root. I moved all the modules under a single packages directory, and I made sure that each module was designed with the notion that it should be able to function independently, if it was pulled out into a separate project. This is great for future proofing but also for being sure that your modules can be run in separate service containers. That means they should have their own linting, and typescript configurations, as well as npm scripts to typecheck, lint, build, start, etc. Use the same script names across the modules so you can run them via npm run from the root of the project using the -w flag to specify the workspace.

Well thought out and modern typecheck and linting configuration strategy

Unfortunately the typescript configuration had evolved along with the project, which meant it suffered from a lack of a cohesive strategy, because the AI/LLMs will give you different advice depending on when you ask them. I had just added various configurations as I went along. It's really important to get both these configurations right in your minimal project, because once the full project structure and files is there, it becomes much harder to think about and troubleshoot. One important thing to remember if you are using Typescript in both the frontend and backend, is you will likely need a shared workspace to house the types that are shared between both. This is where the typescript configuration comes unstuck, because the compiler will refuse to see the shared workspace, and you will end up going around in circles with the AI suggesting the same set of changes, each one confidently touted as the "final industry standard best practice that will fix everything", and each time it will not work. Another reason why having a single devcontainer is better than service containers in development. Minimise the amount of things that could go wrong. Get it working for a super simple frontend and backend workspace that both use a shared workspace. I just used the example expressjs and vite react app.

Linting

Base config in shared module, other workspaces import and use this in their own config where they can override bits if needed, separate out style rules to use @stylistic, project wide rules for imports ordering using perfectionist. I like all imports to visually look the same in each file, with the same ordering. It significantly reduces cognitive load. The ordering I use is vendor modules, vendor types, then project modules and project types, each separated by a single empty line.

Typescript

Unified and Modern, ES Modules (ESM) as Standard, with configuration symmetry between backend and frontend, every workspace has a tsconfig that references app tsconfigs, and if needed references a tools tsconfig (frontend), and conscious separation of typechecking rules and build optimization, which is handled by specialised tools. I based this on the config from the vite typescript example app which had the most modern looking config that uses ESM, which is the best as far as future proofing your project.

Githooks using husky

Run lint using lint-staged pre-commit and lint+build+typecheck on pre-push. I also have a custom commit linter script that I run on pre-push that enforces Conventional Commits standard for commit titles and a related naming convention for branch names. It makes a huge difference to cognitive load during development, because at any time you can run git log --oneline and get a really great summary of what has been happening on the project, that is super easy to parse at a glance.

Bind mounted volume for node_modules folder in the devcontainer

The devcontainer will automatically bind mount your project files into the container, but what you can do also is to create a separate volume mount for your node_modules folder. This is a necessity if your host OS is different to your devcontainer OS because it means some of your modules will need to be compiled for different platforms. You can easily add this in the mounts section of devcontainer.json. You can also add mounts for things like ssh keys, gitconfig and even your .devcontainer folder which you can mount readonly. Typically you will want to install your projects modules (i.e. npm install) in your devcontainer's post create command. One gotcha is that if you are installing any global modules via npm -g, you will likely get permission errors as the globals directories are owned by root. What you can do is reconfigure npm to install it's global packages into a directory that the non root user can write to (e.g. $HOME/.npm-globals). You can do that using npm config set prefix "$HOME/.npm-globals" in the post create command before you npm install.

Building production service containers from workspaces, multi-stage builds

The big idea here is that each workspace should run in it's own service container, with only the files it needs to run. I have a development Containerfile (podman equivalent of a Dockerfile) for the devcontainer, and production Containerfiles for the backend and frontend service containers. It's a bit tricky to get this right, but the general concept is for each service container (i.e. frontend and backend) to do the build in 2 stages. In the first stage you move across all the files you need to build your code to a builder image, and you do what you have to do to generate your dist folder. And in the second stage you copy across your dist folder from the builder to the service container.

The backend is a bit more complex than the frontend, because you have to build your code using the shared workspace, and then make sure to package up the shared module into a tgz file, which you can then reference in your package.json. The frontend also needs to be built with the shared workspace, but since you just output frontend files, there is no node_modules folder to worry about. I use a typical slim node image for the backend and for the frontend I use the unprivileged nginx image, so you can run it as a non root user. You need to re-configure nginx slightly, so it proxies any api requests to the backend.

Easily test at each stage of development

I have npm scripts so it's possible to run each workspace in dev mode (automatic restarts using vite / nodemon), and run lint and typecheck, but also to build the development and production images, and another to build and run the production code. I also have a podman-compose triggerable via npm that runs both the production service containers, so it's super easy to run things in dev and in prod. Something I haven't done yet, but will at some stage, is to run a minicube in podman, which is a local kubernetes cluster, where you can test full blown production deployments all on your local machine.

Keep local LLMs in a separate project, but connect them all via a private dev nework

Running ollama was one of the reasons I ended up down this path in the first place. Initially I had it in my head that it would be best to have ollama part of each project. But that really increased the complexity of things. What was much much easier was to have a completely separate ollama-local project that runs an ollama and an open-webui service container, using the standard images distributed by their respective projects, using podman-compose. That way you can really easily spin up a local AI/LLM cluster with the models and a web chat interface. The trick is to configure them to be on a private network that you create separately. You create the private network on login to your OS. On mac that's via launchd. It's just a bash script that checks if the network is there, and creates it if it isn't. Then you configure the devcontainers in your projects to be on that network, and when you start them up, if the ollama local cluster is up, your vscode extensions can connect, but if they aren't then it won't crash the container. Another thing worth doing is to store your models in a persistent location on the host, which you then bind mount into the ollama container. That way you don't have to re-download all the models each time your rebuild the container.

Backup and restore for your podman images

Rebuilding podman vm should not be difficult. At first I was super worried about recreating the podman vm, because when you do that you lose all your images, and since some of these can be very large, it can take ages to re-download them. I created some bash scripts to save images to a backup location and another to restore them all in one go.

Repeatable way to download and install assets into your containers

This was another useful script to write, which downloads an asset into a persistent location in your home directory, and copies it into your project's assets directory, which you add to gitignore. Have an npm script to initiate all project downloads. It means you can download everything you need when you have an internet connection, but you aren't blocked if you go offline. For example I download the neovim tgz, which later during the development image build, I install via a script that I copy across with the asset to the image, install and then delete the extra files.

Wrapping up

So those are the main things about the new project structure. I'm pretty happy with it as it feels very lean and efficient. It's only been a few days so far, but it looks good, and now that everything is working and there aren't a billion errors everywhere, containers for development definitely feels like the right approach to these modern web apps, especially when it involves developing with AI/LLMs.

How to rebuild a project from scratch

Rebuilding a project from scratch is one of those things that you never really want to do, and so there is a tendency to avoid it. But sometimes you realise that some or all of the foundations are compromised in some way, and the only way to fix them is a complete project rebuild. That just happened to me, and though it was pretty scary, and a lot of effort, I got through it, and it actually wasn't as bad as I thought it would be once I made the decision to do the full rebuild.

Keep in mind that I was the only person working on this repo, if you are working on a repo with others, definitely discuss with them before undertaking anything like this.

I think this is a situation that will become more common place now that many of us are using powerful AI/LLM tools, because as much as you can gain tremendous ground in build outs, it's very easy for instability to creep in without you noticing. This is something that would have happened anyway without these tools eventually, it's just that you reach that point a lot faster. Anyhow you will likely find most of this is just common sense, but when you are in the thick of it, seeing the woods for the trees isn't always obvious.

So what happened? Well the past month or so I have been building an Oauth 2.0 REST API backend with a React frontend. And I have been using AI/LLMs and devcontainers. Quite a few ups and downs, but I was making some seriously great progress, at least from the perspective of the application code. Both the frontend and backend were super solid. I was on solid ground, or so I thought. While experimenting with running Ollama locally on my laptop I uncovered some problems with my dev environment. The devcontainers I had been using started crashing on startup. I tested various configurations, and that uncovered some misalignment in my typescript and linting configurations, and before I knew it, I was in a giant tangle of errors.

I am big into writing minimal projects. Over the years, I have written many of these, I have a special section dedicated to them in my portfolio. When you get into difficulties it's often a great way to find stability again. There is a real art to it. In this specific case, things were so tangled that it wasn't obvious at the outset what exactly the minimal example should be. I thought initially that what I needed was a minimal example of an ollama based webdev environment, and spent a while doing that. Eventually I realised that ollama was complicating things, and that actually what I needed was a NodeJS Typescript Express React minimal example. A minimal project all built on containers. That was a bit of a voyage in itself, but once this new well structured project was working, the big challenge was to backport it into the original, much more complex, REST API + React project.

I thought initially that I would be able to move over pieces of it bit by bit into the old structure, reshaping as needed. It became obvious that was just never going to happen. I spent hours just pushing small pieces around, testing and seeing everything was still broken, and being totally overwhelmed by the enormous mountain of files, all out of shape, plagued by lots of false starts and bits sticking out all over the place. It was a monstrosity. I knew I now had a known good structure, but I didn't want to start a fresh project because I didn't want to lose the project history. There was a lot of valuable information in there. Here's what you do.

  • First of all, backup the main branch, call it something like main-backup. Also backup the entire project into a tar ball.
  • Ok you backed everything up. That's good, but also very very important, backup the project git directory (.git). It's a hidden dotdirectory. You will only be able to see those with ls -la.
  • I decided to not use AI in Vscode for any of this. It has a tendency to try and take over and lead you down terrible paths. I did use AI but only via web browser. I was using Gemini.
  • Create a new feature branch, call it something like refactor-new-project-structure.
  • Make sure you are on your new feature branch, and then delete everything! Literally rm -rf * in your project directory. You read that correctly. This is the scary part. Remember you have backups. If you don't have backups, go back to the start of this list and make backups! Remember to delete all the dotfiles and dotdirectories in the project directory too. I just did that one by one, after deleting all the regular non-hidden files. One important exception: Don't delete the .git directory. That's where all your project git history is stored!
  • Recreate / copy across your minimal working project to your new feature branch.
  • Get that working, test it's all working. There is no reason it won't work since you already got it working in a separate project.
  • Now pull bits in piece by piece from the old main branch into your feature branch. It's a bit tedious, but you can do this relatively easily. Take your time. Don't miss anything. List, view and checkout files and directories direct from the main branch using these commands:
    • git ls-tree --full-tree -r main
    • git ls-tree main:some/folder
    • git show main:path/to/some/file.js
    • git checkout main -- some/folder
    • git checkout main -- path/to/some/file.js
  • Add and commit each piece with a descriptive title. Keep testing it's still working along the way. Ditch anything you no longer need from the old main branch.
  • Eventually you will have recreated your project, and it will all work again!
  • You will have a long series of rebuild commits. I had about 30 commits.
  • Backup the rebuilt branch, call it something like backup-rebuilt-project.
  • At this stage, things were still kind of complex, so I asked AI to help me group these into 6-7 groups of related commits that describe the rebuild process, so I could squash them into a more focussed history. I copied and pasted into the chat the relevant commits listed from running git log.
  • The AI came up with a great rebase plan, with the commit titles and the commits all grouped. I just had to copy and paste into the rebase in neovim. It also prepared titles for each of the 6-7 squashed commits, which I copied and pasted into the rebase as it progressed. I rebase squashed the slightly haphazard 30 commits into 6-7 tidy and focussed commits.
  • At this stage, make another backup of the rebased branch, just in case!
  • Now change to main branch, merge in the squashed branch. In my case there were literally no conflicts, which was a relief but not too surprising.
  • Backup the merged main branch into your final branch that you will merge in on Github, call it something like refactor-new-project-structure-final.
  • Reset your local main branch to the state of the Github remote main branch.
  • Push your final merged branch to Github and create a PR.
  • Merge the PR into main. I got the AI to write me a comprehensive PR message that summarized the rebuild.
  • Pull latest changes to your local main branch from remote, test it's all working on main branch locally.
  • Now that everything is merged into main, and tested, cleanup by deleting all your backups and the feature branch.
  • You are done! Do a retrospective on the nightmare you just completed. What did you learn?

You see it actually wasn't that bad, mostly common sense.

In my case the biggest structural change I had made was to move several top level folders which were all npm workspaces under a single packages folder. Git is quite clever and can mostly keep track of where files have moved to. Apparently having a focussed series of commits makes that easier. Aside from that most of the changes were to do with typescript and eslint configurations. The bulk of the code was completely unchanged. The only code that had changed was some of the npm scripts and Dockerfiles/Containerfiles. But there wasn't that much to merge in, just a few things that were in the original files that I wanted to keep, like installing neovim and networking tools for the devcontainer image. I just copy and pasted those across manually, and tested it was all working.

There were a few odds and ends to smooth out, like updating to a more recent development base image, so neovim had all the libraries it needed, and a bit of a refactor of how the backend production image assembles the final production node_modules folder to include the shared workspace. On the whole though, it was a lot smoother than I expected, even if it was kind if scary.

If you are in a similar situation, I know it sure isn't that much fun, but hopefully reading this will help somewhat. Sometimes it just helps to see something similar mapped out and to have the knowledge that it's possible.

Containers, Typescript, React and AI/LLMs

I wrote a while back about how Typescript, React and Gemini was a pretty great combo. I still stand by that post, but I want to make add a slight refinement: Containers.

Yes, in a way it’s much more complex, at least initially, and you can definitely fall down some very dark debugging and configuration holes, but once you get the hang of it, it's kind of night and day compared to developing the old school way. With containers you have these neat little worlds all bundled and isolated that you can move around as a unit, much like an actual container. There is quite a lot to think about, but once you have your build process figured out, everything is very seamless. At any stage of development you can run a script and a few moments later the app that you were working on is neatly packages up, and you can test it, as if it were deployed, and then of course deploy it.

This becomes really interesting when you are working with AIs because you can create these environments where you can rapidly experiment and explore new things in a safe way, knowing that if anything goes wrong, you won't have trashed your base host machine.

The way I have things setup now, it's sort of like a digital version of a mechanics garage. I have several pretty solid dev tools like an IDE, and all sorts of CLI tools, and now with containers I can whenever necessary spin up a cluster of locally running AI/LLMs which I can connect to directly from within any of my projects in VScode or via a web browser chat interface. It's like there is this big mechanical robot attached to the ceiling, that I can pull down anytime I need to do something requiring something specialist to handle particularly tricky or complex situations.

It's not just local LLMs, I can connect up to cloud LLMs too, which are even more powerful.

React makes building dynamic frontends way easier, and the Typescript acts like scaffolding, giving you lots of structure to help you navigate difficult situations with the AIs. You are able to spot when things go off the rails way sooner than you would otherwise, and as long as you have some well thought out best practices, you can achieve some incredible things.

Things like guarantied end-to-end data integrity. By sharing types between the backend and frontend, creating your types by inferring them from zod templates, which are used to validate data on the backend, while simultaneously strongly typing all objects that you pass to the backend service layer, and doing the same with all responses to the frontend, you can get to a place where your app is incredibly robust. And I‘ve found that the AIs perform much better too, because they have a lot more information to go on as to your intent.

Lots of other things are possible too, here are a few that I wrote about previously in my post about recent project milestones.

And with containers you now control the entire environment within which your apps run, both in development and in production. When I built linkblog.io, I made a conscious choice to build without containers because at the time I felt they weren't ready for prime time. Back then I wrote about the robust NodeJS architecure you could build using open source software and cloud services, I knew at some stage I would eventually be moving to using containers, and there is little doubt in my mind that now is that time.