Operations
#django
#bash
#linux

How to Write a Django Deployment Bash Script

A manual Django deployment often looks simple at first: SSH into a server, pull code, install dependencies, run migrations, collect static files, restart Gunicorn, and hope the...

Problem statement

A manual Django deployment often looks simple at first: SSH into a server, pull code, install dependencies, run migrations, collect static files, restart Gunicorn, and hope the app comes back cleanly.

That process becomes error-prone quickly. Common failures include:

  • deploying unreviewed branch state instead of a pinned revision
  • running migrate against the wrong settings or environment
  • restarting the app before migrations finish
  • collecting static files into the wrong path
  • forgetting to restart Celery workers after a release
  • running two deploys at the same time and leaving the app in a partial state

A Django deployment Bash script should automate repeatable release steps and fail early when something is wrong. It should not provision servers, create databases, issue TLS certificates, or store secrets directly in the script. Those belong in separate infrastructure and secrets management processes.

Quick answer

The safest pattern for a Django deployment Bash script is:

  1. deploy a specific Git commit or tag
  2. use set -euo pipefail
  3. validate required paths and variables before doing anything
  4. activate the correct virtualenv
  5. load production settings and required environment variables
  6. run python manage.py check --deploy
  7. run python manage.py migrate --noinput
  8. run python manage.py collectstatic --noinput
  9. reload or restart Gunicorn/Uvicorn through systemd
  10. run a health check with timeout and retries
  11. keep logs and a rollback path

Bash is enough when you have one server or a small number of similar servers and a predictable release process. Move to a fuller release workflow when you need multi-server ordering, release directories, stronger rollback guarantees, or standardized deploys across multiple Django apps.

Step-by-step solution

Define the scope of your Django deployment Bash script

Choose the deployment style: in-place vs release directories

For a production-safe Bash script, use one of these patterns:

  • In-place deploy: update a checked-out repo on the server, then restart services
  • Release directories: create /srv/myapp/releases/<timestamp>, then point /srv/myapp/current at the active release

Release directories are safer for rollback. Recommended layout:

/srv/myapp/
├── current -> /srv/myapp/releases/20260424-120000
├── releases/
   ├── 20260423-101500
   └── 20260424-120000
└── shared/
    ├── media/
    ├── logs/
    └── .env

Decide which steps belong in the script

Your script should include:

  • target revision selection
  • dependency installation
  • Django checks
  • migrations
  • static file collection
  • service reload or restart
  • health verification
  • logging
  • deploy locking

Keep infrastructure provisioning, database creation, certificate issuance, and secret generation out of the deploy script.

Keep secrets and infrastructure provisioning out of the script

Do not hardcode values like SECRET_KEY, database passwords, or API tokens in Bash. Read configuration from environment files managed outside the repo, systemd unit EnvironmentFile=, or another existing secret mechanism.

If you load an env file with source, only do that for a file you control administratively. Bash will execute its contents as shell code.

Prepare the server and application layout

Create a dedicated deploy location:

sudo mkdir -p /srv/myapp/releases /srv/myapp/shared /srv/myapp/shared/logs
sudo mkdir -p /srv/myapp/shared/media

Dedicated deploy user and permissions

Use a dedicated account instead of deploying as root:

sudo adduser --system --group --home /srv/myapp deploy-myapp
sudo chown -R deploy-myapp:deploy-myapp /srv/myapp
sudo chmod -R 750 /srv/myapp

Your app service can run as a separate least-privilege user if needed, but the deploy user must have write access to release directories and read access to the shared config it needs.

Virtual environment and dependency installation path

Keep the virtualenv outside the release directory so it survives deploy switches:

python3 -m venv /srv/myapp/venv
/srv/myapp/venv/bin/pip install --upgrade pip

Write a minimal Django deployment Bash script

Save this as deploy.sh in your repo or in /srv/myapp/bin/, and make it executable.

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

APP_NAME="myapp"
APP_ROOT="/srv/myapp"
RELEASES_DIR="$APP_ROOT/releases"
CURRENT_LINK="$APP_ROOT/current"
SHARED_DIR="$APP_ROOT/shared"
VENV_PATH="$APP_ROOT/venv"
SERVICE_NAME="gunicorn-myapp"
REPO_URL="git@github.com:example/myapp.git"
SETTINGS_MODULE="myapp.settings.production"
TARGET_REVISION="${1:-}"

if [[ -z "$TARGET_REVISION" ]]; then
  echo "Usage: $0 <git-commit-or-tag>"
  exit 1
fi

for path in "$APP_ROOT" "$RELEASES_DIR" "$SHARED_DIR" "$VENV_PATH"; do
  [[ -e "$path" ]] || { echo "Missing required path: $path"; exit 1; }
done

[[ -f "$SHARED_DIR/.env" ]] || { echo "Missing env file: $SHARED_DIR/.env"; exit 1; }

exec 9>"$APP_ROOT/deploy.lock"
flock -n 9 || { echo "Another deployment is already running"; exit 1; }

TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
NEW_RELEASE="$RELEASES_DIR/$TIMESTAMP"
LOG_FILE="$SHARED_DIR/logs/deploy-$TIMESTAMP.log"
PREVIOUS_RELEASE="$(readlink -f "$CURRENT_LINK" || true)"

exec > >(tee -a "$LOG_FILE") 2>&1

rollback() {
  echo "Deployment failed"
  if [[ -n "${PREVIOUS_RELEASE:-}" && -d "${PREVIOUS_RELEASE:-}" ]]; then
    echo "Rolling back to $PREVIOUS_RELEASE"
    ln -sfn "$PREVIOUS_RELEASE" "$CURRENT_LINK"
    sudo systemctl restart "$SERVICE_NAME"
  fi
}

trap rollback ERR

echo "Starting deployment at $TIMESTAMP"
echo "Target revision: $TARGET_REVISION"

git clone "$REPO_URL" "$NEW_RELEASE"
cd "$NEW_RELEASE"
git fetch --tags origin
git checkout --detach "$TARGET_REVISION"

ln -sfn "$SHARED_DIR/media" "$NEW_RELEASE/media"

source "$VENV_PATH/bin/activate"
export DJANGO_SETTINGS_MODULE="$SETTINGS_MODULE"

set -a
source "$SHARED_DIR/.env"
set +a

: "${DJANGO_SECRET_KEY:?DJANGO_SECRET_KEY is required}"
: "${DATABASE_URL:?DATABASE_URL is required}"

pip install -r requirements.txt

python manage.py check --deploy
python manage.py migrate --noinput
python manage.py collectstatic --noinput

ln -sfn "$NEW_RELEASE" "$CURRENT_LINK"

sudo systemctl reload "$SERVICE_NAME" || sudo systemctl restart "$SERVICE_NAME"
sudo systemctl status "$SERVICE_NAME" --no-pager

curl --fail --silent --show-error --max-time 10 --retry 5 --retry-delay 2 https://example.com/health/

echo "Deployment successful: $NEW_RELEASE"
echo "Previous release was: ${PREVIOUS_RELEASE:-none}"

trap - ERR

Make it executable:

chmod 750 deploy.sh

Run it with a pinned revision:

./deploy.sh v2026.04.24

or:

./deploy.sh 8f3c2d1

Make sure STATIC_ROOT points to the intended production path. In release-directory deploys, STATIC_ROOT is often a shared path or another persistent location managed outside each individual release.

Add production safety checks to the script

Fail fast with set -euo pipefail

This avoids silent failures:

  • -e: exit on command failure
  • -u: fail on unset variables
  • pipefail: fail if any command in a pipeline fails

Confirm required environment variables exist

Validate the variables your Django settings actually require after loading .env:

: "${DJANGO_SECRET_KEY:?DJANGO_SECRET_KEY is required}"
: "${DATABASE_URL:?DATABASE_URL is required}"

If your project uses different names, replace those examples with your real variable names. The important part is to fail before migrations or service restarts if settings cannot load correctly.

Prevent accidental deploys to the wrong branch

Deploy a tag or commit hash, not “whatever is on main right now”. git checkout --detach "$TARGET_REVISION" makes the release explicit and auditable.

Use a lock file to avoid concurrent deployments

The flock pattern prevents two deploy processes from overlapping and racing on migrations, symlink switches, or service reloads.

Log output for auditing and debugging

The tee log pattern keeps a dated deploy log in /srv/myapp/shared/logs/, which helps when a health check fails after restart.

Handle migrations safely in a deployment script

Not all migrations are equally safe. Adding a nullable column is usually lower risk than dropping columns or rewriting large tables.

Run this before restart:

python manage.py check --deploy
python manage.py migrate --noinput

python manage.py check --deploy is only meaningful when your real production settings are loaded correctly. That includes at least:

  • DEBUG = False
  • ALLOWED_HOSTS
  • CSRF_TRUSTED_ORIGINS where applicable
  • secure cookie and SSL-related settings
  • proxy settings such as SECURE_PROXY_SSL_HEADER when HTTPS is terminated by Nginx, Caddy, or a load balancer

If a migration can lock large tables or requires app code and schema changes to happen in stages, do not treat it as a routine deploy step. Plan a maintenance window, pre-deploy backup or checkpoint, or use a backward-compatible migration strategy.

For deeper migration guidance, see How to Run Django Migrations Safely in Production.

Add rollback handling

If the new release fails after the symlink switch, production should not stay pointed at a bad release. That is why the example script records the previous release and uses a trap to restore it on failure.

The manual rollback path is still useful:

ln -sfn /srv/myapp/releases/20260423-101500 /srv/myapp/current
sudo systemctl reload gunicorn-myapp || sudo systemctl restart gunicorn-myapp
curl --fail --silent --show-error --max-time 10 --retry 5 --retry-delay 2 https://example.com/health/

Be careful if migrations already ran. Code rollback is usually easier than schema rollback. If the new code depends on irreversible schema changes, your rollback plan must account for that before the deploy starts.

For a fuller recovery path, see How to Roll Back a Django Deployment.

Integrate the script with systemd and your app server

For Gunicorn, prefer reload when your service unit and app behavior support it cleanly:

sudo systemctl reload gunicorn-myapp

If reload is not configured or does not apply changes reliably, use:

sudo systemctl restart gunicorn-myapp

For Uvicorn under systemd, the same pattern applies, but verify your service unit and process model first.

This script assumes the deploy user has the required sudo permissions to manage the app service. If not, configure a controlled sudoers rule or use a wrapper service account. Do not assume sudo systemctl will work on every server without that setup.

Run the script:

  • manually over SSH
  • from a CI job that SSHes into the server
  • from a controlled release runner

Do not trigger production deploys from cron unless you are solving a specific operational problem and have review controls elsewhere.

Verification after deployment

After every deploy, verify all of these:

sudo systemctl status gunicorn-myapp --no-pager
curl --fail --silent --show-error --max-time 10 --retry 5 --retry-delay 2 https://example.com/health/

If possible, also test an internal or localhost health endpoint through the actual reverse proxy path used in production. That avoids depending only on external DNS or edge routing during deployment verification.

Also check:

  • the site loads through Nginx or Caddy
  • admin login works if applicable
  • new static assets are present
  • migrations were applied to the intended database
  • HTTPS and proxy-aware settings behave correctly behind your reverse proxy
  • Celery workers were restarted if the release changed task code

If the release changes task code, include explicit worker restarts in the deploy flow so web and worker code stay in sync:

sudo systemctl restart celery-myapp
sudo systemctl restart celerybeat-myapp

Explanation

This setup works because it separates deployment from provisioning and makes each release explicit. You deploy a known revision, validate the environment, run Django’s built-in deployment checks, apply schema changes, update static assets, and only then switch traffic to the new code.

A release-directory layout gives you a cleaner rollback path than an in-place Git checkout. It also reduces the chance of partially updated code being served during deployment. In-place deploys are simpler, but release directories are usually the better production default.

The main production risks are not in the Bash syntax itself. They are in the release order:

  • loading the wrong settings
  • running migrations against the wrong database
  • switching current too early
  • failing health checks without rollback
  • forgetting dependent processes like Celery
  • assuming static and media files live inside each release

When this process becomes repetitive across multiple projects, the first things worth standardizing are the safe Bash header, environment validation block, release directory layout, systemd restart logic, health checks, and rollback section. Those are good candidates for a reusable internal script template if you manage several Django apps with the same deployment pattern.

Edge cases or notes

  • Celery: restart workers after deploy if task code changed. Keep worker and web releases aligned.
  • Docker: if you deploy containers, the Bash script should usually build or pull an image, run migrations in a controlled step, and restart containers through Compose or your orchestrator instead of activating a host virtualenv.
  • Zero downtime: a Bash script alone does not guarantee zero downtime. Migrations, process reload behavior, connection draining, and proxy configuration all affect whether requests fail during release.
  • Static and media: collectstatic manages static assets, not user-uploaded media. Media should live in a shared path or object storage, not inside release directories.
  • Shared env files: if you use source "$SHARED_DIR/.env", keep that file admin-controlled and shell-safe.
  • Destructive commands: validate paths before using commands like rm -rf. Never build delete paths from unchecked variables.
  • Multi-server deploys: coordinate migrations and app restarts carefully. On multiple app servers, deploy code in a controlled order and avoid incompatible code/schema transitions.
  • Rollback limits: a previous code release is not always safe against a new schema. Test rollback on real staging data patterns, not only on clean local databases.

FAQ

Should a Django deployment script run migrations automatically?

Yes, if migrations are part of your normal release path and you understand their risk. For large or potentially blocking schema changes, use a staged migration plan or maintenance window instead of treating them as routine.

Where should the script live: on the server or in the repo?

Usually in the repo, because it versions the release logic with the app. On-server wrapper scripts are still useful for environment-specific paths, permissions, or CI entrypoints.

Should the script restart Gunicorn or reload it?

Use reload when your Gunicorn service is configured for it and you have verified it works reliably with your app. Use restart when you need the simpler and more predictable behavior.

Is it better to deploy by branch name, tag, or commit hash?

Use a tag or commit hash in production. A branch name is mutable and can point to different code over time, which makes rollbacks and auditing harder.

How do I make a Bash deployment script safer for production?

Use set -euo pipefail, require a pinned revision, validate paths and variables, keep secrets out of the script, prevent concurrent deploys with flock, log every run, verify health after restart, include worker restarts where needed, and test rollback before you need it.

2026 · django-deployment.com - Django Deployment knowledge base