Operations
Day-to-day work with the server: connecting, updating, logs, backups, diagnostics. This document is the primary source on operations.
All docker commands are run on the server as the deploy user
from the /opt/django-plastic-landing directory.
Server facts
| Parameter | Value |
|---|---|
| Hosting | Selectel VDS, Ubuntu 24.04 |
| SSH user | deploy |
| Project directory | /opt/django-plastic-landing |
| Project Compose file | deploy/selectel/compose.yaml |
| Reverse proxy | shared Traefik (external network proxy) |
| Image | ghcr.io/skerter/django-plastic-landing:latest |
| DNS and CDN | Cloudflare (record proxied) |
| Demo | https://demoplast.skerter.dev |
Traefik is a separate stack
The entry Traefik (the :80/:443 entrypoint + TLS) lives on its own and connects
to the project through the external docker network proxy; CI never touches it.
The commands below use -f deploy/selectel/compose.yaml — see the alias.
1. Connecting over SSH
Substitute the path to your private key and the server address:
ssh -i <path-to-ssh-key> <ssh-user>@<server-IP>
cd /opt/django-plastic-landing
To leave — exit or Ctrl+D.
Windows
In PowerShell the home directory is $env:USERPROFILE (for example
"$env:USERPROFILE\.ssh\id_ed25519"). If SSH complains about the key's
permissions, restrict them to your user only.
2. Image registry (GHCR)
Needed to pull the image. Logging in through stdin keeps the token out of the shell history:
echo "<GHCR_TOKEN>" | docker login ghcr.io -u <github-user> --password-stdin
The token: GitHub → Settings → Developer settings → Tokens (classic) with the
read:packages scope. The same token is stored in the GHCR_TOKEN GitHub secret
(used by CI) — when rotating it, update both the secret and the docker login
on the server.
3. Editing .env
The project .env sits next to its example (deploy/selectel/.env.example) and is not in git:
/opt/django-plastic-landing/deploy/selectel/.env
nano deploy/selectel/.env
Saving in nano: Ctrl+O → Enter → Ctrl+X. To apply (containers read
.env only at startup):
docker compose -f deploy/selectel/compose.yaml up -d
The full list of variables — Environment variables.
What lives in the admin and what in .env
A common confusion. Tokens and SMTP go into .env; the notification recipient,
the Telegram chat_id and the Metrica ID go into the admin (SiteSettings). The
"what goes where" table is in Notification channels.
4. Containers (docker compose)
Status:
docker compose -f deploy/selectel/compose.yaml ps
Bring up / update the stack:
docker compose -f deploy/selectel/compose.yaml up -d
Restart a single service:
docker compose -f deploy/selectel/compose.yaml restart web
docker compose -f deploy/selectel/compose.yaml restart media
Recreate from scratch (if it cached an old state):
docker compose -f deploy/selectel/compose.yaml up -d --force-recreate web
Django management inside web:
docker compose -f deploy/selectel/compose.yaml exec web python manage.py <command>
# e.g.: createsuperuser, migrate, shell
5. Logs
docker compose -f deploy/selectel/compose.yaml logs web --tail 40
docker compose -f deploy/selectel/compose.yaml logs -f web # follow in real time
docker compose -f deploy/selectel/compose.yaml logs media --tail 40
Greps for diagnostics:
docker compose -f deploy/selectel/compose.yaml logs web | grep -i error
Traefik logs (certificates, routes, ACME) are inspected in its own stack.
6. Database
Back up to a file:
docker compose -f deploy/selectel/compose.yaml exec db \
pg_dump -U demoplast demoplast > backup_$(date +%F).sql
Restore from a backup:
cat backup_2026-06-19.sql | docker compose -f deploy/selectel/compose.yaml exec -T db \
psql -U demoplast demoplast
Open psql:
docker compose -f deploy/selectel/compose.yaml exec db psql -U demoplast demoplast
\q)
7. HTTPS / certificate
TLS is entirely on Traefik (Let's Encrypt, HTTP-01) — no certbot. The production
certificate is requested automatically on first start for the domain from
SITE_DOMAIN (resolver le); Traefik renews it on its own afterwards.
Cloudflare sits on top of this: the browser terminates TLS at its edge node, and Cloudflare opens a separate TLS connection to the origin, where the Traefik certificate meets it. To check what is actually on the origin, bypass Cloudflare — go by IP and pass the host name through SNI:
echo | openssl s_client -connect <server-IP>:443 -servername demoplast.skerter.dev 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates
An issuer of Let's Encrypt means everything is fine; CN=TRAEFIK DEFAULT CERT means
Traefik has no router for that Host and is serving a self-signed placeholder.
Production Let's Encrypt rate limit
5 certificates per week per domain. Do not delete the Traefik certificate volume without a reason — reissuing a certificate consumes the limit.
TODO: switch Cloudflare to Full (Strict)
The zone currently runs in Full mode: Cloudflare → origin traffic is encrypted,
but the authenticity of the origin certificate is not verified, so a theoretical MITM
on that leg remains possible. demoplast.skerter.dev already has a valid
Let's Encrypt certificate, so Full (Strict) can be enabled for it without any
extra preparation.
The switch is postponed because the encryption mode is set for the whole zone
skerter.dev and would affect neighbouring hosts. The careful path is to leave the
zone alone and add a Configuration Rule with the condition
Hostname equals demoplast.skerter.dev and the setting SSL → Full (Strict).
Before enabling it, check the certificates of the other proxied hosts with the
openssl command above: any host serving TRAEFIK DEFAULT CERT will start
returning 526 under Strict.
8. Deploying code
Automatic — a push to main, GitHub Actions builds the image and updates the server.
More detail: Deploy → update production.
9. Diagnosing "the site does not open"
- Container status — are they all
Up:docker compose -f deploy/selectel/compose.yaml ps - 502 / Bad Gateway → Traefik could not reach
web. Check thatwebisUpand on theproxynetwork; inspect the Traefik logs. webkeeps restarting → read its log (§5), look for a traceback. Usually the cause is an error in.env(see Environment variables).- The certificate is not issued → check that
SITE_DOMAINresolves to the server IP and that :80 is open (HTTP-01 goes over :80); inspect the Traefik ACME logs. If the domain is proxied by Cloudflare — temporarily switch the record to DNS only, issue the certificate directly and turn the proxy back on (Go-live). - 400 Bad Request with
server: gunicorn→ Django rejects theHost. The domain is missing fromALLOWED_HOSTS: compare.envwith what the container actually sees (docker compose -f deploy/selectel/compose.yaml exec web env | grep ALLOWED_HOSTS) — with a duplicate variable in.envthe last line wins. - 526 / 525 from Cloudflare → the origin has no certificate, or it is invalid for the selected encryption mode (see §7).
/media/is not served (404) → check themediaservice and that themedia_volumevolume is mounted.- The site does not load at all → external ports + firewall (
sudo ufw status), Traefik holding :80/:443 (sudo ss -tlnp | grep -E ':80|:443').
A handy alias
To avoid typing -f deploy/selectel/compose.yaml every time:
echo "alias dc='docker compose -f /opt/django-plastic-landing/deploy/selectel/compose.yaml'" >> ~/.bashrc
source ~/.bashrc
After that: dc ps, dc logs web --tail 40, dc restart web.