Deploy Django Without Docker: Gunicorn, systemd and Caddy
Deploy Django without Docker on your Ubuntu VPS: gunicorn under systemd, Caddy for HTTPS and PostgreSQL. Every step by hand, then the same deploy with ox.
You can deploy Django without Docker by running it as a plain process on an Ubuntu server: gunicorn serves the app, systemd keeps it running, Caddy handles HTTPS, and PostgreSQL holds the data. Ubuntu already ships every one of those pieces, so there is no image to build and no container runtime to babysit.
This guide does it by hand first, so you know what every piece does. Then it shows the same deploy with ox, which sets up those same pieces on your server for you.
In this guide:
- What you need
- The pieces of a Django deploy without Docker
- Deploy Django by hand, step by step
- What the manual setup leaves to you
- The same deploy with ox
- Frequently asked questions
What you need
A VPS running the latest Ubuntu LTS. Hetzner, DigitalOcean, Vultr and Linode rent them for about $5 to $24 a month.
A domain name, with an A record pointing at the server's IP address.
A Django project in a Git repository, with its dependencies in
uv.lockorrequirements.txt, andgunicorn(oruvicornfor ASGI) among them.
The pieces of a Django deploy without Docker
| Piece | Job |
|---|---|
| gunicorn | Runs your Django app (WSGI) on a local port, with a few worker processes. |
| systemd | Starts gunicorn at boot, restarts it if it crashes, and keeps its logs in the journal. |
| Caddy | Answers on ports 80 and 443, gets and renews the HTTPS certificate, serves static files, and passes the rest to gunicorn. |
| PostgreSQL | The database, on the same server, reachable only from it. |
Nginx and Certbot can replace Caddy. Caddy is used here because it handles certificates on its own and its config is a few lines. If you prefer Nginx, Supervisor and Uvicorn, the beginner's guide to Django with Uvicorn and Nginx walks through that stack.
Deploy Django by hand, step by step
1. A user for the app, and the code
Run the app as its own user, never as root.
sudo adduser --system --group --home /srv/myapp myapp
sudo chmod 755 /srv/myapp
sudo -u myapp git clone https://github.com/you/myapp.git /srv/myapp/app
The chmod lets Caddy read the static files later. A private repository needs a deploy key or a token for that clone.
2. Python and dependencies
uv installs Python and your packages in one step.
sudo -u myapp -H sh -c 'curl -LsSf https://astral.sh/uv/install.sh | sh'
cd /srv/myapp/app
sudo -u myapp -H ~myapp/.local/bin/uv sync --frozen --no-dev
With requirements.txt instead, make a virtual environment and run uv pip install -r requirements.txt in it.
3. PostgreSQL
sudo apt install -y postgresql
sudo -u postgres createuser myapp --pwprompt
sudo -u postgres createdb -O myapp myapp
4. Production settings
Read secrets from the environment, never from the repository. Put them in a file only root and the app can read:
# /etc/myapp.env (chmod 640, owned by root:myapp)
SECRET_KEY=a-long-random-string
DATABASE_URL=postgres://myapp:[email protected]:5432/myapp
ALLOWED_HOSTS=example.com
DEBUG=false
In settings.py, read them:
import os
import dj_database_url
SECRET_KEY = os.environ["SECRET_KEY"]
DEBUG = os.environ.get("DEBUG") == "true"
ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
DATABASES = {"default": dj_database_url.config(conn_max_age=600)}
STATIC_ROOT = BASE_DIR / "staticfiles"
Django's own python manage.py check --deploy lists the settings still missing for production, and the official Django deployment checklist explains each one.
5. Migrate and collect static files
cd /srv/myapp/app
sudo -u myapp -H sh -c 'set -a; . /etc/myapp.env; set +a; \
~/.local/bin/uv run python manage.py migrate --noinput && \
~/.local/bin/uv run python manage.py collectstatic --noinput'
6. A systemd service for gunicorn
# /etc/systemd/system/myapp.service
[Unit]
Description=myapp (gunicorn)
After=network.target postgresql.service
[Service]
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/etc/myapp.env
ExecStart=/srv/myapp/app/.venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 3
Restart=always
[Install]
WantedBy=multi-user.target
Replace config with your project's package, the folder that holds wsgi.py. Then start it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
journalctl -u myapp -f
7. Caddy in front, with HTTPS
Install Caddy from its official package (the Caddy install docs list the apt commands for Ubuntu), then:
# /etc/caddy/Caddyfile
example.com {
handle_path /static/* {
root * /srv/myapp/app/staticfiles
file_server
}
reverse_proxy 127.0.0.1:8000
}
Run sudo systemctl reload caddy. Caddy gets a certificate for example.com on the first request. Open only SSH, 80 and 443 in the firewall:
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable
What the manual setup leaves to you
Your site is live. Here is what the setup above does not do yet:
Updates without downtime. A
git pullandsystemctl restartdrops requests while gunicorn restarts, and a broken commit takes the site down. Doing it safely takes a second copy of the app and a switch; see zero-downtime deploys with systemd and Caddy.Backups. Nothing saves the database yet. You need a daily
pg_dump, a copy off the server, and a restore you have tried once.Going back. When a release misbehaves, you need the previous one ready to run, not just its commit.
Deploy on push. A GitHub Action over SSH, or a webhook script, with the secrets that come with it.
A place to test. A staging copy with its own database, so you never try a migration on real data.
The same deploy with ox
ox sets up the same pieces on your server: your app runs as a systemd service under its own user, Caddy serves HTTPS, and PostgreSQL holds the data. There is still no Docker. The control panel is hosted by ox, so you install nothing but a small agent on the server.
The steps
Sign in with GitHub at deploywithox.com.
Add your server. ox shows one command to paste on a fresh Ubuntu LTS server. It installs the agent and Caddy and locks the server down: a firewall that allows only SSH, 80 and 443, fail2ban, automatic security updates, and no password logins.
Pick the repository. ox reads it and shows the plan before anything runs.
Set your variables, like
SECRET_KEY, then deploy.
The ox quickstart shows each of these screens.
What ox detects from a Django repo
From manage.py and your lockfile, with no config:
The install:
uv sync --frozen --no-devwithuv.lock, or a virtual environment andrequirements.txt.The start: gunicorn on your project's
wsgi, or uvicorn onasgiwhen uvicorn is a dependency andasgi.pyexists.The migration:
manage.py migrate --noinput, run before visitors move to the new release.collectstatic --noinput, whensettings.pysetsSTATIC_ROOT.PostgreSQL, when
psycopgorpsycopg2is a dependency and the repo has noox.tomlyet.
A small ox.toml
To set the domain and let Caddy serve your collected static files, add ox.toml at the repository root:
domains = ["example.com"]
[static]
paths = { "/static" = "staticfiles" }
[services]
postgres = {}
Once a repo has an ox.toml, the services are exactly the ones it lists, so postgres is declared here. ox provides DATABASE_URL, PUBLIC_URL and PUBLIC_HOST. You set SECRET_KEY, and ALLOWED_HOSTS can point at ox's value as ${PUBLIC_HOST}. Keys listed in your .env.example are required, and a deploy waits until each one is set. Run ox check in the repo to see the whole plan offline. The config reference lists every key and default.
After the first deploy
Every push to your production branch deploys. The new release starts beside the old one and visitors move over only after it answers.
The database is backed up every day, kept on the server, and copied to your own S3-compatible bucket if you add one. ox also takes a snapshot before each migration. How ox backs up and restores PostgreSQL covers how many it keeps and how to restore one.
Rollback goes back to a kept release without rebuilding it.
A staging copy, and a preview for each branch once you turn previews on, each get their own database.
Celery and scheduled jobs
A Celery worker, beat, and cron jobs go in the same file:
[workers]
worker = "uv run celery -A config worker --loglevel=INFO"
[cron]
digest = { schedule = "0 7 * * *", run = "uv run python manage.py send_digest" }
[services]
postgres = {}
redis = {}
ox provides REDIS_URL for the broker. The Django deploy guide has a complete ox.toml with a health check view, uploads kept across releases, and a daily cron job.
Manual setup or ox: what each one gives you
| Task | By hand | With ox |
|---|---|---|
| HTTPS certificate | Caddy, once you write the Caddyfile | Caddy, set up for you |
| Restart on crash and at boot | Your systemd unit | A systemd unit ox writes |
| Deploy on git push | A GitHub Action or webhook script you maintain | Built in |
| Updates without dropped requests | A second copy of the app and a switch you build | Built in, after a health check |
| Daily database backups | A cron job with pg_dump and an off-site copy | Built in, with optional S3 |
| Rollback | Keep old releases and switch back yourself | One action, no rebuild |
Key takeaways
- Django does not need Docker in production: gunicorn, systemd, Caddy and PostgreSQL on Ubuntu are enough.
- Keep secrets in an environment file the app user can read, and run
manage.py check --deploybefore going live. - The hard parts come after the first deploy: updates without downtime, backups, rollback and a staging copy.
- ox sets up the same native pieces on your own server and adds those parts, with no containers.
Frequently asked questions
Do I need Docker to deploy Django?
No. Django runs well as a plain Python process under systemd, behind a web server like Caddy or Nginx. Docker is one way to package an app, not a requirement. On a single Ubuntu server, a virtual environment, a systemd unit and a reverse proxy give you the same result with fewer moving parts.
Should I use gunicorn or uvicorn for Django?
Use gunicorn for a regular WSGI Django app; it is the most common and best documented choice. Use uvicorn when you rely on Django's async views, websockets through Channels, or another ASGI feature. ox picks uvicorn on its own when uvicorn is a dependency and your project has an asgi.py file.
How big a VPS do I need for Django?
A small Django site with PostgreSQL usually runs in 1 to 2 GB of memory. Add more if you run Celery workers, Redis, or several apps on one server. ox tests every release on servers with 4 GB, which leaves room for a few apps, a staging copy and branch previews.
Where do uploaded media files go without Docker?
Not in the release folder, because it is replaced on every deploy. By hand, point MEDIA_ROOT at a folder outside the code and let your web server read it. With ox, list the folder under [storage] keep and it survives every release, or store uploads in an S3 bucket instead.
Can I stop using ox later?
Yes. The server is yours, your app is a plain program on it, and your database is plain PostgreSQL that you can dump at any time. Running sudo ox uninstall removes what ox added and keeps the security settings in place, so the server stays locked down after you leave.
Next step
Try the manual steps on a spare server once, so you know what each piece does. When you would rather not maintain the deploy script, the backups and the rollback yourself, follow the Django guide in the ox docs and deploy the same app with ox, free during the beta.
