One Quick Way To Host A WebApp

From Rest of What I Know
Revision as of 21:23, 18 November 2025 by Roshan (talk | contribs) (The App)

I host a couple of web-apps on a VPS and it's gotten to the point that I'm fairly quick at doing it. However, if you haven't done this before, there are a lot of fiddly bits and it doesn't make much sense. I'll write it down here since I easily forget.

Nothing particularly complex and you can get rid of any cloudy bits

This is mostly driven by the problems I've run into doing other things, and the specific considerations of how I like to host stuff (get a weak VPS and put everything on it) because I don't get that much traffic.

Considerations

  1. Things should be replicable given software changes over time
  2. Things should be easy to edit and update
  3. The iteration cycle should be short

These are all sort of important because I used to have everything on the machine raw and over time my 10 year box accumulated so much bespoke fixing that I couldn't easily recreate it, resulting in the death of this other blog I used to have but also I don't want to deal with too much infra (say a container registry and kubernetes and so on). Others may have other concerns but this balance feels right for me.

The Design

  1. I use Namecheap for domains ever since Google Domains went down
  2. I use Cloudflare for DNS ever since Google Domains went down
  3. I use Cloudflare as a web proxy
  4. I use Cloudflare R2 for persistent storage
  5. I use nginx as a reverse proxy
  6. I use certbot to get a Let's Encrypt SSL cert
  7. I run each app in Docker
  8. I keep them running using systemd
  9. I use a single MySQL and a single PostgreSQL on the same host
  10. I build my apps on the host
  11. I use Cloudflare Tunnel to self-host on my home hardware

The idea is that nothing should lock me in too hard because I will likely not pay enough attention to it and the ecosystem will change around me and then I'll be stuck. Each bit here acts as middleware and is easily substitutable.

The rawest thing is the SQL DB and the app itself. We wrap that in Docker so environments are fixed. We wrap that in systemd so restarting is easy. We point nginx to that so that we can have a high-performance front-end serve static files and so on. We wrap that in Cloudflare so that we can get speed. And then we need DNS and a domain to get things done.

R2 for storage means we've got all the data somewhere nice and with it mounted locally we can just dump DB backups to it.

If each piece went away it would be straightforward to just disable it and move one step back. Nothing is catastrophic.

The Environment

Domain

I usually have a domain already but otherwise I buy it on Namecheap because it's straightforward to use. It's pretty much only good as a domain registrar and I don't use any of the other features with it. Any registrar is fine here.

DNS

The first thing I do is create a Cloudflare zone and point the NS records to Cloudflare. CF is way better at managing DNS and has all sorts of features that make it worthwhile. Then, once CF is set up, I point the record at my host without proxying. This does reveal the underlying IP but that's fine because no one is trying to hurt me.

I do this primarily to simplify HTTP-based certbot later. I've also done Cloudflare DNS based certbot and it's actually just as easy, but you have to create a cloudflare token for the zone and put it on the host.

But an additional benefit is that at this stage if you put a web server on the host then you're functioning as raw as can be. You can run (in a temporary directory with a simple `index.html`):

   python3 -m http.server 10000

And verify that you can access it at http://sub.roshangeorge.dev:10000 , and you know you're set up for the world. You don't have HTTPS but what are you securing anyway. If you can't access things at this point, you should try via the IP (which means DNS is the issue) and then from the host itself (which may mean that you didn't bind to the external IP or 0.0.0.0 if you're promiscuous) and then you should check your firewall.

Nginx

Nginx is a good web server to use as a reverse proxy. Some people use Caddy since it has Let's Encrypt built-in for HTTPS but this works for me so I use it. k3s and friends use Traefik and that works well too. In the LLM age, one advantage is that Nginx is well-documented and has been around for a while with a consistent configuration format.

As usual I make one of these simple config files in `/etc/nginx/sites-available/sub.roshangeorge.dev` and symlink from `/etc/nginx/sites-enabled/sub.roshangeorge.dev` to it.

server {

    server_name sub.roshangeorge.dev;

    location /uploads/ {
        alias /mnt/r2/uploads/; # Serve static files from this directory
        expires max;
    }

    location / {
        proxy_pass http://localhost:10000; # Proxy pass to app
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

}


This is roughly the same each time. I want some things to be static, usually favicons and robots.txt and llms.txt and stuff like that. And the rest I'm going to send over to the app which is going to run at that port.

I perform the cargo-cult ritual of restarting nginx after each of these things, though technically a `reload` should do the trick.

   sudo systemctl restart nginx

Once this is all running, we should be able to go to http://sub.roshangeorge.dev and it should correctly show what the app would show (which in our case is Python's default `http.server`). If I have websockets there are a few more incantations around the `Upgrade` header.

Certbot

For SSL, I just run certbot on the machine. When you run certbot for the first time it will prompt you to select which host you want to configure. Or you could just tell it yourself.

   sudo certbot --nginx -d sub.roshangeorge.dev

As usual, I do the ritual restart of the nginx. Then we should be able to access https://sub.roshangeorge.dev over SSL and we're pretty happy about the environment.

If you check your `sites-available/my-site` you'll see that certbot has added the certificates in and everything. It also adds a recurring job to refresh the certificates.

Cloudflare Web Proxy

At this point if you want, you can go back and switch the DNS settings on Cloudflare to start proxying. If you then access your site it should just work. Doing it earlier makes debugging hard, but doing it now should just work.

Cloudflare R2

I also like to mount an R2 bucket so that I have lots of storage and a backup target to hit.

   s3fs r2-storage /mnt/r2 -o rw,allow_other,umask=000,passwd_file=/etc/fuse-r2/passwd-s3fs,url=https://userspecific.r2.cloudflarestorage.com,use_path_request_style,nomixupload,dev,suid

You'll have to make sure your `/etc/fuse.conf` has the following in order to allow ordinary users access

   user_allow_other

And then you can mount as one user who has access to both MySQL and your R2 and they can go around backing up stuff to R2!

Cloudflare Tunnel

You can do all of this on your home hardware if you feel like it. The way I do this is that I have an Epyc-based home server running at home on my Google Fiber connection. I then use Cloudflare tunnel to link traffic between Cloudflare's network and that home server. This takes the whole dynamic DNS business out of the picture and you can just push traffic through the tunnel.

One downside is that if you lose power at home, your site is going to go down. But you can use Cloudflare's Always On to get cached versions of your pages out of the Internet Archive. That's good enough for me since I can bring things up at a later date, and switching off this is just a quick DNS swap to an externally visible host.

The App

The App Itself

The first thing I try to do is get the app running one way or the other. This part is easy and I just iterate locally on my dev machine (a Macbook). Once I've got it running, I try to encode that state into a Docker container. I use Orbstack to develop on MacOS because it's far more convenient than Docker proper.

This is not-technically required, but since I have low usage, I can pack more things onto a single box with Docker since I don't have to worry about environments clobbering each other. It's also not usually too hard.

Database

If you're writing a personal application, SQLite is the best tool to reach for. It's a single file and it's easy to deal with. If you're also hosting other things, like I am with Mediawiki, it is worth running your distribution's default MySQL/PostgreSQL on the host itself. If you're going to use Docker later, remember that users will need to be granted permissions to log in from the Docker/Podman network too (which is often 10.x.x.x).

Hosting Your Code

You can build your app and only deploy the artifacts, but I've found it much easier to just push the source code and build the app on the production host. The way I do this is perhaps unorthodox or perhaps standard.

First, create your app directory:

   mkdir ~/sub.roshangeorge.dev

Then make it into a git repo:

   cd ~/sub.roshangeorge.dev
   git init .

Then on your dev machine, add it as a remote:

   git remote add deploy user@host:~/sub.roshangeorge.dev
   git push deploy main

Now, this would seem to be enough, but you can't push to a branch that is checked out. So what I do is that I only ever have 'releases' checked out. And a release is just a tag. So on my local machine, I might do.

   git tag release-20250313
   git push deploy --tags

And then on the host I check out the relevant tag when I want to run that:

   git checkout release-20250313

This little trick lets me just `git push` when I want to deploy. If you like, you can put in post-commit hooks and all that, but I haven't found them useful. I just ssh on and build.

Building

I like to have, in each of my repos, a `bin/build` script. For the ones with Dockerfiles these look as simple as:

   #!/usr/bin/env bash
   podman build -t sub.roshangeorge.dev:latest .

A convenience thing I have throughout is that I refer to the app everywhere by some canonical subdomain. The docker tag is the subdomain, the nginx site is the subdomain name, the code directory is the subdomain. Everything is the subdomain.

I also have a `bin/backup` that describes how to backup.

Containerization

I prefer using Linux containers for everything because they pins the environment but also because they retain app environments in their own worlds. Mediawiki, which this site is based on, is written in PHP and composer (a PHP package manager) just goes everywhere. With docker/podman, all that is contained in one place.

Other languages don't do this as much. For example, Rust's `cargo` is quite well-contained in that it runs inside a user-context. So I build Rust programs in their own directory. I used to run Python programs in containers exclusively but with `mise` and `uv` it is possible to run them raw. I still prefer to run them in containers, though. I just have to remember to make them listen on a unique port, and to expose the port.

Quadlets

I used to use Podman and then write a traditional systemd unit file to manage the container as if it were a service. I liked that approach because the fact that it was in podman was an implementation detail of the app. I could just as well have it a bare binary doing the same thing and I'd run the same `systemctl` command.

I later discovered systemd quadlets: containers managed by systemd itself. These are quite nice since you write them in a format that is like a unit file and you can use your usual systemctl commands to manage them directly. Here's an example quadlet configuration.

[Unit]
Description=MediaWiki Container
After=network-online.target
Wants=network-online.target

[Container]
Image=localhost/wiki.roshangeorge.dev:latest
ContainerName=rowik
Environment=MEDIAWIKI_DB_TYPE=mysql
PublishPort=127.0.0.1:8080:80
Volume=/mnt/r2/uploads:/var/www/html/images:Z
AutoUpdate=registry

[Service]
Restart=always
TimeoutStartSec=300

[Install]
WantedBy=multi-user.target default.target

One debug annoyance is that viewing quadlet startup logs is not straightforward if you have made an error in quadlet configuration. Instead of an error in your service, it is a syslog error on the unit file generator. This is because when a quadlet is configured like this, what happens is that systemd has a generator that converts it to a service file on the fly and then runs that, so if you have a configuration error here it isn't marked against the service and so you'll just get a 'service not found'. Regardless, I find quadlets nicer to manage than writing a run script and a systemd unit file around that script.

Docker / Podman

The default thing I used to do was build the Docker image on the server and just run them there. When you do that, you can use docker/podman for process management or you can use systemd to manage your docker/podman commands. I prefer using systemd because then I don't have to remember which parts are managed with systemd and which ones with docker.

One thing to note with podman is that you have to fully specify the name of parent images unless you configure the default to docker.io. Fully specifying the parent images is more portable in that you just copy the Dockerfile elsewhere and it will still build. Here's an example Dockerfile for my wiki.

FROM docker.io/mediawiki:latest

RUN apt-get update && apt-get install -y lua5.4 sendmail

COPY ./apache.conf /etc/apache2/sites-available/000-default.conf

WORKDIR /var/www/html

COPY custom-php.ini /usr/local/etc/php/conf.d/custom-php.ini

# Copy any additional directories (extensions, skins, etc.)
COPY extensions/ ./extensions/

COPY robots.txt ./robots.txt

RUN ln -sf images uploads

COPY LocalSettings.php ./LocalSettings.php

# update if possible (so migrations etc. can run)
RUN php maintenance/update.php --quick

# generate the sitemap (so that google can find sub-pages)
RUN php maintenance/run.php generateSitemap --compress=no --identifier=sitemap
RUN cp sitemap-index-sitemap.xml sitemap.xml
Process Management

With docker/podman, you can just set them to restart or whatever whenever you like and things will stay perpetually running, but I like a single system for all my apps, whether docker or not, and so I use systemd to manage them. An example systemd file looks like this for a docker app:

[Unit]
Description=App Container
After=network.target

[Service]
Type=simple
User=ubuntu
ExecStart=/usr/bin/podman run --name sub_roshangeorge_dev --mount type=bind,source=/mnt/r2/files,target=/var/www/files -e ENV="var" -p 127.0.0.1:8080:80 sub.roshangeorge.dev:latest
ExecStop=/usr/bin/podman stop sub_roshangeorge_dev
ExecStopPost=/usr/bin/podman rm sub_roshangeorge_dev
Restart=always

[Install]
WantedBy=multi-user.target

But you could just as well have a simple non-docker version (this one is a mail server not a web-app but the idea applies)

[Unit]
Description=Superheap RSS Service
After=network.target

[Service]
Type=simple
User=ubuntu
ExecStart=/home/ubuntu/superheap/target/release/superheap serve -c /home/ubuntu/rss/superheap.json
Restart=always
RestartSec=5
WorkingDirectory=/home/ubuntu

[Install]
WantedBy=multi-user.target

While you'll want to symlink these into either `/etc/systemd/system/sub.roshangeorge.dev.service` or `~/.config/systemd/user/sub.roshangeorge.dev.service` if you want to keep them entirely home directory constrained, you can just leave the service unit file in your repo and have it symlinked in. Because your entire repo is on the host, you can just embed all of the host configs in your repo and then appropriately symlink them.

Registry

Originally, I used to just build the docker images on the server itself. This was simply out of convenience. After some time, it got to be a bit of a hassle and I found myself doing the annoying thing of just writing code on the server and building the image there. Well, that's a bit dopey, but it's very easy to fix. You can run a registry container on the server and just push images to it when you're ready.

ubuntu@kant:~$ cat ~/.config/containers/systemd/registry.container
[Unit]
Description=Container Registry
After=network-online.target
Wants=network-online.target

[Container]
Image=docker.io/library/registry:2
PublishPort=5000:5000
Volume=registry-data:/var/lib/registry
AutoUpdate=registry

[Service]
Restart=always
TimeoutStartSec=900

[Install]
WantedBy=default.target

That's a systemd quadlet that runs the registry. I do the usual `docker build --platform=linux/amd64 .` on my Mac and then the `docker tag registry.blahblah.com` and then `docker push` to it and it all works just like you'd expect.

Conclusion

To be honest, I've found this entire thing quite straightforward to use and the reason I use it is that few of the parts are integrally linked to others. That makes it easy to just `ssh` on and debug things. Each piece just does the one bit. And in a world with no Cloudflare, it would all stay the same (just my DNS would be from some other provider, and I'd store stuff on-host). I don't specifically need docker or anything, it's just one piece of the puzzle.

Hopefully, in solving my "the environment has changed" problem I haven't introduced anything new and novel to my universe. For the couple of years I've hosted using this solution.