A complete playbook for moving a running Django site to a new server: inventory, runtime parity, database and media transfer, certificates before DNS, the delta-sync window, the IPv6 trap that breaks TLS issuance, and how to keep a working fallback.
Moving a running Django application to a new server is one of those tasks that looks like a file copy and turns out to be a small project. The code is the easy part. What bites people is everything that surrounds the code: the exact Python version, the system libraries a package was compiled against, the cron entries nobody documented, the media directory that lives outside the repository, and the certificate that refuses to issue at the worst possible moment.
This guide is a migration playbook. It assumes you have a Django application in production on
one virtual private server and you want it running on another, with minimal downtime and no
data loss. Every command uses neutral placeholders — replace example.com and
/srv/django-app with your own.
Migrations rarely fail because someone forgot to copy the source code. They fail because a production server accumulates state that never made it into version control. A package installed by hand eighteen months ago to fix a PDF rendering bug. A cron job added during an incident. An environment variable set directly in a systemd unit rather than in the environment file. A directory of user uploads that has been growing quietly since launch.
The remedy is not heroism on cutover night. It is an inventory taken calmly beforehand, and a sequence that lets you verify the new server thoroughly while the old one is still serving traffic. If you can test the destination before anyone is pointed at it, a migration stops being a leap and becomes a switch you flip.
Before touching the new machine, write down what actually constitutes your application. Not what you think it is — what the server says it is. Six categories cover almost everything:
Walk each one on the live machine and write the answer down. The exercise takes twenty minutes and prevents the three-hour debugging session that otherwise happens at midnight.
A requirements.txt is a statement of intent. What is actually installed can differ:
a package pinned loosely may have resolved to a newer version months ago, and something may have
been installed directly with pip and never written down.
ssh old-server "cd /srv/django-app && venv/bin/pip freeze" > freeze-live.txt
diff <(sort requirements.txt) <(sort freeze-live.txt) | head -40
Build the new environment from the freeze output, not from the requirements file. You can reconcile the two afterwards at your leisure. On cutover day you want the new server to behave exactly like the old one, including the versions you did not intend to be running.
Python packages that render PDFs, process images, talk to databases or handle cryptography are thin wrappers around C libraries. Install the Python package on a fresh server without those libraries and the import succeeds until the moment a user asks for an invoice.
sudo apt-get install -y build-essential libpq-dev libjpeg-dev zlib1g-dev \
libffi-dev libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
The reliable way to find what you need is to import every third-party package in a shell on the
new server before you migrate anything else. An ImportError or an
OSError: cannot load library at that point costs you a minute. The same error
discovered by a customer costs considerably more.
Bring the new machine to a known state before any application code touches it: operating system updates applied, a firewall with a default-deny policy allowing only what you need, automatic security updates enabled, key-only SSH access, and an intrusion-prevention daemon watching the SSH log.
Resist the temptation to combine hardening with deployment. They are different activities with different failure modes, and mixing them means that when something breaks you have two suspects instead of one. Harden, verify, then deploy.
For PostgreSQL, the custom format is the right default. It compresses, it supports selective restores, and it lets you drop and recreate objects cleanly.
# On the old server
pg_dump -U appuser -Fc appdb > appdb.dump
# Verify before you trust it
head -c 5 appdb.dump # should print PGDMP
# On the new server
pg_restore -U appuser -d appdb --clean --if-exists --no-owner appdb.dump
The --no-owner flag matters when the database roles differ between machines.
The --clean --if-exists pair makes the restore repeatable, which you will appreciate
when you run it a second time during the final delta sync.
A restore that prints no errors has still only proven that the file parsed. Check that the data arrived: count the tables, count the rows in the two or three tables that matter most, and confirm that the migration state matches.
python manage.py showmigrations --plan | grep -c "^\[ \]" # expect 0
python manage.py shell -c "from django.contrib.auth import get_user_model; \
print(get_user_model().objects.count())"
Two numbers that match the old server are worth more than a hundred lines of successful output.
Django's MEDIA_ROOT holds everything your users have uploaded. It is not in git, it
is not in the database, and it is routinely forgotten because on a working server it is simply
there. If your media lives on object storage rather than local disk, note that the bucket is a
separate product from the server — migrating the machine does not migrate the bucket, and
cancelling the old provider account can take the storage with it.
rsync -az --info=progress2 old-server:/srv/django-app/media/ /srv/django-app/media/
find /srv/django-app/media -type f | wc -l # compare both sides
Environment files, systemd units and web server configuration are as much a part of the running system as the code. Copy them deliberately and read them afterwards. Environment files in particular tend to contain settings that were changed during an incident and never propagated back to the repository.
Handle them as secrets: transfer over an encrypted channel, restrict permissions to the account that needs them, and never place them under a directory the web server can serve. A key inside a publicly served path is a key you must consider compromised.
Two categories of work run outside the request cycle: scheduled jobs and queue workers. Both are invisible when you test a page in a browser, and both are noticed days later when a report does not arrive or a queued email never sends.
crontab -l > cron-backup.txt
systemctl list-units --type=service --state=running | grep -i -E "worker|beat|queue"
systemctl list-timers --all
Copy the definitions, but keep the schedulers disabled on the new server until after cutover. Two machines running the same nightly job against the same database is a way to send every customer two invoices.
A TLS certificate proves you control a domain, and the usual proof is that the certificate authority can reach a file on the server the domain points at. Before cutover, the domain still points at the old server — so issuance on the new one will fail unless you use a challenge that does not depend on the address record.
If your DNS provider offers an API, the DNS-01 challenge lets you obtain a certificate for the new server while the domain still resolves elsewhere. If it does not, accept that certificate issuance happens immediately after the DNS switch, and plan for a short window in which the new server answers on HTTP but not yet on HTTPS.
This is the step that turns a migration from a gamble into a verification. You can exercise the new server exactly as a visitor would, while DNS still sends real traffic to the old one, by telling your client which address to use.
curl -sk --resolve example.com:443:203.0.113.10 https://example.com/ -o /dev/null -w "%{http_code}\n"
curl -sk --resolve example.com:443:203.0.113.10 https://example.com/admin/ -o /dev/null -w "%{http_code}\n"
Walk the real paths: the homepage, a detail page, the admin login, a static asset, an uploaded image, a form submission. Anything that returns the wrong status now is a problem you get to fix calmly.
Lower the time-to-live on the relevant records well before the switch — a day ahead if you can. TTL governs how long resolvers cache the old answer, and lowering it at the moment of cutover is too late, because the old, longer TTL is already cached everywhere.
When you switch, change every record that resolves the hostname, and change them together.
Here is a failure that catches experienced engineers. You update the A record to the new server and leave the AAAA record pointing at the old one, perhaps because you forgot IPv6 existed or because you intended to do it later.
Most clients will happily use IPv4. Certificate authorities, however, tend to prefer IPv6 when it is available. The validation request goes to the address in the AAAA record — the old server — which knows nothing about the challenge file, returns a 404, and issuance fails with an error that says nothing about IPv6 at all.
Change A and AAAA in the same operation, or remove the AAAA record before cutting over and add it back afterwards. Verify with an explicit query rather than trusting the control panel.
dig +short A example.com @1.1.1.1
dig +short AAAA example.com @1.1.1.1
Between your database dump and the moment traffic moves, the old server keeps accepting writes. Those writes exist only there. For a low-traffic site the window is negligible; for a busy one it is not.
The pragmatic approach for most applications: take a fresh dump and restore it immediately before the DNS change, then accept that a small number of writes may land on the old server during propagation. If your application cannot tolerate that, put the old site into a read-only or maintenance mode for the duration — a brief, honest maintenance page is better than silently losing an order.
After propagation, verify from outside your own network. Your browser may hold a cached DNS answer, and your own machine is the least representative client you have.
curl -s -o /dev/null -w "%{http_code} ssl=%{ssl_verify_result} ip=%{remote_ip}\n" https://example.com/
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
| openssl x509 -noout -issuer -dates
Check the status code, that certificate verification returns zero, and that the address really is the new server. Then log in, submit a form, and — this is the one everybody forgets — trigger an email and confirm it arrives. Outbound mail is frequently blocked by default on fresh servers, and a site that renders perfectly while sending nothing looks entirely healthy from the outside.
Do not decommission the old machine on cutover day. For as long as it runs, your rollback plan is a DNS change rather than a restore from backup. That is the difference between a five-minute recovery and an afternoon.
Leave it running, untouched, for at least a few days of normal traffic. If nothing has surfaced by then, nothing will.
When you are ready, take a final backup of the old machine and store it somewhere that is not either server. Then stop the services, confirm that nothing breaks, and only afterwards remove the data. Stopping first gives you a reversible step; deleting first does not.
Finally, cancel the resources you are no longer using. Idle servers keep billing, and an unused machine that still holds a copy of your production database is a liability rather than an asset.
A server migration is a sequence of small, verifiable steps rather than one large leap. Inventory what the running system actually is. Reproduce the runtime rather than the intent. Move the database in a format you can restore twice, and the media that lives outside both the repository and the database. Prepare and harden the destination before deploying to it. Test with an explicit address override while the old server still serves real users. Switch every DNS record together, IPv6 included. Verify from outside, including a real email. Keep the old machine alive as your rollback, and take it apart only when it has been boring for several days.