BlogGuides

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

  • 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.lock or requirements.txt, and gunicorn (or uvicorn for ASGI) among them.

The pieces of a Django deploy without Docker

PieceJob
gunicornRuns your Django app (WSGI) on a local port, with a few worker processes.
systemdStarts gunicorn at boot, restarts it if it crashes, and keeps its logs in the journal.
CaddyAnswers on ports 80 and 443, gets and renews the HTTPS certificate, serves static files, and passes the rest to gunicorn.
PostgreSQLThe 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.

Shell
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.

Shell
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

Shell
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:

Shell
# /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:

Python
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

Shell
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

INI
# /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:

Shell
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:

Code
# /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:

Shell
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 pull and systemctl restart drops 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

  1. Sign in with GitHub at deploywithox.com.

  2. 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.

  3. Pick the repository. ox reads it and shows the plan before anything runs.

  4. 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-dev with uv.lock, or a virtual environment and requirements.txt.

  • The start: gunicorn on your project's wsgi, or uvicorn on asgi when uvicorn is a dependency and asgi.py exists.

  • The migration: manage.py migrate --noinput, run before visitors move to the new release.

  • collectstatic --noinput, when settings.py sets STATIC_ROOT.

  • PostgreSQL, when psycopg or psycopg2 is a dependency and the repo has no ox.toml yet.

A small ox.toml

To set the domain and let Caddy serve your collected static files, add ox.toml at the repository root:

TOML
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:

TOML
[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

TaskBy handWith ox
HTTPS certificateCaddy, once you write the CaddyfileCaddy, set up for you
Restart on crash and at bootYour systemd unitA systemd unit ox writes
Deploy on git pushA GitHub Action or webhook script you maintainBuilt in
Updates without dropped requestsA second copy of the app and a switch you buildBuilt in, after a health check
Daily database backupsA cron job with pg_dump and an off-site copyBuilt in, with optional S3
RollbackKeep old releases and switch back yourselfOne 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 --deploy before 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.

Keep reading

Deploy to a server you own

ox sets up systemd, Caddy and PostgreSQL on your Ubuntu server and deploys on every push. Free during the beta.

Sign up with GitHub Read the docs