Deploy to a server
How to deploy the project to a server and how to ship updates. This document is the primary source on deployment.
Live demo of the project: https://demoplast.skerter.dev.
How production is built
A single VPS (Selectel VDS, Ubuntu 24.04) running Docker Compose. The project stack
(deploy/selectel/compose.yaml):
web— Django + gunicorn (runs migrations,collectstaticand the demo seed on startup),db— PostgreSQL 16,media— nginx, serves/media/only (admin uploads).
The entry reverse proxy is a shared Traefik on :80/:443, which issues and
renews Let's Encrypt TLS certificates itself (resolver le). Traefik lives in its
own stack and connects to the project through the external docker network proxy;
CI never touches it. More on the design — Architecture.
Cloudflare sits in front of the server in proxy mode: the domain's DNS record is orange, so a visitor connects to a Cloudflare edge node, which in turn reaches the VPS. This hides the server IP and adds a CDN. The origin certificate is still issued by Traefik — Cloudflare does not replace Let's Encrypt, it works on top of it.
The image is built in GitHub Actions and pushed to GHCR
(ghcr.io/skerter/django-plastic-landing); the server only pulls the ready image —
nothing is built on the VPS (avoiding load and OOM).
Internet
│
▼
┌────────────┐
│ Cloudflare │ proxied, encryption mode Full
└────────────┘
│ :80/:443
▼
┌──────────┐ docker network "proxy" (external)
│ Traefik │◄────────┬──────────────┐
└──────────┘ ┌─────▼────┐ ┌─────▼──────┐
│ web │ │ media │
│ gunicorn │ │ nginx │
└─────┬────┘ └────────────┘
network "demoplast_internal" (private)
┌─────▼──────┐
│ db │
└────────────┘
web and media are on the proxy network (Traefik discovers them by docker labels);
db is only on the internal demoplast_internal. Static files are served by
WhiteNoise inside web; admin uploads (/media/) by the media service.
First deploy to a new server
Done once by hand: the secrets, the .env and the proxy docker network do not exist
on the server yet. Subsequent deploys are automatic (see below).
This assumes the server is already prepared: a deploy user with sudo and SSH key
access, an open firewall (22/80/443), Docker + Compose installed, and the shared
Traefik running with the external proxy network. Connecting and the basic commands —
Operations.
1. Clone the repository
sudo mkdir -p /opt/django-plastic-landing
sudo chown deploy:deploy /opt/django-plastic-landing
git clone https://github.com/Skerter/django-plastic-landing.git /opt/django-plastic-landing
cd /opt/django-plastic-landing
2. Log in to the image registry (GHCR)
echo "<GHCR_TOKEN>" | docker login ghcr.io -u <github-user> --password-stdin
GHCR_TOKEN is a Personal Access Token (classic) with the read:packages scope
(GitHub → Settings → Developer settings → Tokens (classic)).
3. Create the project .env
cp deploy/selectel/.env.example deploy/selectel/.env
nano deploy/selectel/.env
The .env sits next to compose.yaml — Compose reads it both for ${...}
interpolation and as the containers' env_file:. Fill in at least:
SECRET_KEY—python3 -c "import secrets; print(secrets.token_urlsafe(50))"POSTGRES_PASSWORD— a strong password; the same one inDATABASE_URLALLOWED_HOSTSandSITE_DOMAIN— the site domainDJANGO_SETTINGS_MODULE=config.settings.prod,DEBUG=False
The full list of variables — Environment variables. Notification channels (SMTP/Telegram) can be filled in later — Notification channels.
4. Make sure the external docker network exists
docker network create proxy # if the shared Traefik stack has not created it yet
The proxy network (external: true) is shared with Traefik. Without it the
project's up -d fails.
5. Bring the project up
docker compose -f deploy/selectel/compose.yaml pull
docker compose -f deploy/selectel/compose.yaml up -d
On startup web runs the migrations, the demo seed and collectstatic itself. For the
domain from SITE_DOMAIN, Traefik automatically requests a production Let's Encrypt
certificate — as soon as the site is up, the browser shows a valid padlock.
Production Let's Encrypt rate limit
5 certificates per week per domain. Do not recreate the Traefik certificate volume without a reason — you may hit the limit.
6. Create an administrator
docker compose -f deploy/selectel/compose.yaml exec web \
python manage.py createsuperuser
Next — fill the catalog through the admin and enable the notification channels.
Update production
Deployment is automatic: a push to main triggers
GitHub Actions,
which builds the image, pushes it to GHCR and updates the containers on the server over SSH.
git push origin main # ← triggers the build and the deploy
Progress is on the Actions tab in GitHub. CI runs git pull + docker
compose pull + up -d for the project; migrations, the demo seed and collectstatic
are performed by the web container itself on startup.
Manual deploy (if CI is unavailable):
cd /opt/django-plastic-landing
git pull
docker compose -f deploy/selectel/compose.yaml pull
docker compose -f deploy/selectel/compose.yaml up -d
Go-live: moving to the production domain
The procedure below was verified while moving the demo from a temporary nip.io address
to demoplast.skerter.dev. It works the same way for a client's production domain.
The domain is set by a single variable SITE_DOMAIN, which Compose interpolates into
the Traefik labels — compose.yaml needs no edits.
- DNS: an A record for the domain → the server IP. If the domain is on Cloudflare, set the record to DNS only (grey cloud) while the certificate is issued — see the warning below. Lower the TTL to 300 in advance.
.envon the server:SITE_DOMAIN=<domain>,ALLOWED_HOSTS=<domain>,CSRF_TRUSTED_ORIGINS=https://<domain>. For several hosts, list them comma-separated (<domain>,www.<domain>).- Apply:
docker compose -f deploy/selectel/compose.yaml up -d. Compose sees the changed labels and recreateswebandmedia, Traefik builds a router for the newHostand requests a certificate. - Check:
curl -sI https://<domain>/→HTTP/2 200. A400response withserver: gunicornmeans the container does not see the domain inALLOWED_HOSTS(a common cause is a duplicate variable in.env— the last line wins). - Turn the Cloudflare proxy back on (orange cloud) if you disabled it in step 1,
and confirm the site still returns
200. - Open up indexing in
robots_txt(apps/pages/views.py): the demo usesDisallow: /, a production site needsAllow: /.
Cloudflare: certificate first, proxy second
In Full and Full (Strict) modes Cloudflare reaches the origin over HTTPS and
expects a working certificate there. Until Traefik has issued one you get a
deadlock, and the visitor sees 526 Invalid SSL certificate. That is why the
record is switched to DNS only first, the certificate is issued directly, and only
then the proxy is enabled. The proxy does not interfere with renewals afterwards:
Cloudflare does not redirect the /.well-known/acme-challenge/ path.
Flexible mode is incompatible with the project
In Flexible mode Cloudflare reaches the origin over HTTP, while SECURE_SSL_REDIRECT
in config/settings/prod.py
sends it back to HTTPS — an infinite redirect loop.
Only Full and Full (Strict) are acceptable.