DeployxaDocs Portal
CLI v1.0.4
Tutorial: Django 5 + PostgreSQL REST API
Tutorial · Django

Django 5 + PostgreSQL REST API, end to end

Build a notes API with Django 5 and Django REST Framework, use Django's built-in PostgreSQL connection pool, serve static files with WhiteNoise, and deploy it to Deployxa behind Gunicorn with migrations on every release.

What you will build

  • A Note model with a CRUD API at /api/notes/, plus the browsable API and the Django admin.
  • Settings driven entirely by environment variables, so the same code runs locally and on Deployxa.
  • Connection pooling with psycopg 3 (Django 5.1+), sized for Gunicorn workers.

You need Python 3.12, a Deployxa account and optionally the CLI (npm install -g @deployxa/cli).

1. Create the project

shell
$ mkdir notes-api && cd notes-api
$ python -m venv .venv && source .venv/bin/activate
$ pip install "django>=5.1,<6" djangorestframework gunicorn whitenoise dj-database-url "psycopg[binary,pool]"
$ django-admin startproject config .
$ python manage.py startapp notes
$ pip freeze > requirements.txt

The trailing . puts manage.py at the repository root, which is where Deployxa looks for it. The project package is config, so the WSGI module is config.wsgi.

2. The model

python
# notes/models.py
from django.db import models


class Note(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField(blank=True)
    pinned = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-pinned", "-updated_at"]

    def __str__(self):
        return self.title
python
# notes/admin.py
from django.contrib import admin
from .models import Note

admin.site.register(Note)

3. Serializer, viewset and URLs

python
# notes/serializers.py
from rest_framework import serializers
from .models import Note


class NoteSerializer(serializers.ModelSerializer):
    class Meta:
        model = Note
        fields = ["id", "title", "body", "pinned", "created_at", "updated_at"]
        read_only_fields = ["created_at", "updated_at"]
python
# notes/views.py
from django.db import connection
from django.http import JsonResponse
from rest_framework import viewsets
from .models import Note
from .serializers import NoteSerializer


class NoteViewSet(viewsets.ModelViewSet):
    queryset = Note.objects.all()
    serializer_class = NoteSerializer


def health(request):
    with connection.cursor() as cur:   # proves the database is reachable
        cur.execute("SELECT 1")
    return JsonResponse({"ok": True})
python
# config/urls.py
from django.contrib import admin
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from notes.views import NoteViewSet, health

router = DefaultRouter()
router.register("notes", NoteViewSet)

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include(router.urls)),
    path("health/", health),
]

4. Production-ready settings

Replace the matching parts of config/settings.py:

python
# config/settings.py
import os
from pathlib import Path
import dj_database_url

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.getenv("SECRET_KEY", "dev-only-not-secret")
DEBUG = os.getenv("DEBUG", "0") == "1"
ALLOWED_HOSTS = [h for h in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if h]
CSRF_TRUSTED_ORIGINS = [o for o in os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",") if o]

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = not DEBUG

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",
    "notes",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

# DATABASE_URL is injected by Deployxa; without it, fall back to SQLite for local work
DATABASES = {
    "default": dj_database_url.config(default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}")
}
if DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql":
    # Django 5.1+ connection pool (psycopg 3). CONN_MAX_AGE must stay 0 with a pool.
    DATABASES["default"]["OPTIONS"] = {"pool": {"min_size": 1, "max_size": 4}}

STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static"
STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticatedOrReadOnly"],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}
Note: Each Gunicorn worker process keeps its own pool. With 3 workers and max_size 4, the app can open up to 12 connections, so keep workers × max_size below your database plan's connection limit.

5. Run it locally

shell
$ python manage.py makemigrations notes
$ python manage.py migrate
$ python manage.py createsuperuser
$ python manage.py runserver
# http://127.0.0.1:8000/api/notes/  (log in at /admin/ to create notes)

Commit the generated notes/migrations/0001_initial.py; production only applies migrations, it never creates them. Add a .gitignore with .venv/, db.sqlite3, static/ and __pycache__/.

6. Deploy to Deployxa

  1. 1
    Import

    Push to GitHub, open /dashboard/projects/new and pick the repository.

  2. 2
    Configure

    Choose the Django preset, set Database to PostgreSQL, and change the build command so static files are collected:

    shell
    pip install -r requirements.txt && python manage.py collectstatic --noinput
  3. 3
    Environment

    Add these variables (replace the host with your project's Deployxa URL):

    env
    SECRET_KEY=<output of get_random_secret_key()>
    ALLOWED_HOSTS=notes-api.deployxa.online
    CSRF_TRUSTED_ORIGINS=https://notes-api.deployxa.online
    DJANGO_SUPERUSER_USERNAME=admin
    DJANGO_SUPERUSER_EMAIL=you@example.com
    DJANGO_SUPERUSER_PASSWORD=<a strong password>
  4. 4
    Start command and port

    After the first deploy, open the Environment Canvas, select the web service, set Internal Port to 8000 and the start command to:

    shell
    sh -c 'python manage.py migrate --noinput && (python manage.py createsuperuser --noinput || true) && gunicorn config.wsgi --bind 0.0.0.0:8000 --workers 3 --access-logfile -'

    createsuperuser --noinput reads the DJANGO_SUPERUSER_* variables; || true ignores the "already exists" error on later deploys. Once the admin account exists you can remove that part and the password variable.

Or from the CLI, after creating the project and attaching PostgreSQL in the dashboard:

shell
$ rm -rf .venv static db.sqlite3      # deploy uploads the folder as-is
$ deployxa link
$ deployxa env set SECRET_KEY "$(python -c 'from django.core.management.utils import get_random_secret_key as g; print(g())')"
$ deployxa env set ALLOWED_HOSTS notes-api.deployxa.online
$ deployxa deploy
$ deployxa logs --live

7. Try the API

shell
$ curl https://notes-api.deployxa.online/health/
{"ok": true}

$ curl -u admin:<password> -X POST https://notes-api.deployxa.online/api/notes/ \
    -H "Content-Type: application/json" \
    -d '{"title": "Ship it", "body": "Deployed on Deployxa", "pinned": true}'

$ curl https://notes-api.deployxa.online/api/notes/

The admin at /admin/ is styled, which confirms WhiteNoise is serving the collected static files. Basic auth works with the browsable API's defaults; for real clients add token or JWT authentication.

8. Custom domain

Connect api.example.com in Dashboard → Domains (or deployxa domains add api.example.com), create the CNAME record shown, then add the domain to both variables and redeploy:

shell
$ deployxa env set ALLOWED_HOSTS "notes-api.deployxa.online,api.example.com"
$ deployxa env set CSRF_TRUSTED_ORIGINS "https://notes-api.deployxa.online,https://api.example.com"
$ deployxa redeploy

Troubleshooting

SymptomFix
400 Bad RequestThe host is missing from ALLOWED_HOSTS (no scheme, comma-separated).
CSRF verification failed in the adminAdd the https origin to CSRF_TRUSTED_ORIGINS; keep SECURE_PROXY_SSL_HEADER.
ImproperlyConfigured: Pooling doesn't support persistent connectionsDo not pass conn_max_age to dj_database_url when pool is set.
Error loading psycopg_pool moduleInstall psycopg[binary,pool] and regenerate requirements.txt.
FATAL: too many connectionsLower --workers or the pool's max_size.
relation "notes_note" does not existCommit the migration files and check the log for the migrate step.
Admin without CSSMake sure collectstatic is in the build command and WhiteNoise middleware is present.

More settings and errors are in Deploy Django.