I've been toying with a new side project recently. Having built it in Django, I was looking for a way to host it without using a cloud hosting provider.
After a bit of research and trying out a couple of options (including Kamal, Dokku, Dokploy, and Uncloud), I ended up going with Uncloud. So far I'm very happy with the choice. Uncloud has a nice developer experience and overall the process of getting a Django app up and running was smooth. In this post, I'll describe what that setup looks like.
Why self-host
Short answer: Pricing.
Cloud hosting tends to become expensive quickly, and I'm a bit of a cheapskate when it comes to funding my side quests. Also, the amount of raw computing power you can get on a VPS for roughly the same price as you'd pay for hosting it on a cloud provider is ridiculous.
Adding some context here: after deciding to self-host this project, I signed up for a small Linux VPS from IONOS. At the moment I'm paying about €2 a month and the machine has 2 CPU cores, 4GB RAM, and an 80GB NVMe disk, which would cost an order of magnitude more with a cloud provider. IONOS does have a bit of a wonky UI, but at this price, it's bearable.
Initializing Uncloud
After provisioning your VPS, installing the Uncloud CLI locally, and putting a few basic security measures in place, the first step is to have Uncloud initialize your server.
Run uc machine init root@<your-server-ip> to have Uncloud do its thing. This installs
Docker and the Uncloud daemon on your server, deploys Caddy as the reverse proxy
listening on ports 80 and 443 (and even reserves a free <name>.uncld.dev subdomain
pointing to your server's IP). All this takes only about a minute.
Once that's out of the way, we can start by telling Uncloud about our Django app. Luckily, it supports the standard Docker Compose configuration, so if you're using compose, you should be good to go with a few minor additions to the compose configuration.
Assuming you have a Dockerfile suitable for Django applications in your project root,
here's a very basic (but complete) example:
services:
web:
build:
context: .
platforms:
- linux/amd64
x-ports:
- app.example.com:8000/https
x-pre_deploy:
command: uv run python manage.py migrate
environment:
DEBUG: False
x-ports is an Uncloud-specific extension for port forwarding. Uncloud uses Caddy as
the reverse proxy. Setting something like app.example.com:8000/https tells Uncloud to
expose port 8000 (from our Django server) to the given domain name and have Caddy
provision an SSL certificate automatically. Similarly, the x-pre_deploy entry is
another Uncloud-specific extension that runs an arbitrary command using the service
image before the deployment. Django apps (or really most apps with a database
connection) need to run database migrations as part of the deployment, and
x-pre_deploy helps us do that.
Adding a database
The configuration above is a good start. If you want a separate database service running alongside Django, defining it in the compose configuration should be enough. Uncloud should detect it and provision a database container on the target VPS.
services:
web:
build:
context: .
platforms:
- linux/amd64
x-ports:
- app.example.com:8000/https
x-pre_deploy:
command: uv run python manage.py migrate
environment:
DEBUG: False
DB_HOST: postgres
DB_NAME: example
DB_USER: example
DB_PASSWORD: example
depends_on:
- postgres
postgres:
image: postgres:18
environment:
POSTGRES_DB: example
POSTGRES_USER: example
POSTGRES_PASSWORD: example
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U example -d example"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
Adding a separate volume for the postgres container is necessary in this case to make
sure that the data is stored outside the container so it can survive service restarts.
The depends_on entry simply tells Uncloud to deploy web after postgres, and the
healthcheck lets Uncloud know whether the database is actually ready to accept
connections. There is obviously a lot more you should do to make this database setup
production ready (including not running the database service inside Docker Compose
entirely), but that's beyond the scope of this post.
Configuring secrets
In the previous section we set POSTGRES_PASSWORD and DB_PASSWORD to example. This
is a bad idea in most situations. Uncloud has native support for sensitive data that
lives outside of the compose configuration. This involves defining a top-level secrets
section in compose.yaml and telling Uncloud how to resolve the secret value.
As an example, let's add Django's secret key as a secret:
services:
web:
build:
context: .
platforms:
- linux/amd64
x-ports:
- app.example.com:8000/https
x-pre_deploy:
command: uv run python manage.py migrate
environment:
DEBUG: False
DJANGO_SECRET_KEY: secret://django_secret_key
secrets:
django_secret_key:
x-command: cat ./secrets/django_secret_key.txt
In this example we ask Uncloud to resolve the django_secret_key secret by reading it
from the ./secrets/django_secret_key.txt file. While there's a shorthand for this
(replace x-command with file: ./secrets/django_secret_key.txt), you can provide any
command to read the secret value. For instance, you could have Uncloud read from your
1Password vault using op read "op://production/example/django_secret_key". As long as
it's a shell command that returns the secret value successfully, Uncloud can work with
it.
Deploying
With the compose configuration complete, deploying is a single command:
uc deploy
uc deploy builds the service images locally, pushes them straight to the VPS (no
external container registry needed), parses the compose configuration to print out a
deployment plan of exactly what will change, and performs a zero-downtime rollout once
you confirm the plan.
Conclusion
When setting up DevOps tooling I'm always extra cautious as almost nothing works on the
first try. Uncloud really was an exception. After running uc machine init and adding
the x- extensions to my compose configuration, running uc deploy basically went
through without a hitch. And for a single-server side project it hits a sweet spot:
compose-compatible configuration and none of the ceremony of a full container
orchestrator. There's plenty of other tooling in this space (e.g. Kamal, Dokku, etc.),
but the direction that Uncloud is focusing on is very interesting and is definitely
something to keep an eye on!