Django's default manage.py runserver screams at you when you try to run it in production, and for good reason — it's single-threaded, has no request queueing, and dies on the first unhandled exception. You need Gunicorn or uWSGI, static files served from a CDN or object storage, and database migrations that run before the new code starts accepting traffic. Most platforms make you script this yourself; PandaStack lets you customize the build command to run migrations and collectstatic before the container goes live.
This guide deploys a Django app with a managed PostgreSQL database, shows how to run migrate and collectstatic in the build step so they complete before the app starts, and explains why binding to 0.0.0.0 instead of 127.0.0.1 is critical for container health checks.
The Django app
Create a new Django project:
pip install django gunicorn psycopg2-binary whitenoise
django-admin startproject myapp
cd myappUpdate myapp/settings.py to read the database URL from the environment:
import os
import dj_database_url
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-key-change-in-production')
DEBUG = os.environ.get('DEBUG', 'False') == 'True'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
DATABASES = {
'default': dj_database_url.config(
default='sqlite:///db.sqlite3',
conn_max_age=600
)
}
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', # Serve static files
# ... rest of middleware
]
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'Install dj-database-url for parsing DATABASE_URL:
pip install dj-database-urlFreeze dependencies:
pip freeze > requirements.txtCreate a simple view to verify it works. In myapp/urls.py:
from django.contrib import admin
from django.urls import path
from django.http import JsonResponse
def health(request):
return JsonResponse({'status': 'ok'})
def index(request):
return JsonResponse({'message': 'Django app is running'})
urlpatterns = [
path('admin/', admin.site.urls),
path('health/', health),
path('', index),
]Test locally:
python manage.py migrate
python manage.py collectstatic --noinput
gunicorn myapp.wsgi:application --bind 0.0.0.0:8000Visit http://localhost:8000/health. You should see {"status":"ok"}. The app works. Now make it deployable.
Custom build command for migrations
Most Django deploys follow this sequence:
- 1Install dependencies (
pip install -r requirements.txt) - 2Collect static files (
python manage.py collectstatic --noinput) - 3Run database migrations (
python manage.py migrate --noinput) - 4Start the WSGI server (
gunicorn myapp.wsgi:application)
Steps 2 and 3 must happen before step 4, but they're not part of the start command. PandaStack's buildCommand field runs between dependency installation and starting the app. Create pandastack.json:
{
"type": "container",
"name": "django-postgres-app",
"language": "python",
"buildCommand": "python manage.py collectstatic --noinput && python manage.py migrate --noinput",
"startCommand": "gunicorn myapp.wsgi:application --bind 0.0.0.0:8080 --workers 3",
"healthCheckPath": "/health",
"env": [
{
"key": "SECRET_KEY",
"description": "Django secret key. Generate with: python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'"
},
{
"key": "DEBUG",
"value": "False"
},
{
"key": "ALLOWED_HOSTS",
"value": "*"
}
]
}The buildCommand runs collectstatic and migrate in sequence (the && ensures the second command only runs if the first succeeds). The startCommand binds Gunicorn to 0.0.0.0:8080 — the host must be 0.0.0.0 (not 127.0.0.1) so the health check probe can reach it from outside the container.
The SECRET_KEY is prompted at deploy time (the description includes the command to generate one). DEBUG and ALLOWED_HOSTS are preset.
Provision a PostgreSQL database
Go to the PandaStack dashboard, navigate to Databases, and click New Database. Choose:
- Engine: PostgreSQL 16
- Plan: Free
- Name:
django-db
PandaStack provisions the database and shows connection details. Do not copy them manually — linking the database to the app injects DATABASE_URL automatically.
Deploy the Django app and link the database
Push to GitHub:
git init
git add .
git commit -m "Add Django app with Postgres config"
git remote add origin https://github.com/yourname/django-postgres-app.git
git push -u origin mainDeploy via the dashboard:
# Go to https://dashboard.pandastack.io/deploy?repo=yourname/django-postgres-appThe deploy screen reads pandastack.json and prompts for SECRET_KEY. Generate one:
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"Paste it into the form and click Deploy. The build runs:
Installing dependencies from requirements.txt...
Running build command: python manage.py collectstatic --noinput && python manage.py migrate --noinput
Collecting static files...
125 static files copied to '/app/staticfiles'.
Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
...
Starting container with: gunicorn myapp.wsgi:application --bind 0.0.0.0:8080 --workers 3The container starts, but the app crashes with a database connection error because DATABASE_URL is unset. In the project's Settings tab, find the Databases section and click Link Database. Select django-db.
PandaStack injects DATABASE_URL and triggers a redeploy. The build runs again, migrations execute against the Postgres database, and the app goes live. Test it:
curl https://django-postgres-app-abc123.pandastack.io/health
# {"status":"ok"}
curl https://django-postgres-app-abc123.pandastack.io/
# {"message":"Django app is running"}The app is live with a managed Postgres backend.
Create a Django model and test migrations
Add a model to verify database writes work. Create polls/models.py:
python manage.py startapp pollsIn polls/models.py:
from django.db import models
class Question(models.Model):
question_text = models.CharField(max_length=200)
pub_date = models.DateTimeField('date published')
def __str__(self):
return self.question_textAdd polls to INSTALLED_APPS in settings.py:
INSTALLED_APPS = [
'polls',
'django.contrib.admin',
# ...
]Make migrations:
python manage.py makemigrationsThis creates polls/migrations/0001_initial.py. Commit and push:
git add .
git commit -m "Add Question model"
git pushThe auto-deploy triggers. The build log shows:
Running build command: python manage.py collectstatic --noinput && python manage.py migrate --noinput
...
Applying polls.0001_initial... OKThe migration ran before the new container started. The old container (without the Question model) is still serving traffic until the new one passes health checks. This is a zero-downtime deploy: migrations run, new code starts, old code shuts down only after the new version is healthy.
Why bind to 0.0.0.0 instead of 127.0.0.1
If you change the startCommand to:
"startCommand": "gunicorn myapp.wsgi:application --bind 127.0.0.1:8080"The app starts, the logs show Gunicorn is running, but the health check fails and the deployment times out. This is because 127.0.0.1 (localhost) is only accessible from inside the container. PandaStack's health check probes come from the Kubernetes cluster network (outside the container), and they can't reach 127.0.0.1.
Binding to 0.0.0.0 listens on all network interfaces, including the container's external interface. The health check hits http://pod-ip:8080/health, gets a 200 response, and the deployment succeeds.
Deploy via the API with database linking
For CI, you can create the project and link the database in one API call:
curl -X POST https://api.pandastack.io/v1/projects \
-H "Authorization: Bearer psk_live_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"slug": "container",
"name": "django-postgres-app",
"repositoryName": "yourname/django-postgres-app",
"branch": "main",
"databaseId": "db-abc123",
"buildCommand": "python manage.py collectstatic --noinput && python manage.py migrate --noinput",
"startCommand": "gunicorn myapp.wsgi:application --bind 0.0.0.0:8080 --workers 3",
"env": [
{ "name": "SECRET_KEY", "value": "prod-secret-from-ci" },
{ "name": "DEBUG", "value": "False" }
],
"autoDeploy": true
}'Get the databaseId from the dashboard or via:
curl -H "Authorization: Bearer psk_live_your_token_here" \
https://api.pandastack.io/v1/databasesThe project is created with the database linked, so DATABASE_URL is set from the start.
Rolling back a bad migration
If a migration breaks the app, you can roll back via the dashboard. Go to the project's Deployments tab, find the last working deployment, and click Rollback. The old code and database schema are restored.
For more control, SSH into the container (via the Console tab in the dashboard) and run:
python manage.py migrate polls 0001 # Roll back to migration 0001Then redeploy with the migration removed from the codebase.
Static files and WhiteNoise
The buildCommand runs collectstatic, which copies static files to staticfiles/. WhiteNoise (configured in settings.py) serves them directly from Gunicorn without needing Nginx or a separate static file server. This works for small-to-medium apps.
For high-traffic sites, serve static files from PandaStack's CDN instead. Upload the staticfiles/ directory to object storage (S3, Google Cloud Storage) and configure Django to use a custom STATIC_URL:
STATIC_URL = 'https://cdn.yourapp.com/static/'The buildCommand uploads files to the CDN after collectstatic, and the app serves only dynamic content.
What breaks and how to fix it
Migrations fail with "database does not exist": The DATABASE_URL is malformed or points to a database that wasn't created. Check that the database is linked in the project settings, and verify DATABASE_URL is set via panda projects env.
Static files 404 in production: collectstatic didn't run, or STATIC_ROOT is wrong. Check the build log to verify collectstatic executed. Ensure STATIC_ROOT points to a directory inside the container (e.g., /app/staticfiles).
App starts but crashes on first request: The migration introduced a breaking change (renamed column, dropped table). Django's ORM queries fail because the code expects the new schema but the old code is still running. Avoid destructive migrations (drop column, rename table) in the same deploy as code changes — split them into two deploys.
Gunicorn workers timeout: The --workers count is too high for the available memory. Free-tier containers have limited RAM; start with --workers 2 and increase based on memory usage (check the Metrics tab in the dashboard).
Next steps
You've deployed a Django app with a managed PostgreSQL database, custom build commands that run migrations before the app starts, and Gunicorn configured for production. The same pattern works for Flask (with Alembic migrations) or FastAPI (with SQLAlchemy).
For production Django apps, consider:
- Enabling database connection pooling (via
conn_max_ageinDATABASES) - Configuring Celery for background tasks (deployed as a separate worker container)
- Adding a custom domain and SSL (automatic via the dashboard)
- Using the KV store (managed Redis) for Django's cache backend
PandaStack handles the infrastructure; you write the views.
References
- [Django deployment checklist](https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/)
- [Gunicorn deployment guide](https://docs.gunicorn.org/en/stable/deploy.html)
- [WhiteNoise documentation](http://whitenoise.evans.io/)
- [PandaStack database guide](https://docs.pandastack.io/databases)