Skip to content

Uplink 2.0 Server Setup Log

Server: Uplink2 ()
Date: February 20, 2026
Objective: Set up fresh Ubuntu 24.04 LTS server for Uplink 2.0 deployment


1. Initial Server Access & User Setup

SSH into new server as root

ssh root@<server-ip>

Created non-root user

adduser uplink
usermod -aG sudo uplink
exit
ssh uplink@<server-ip>
# Or from root:
sudo -u uplink -i

Status: ✓ Complete


2. System Update & Essential Tools

Update and upgrade system

sudo apt-get update && sudo apt-get upgrade -y

Install essential tools

sudo apt-get install -y git curl wget vim htop net-tools unzip build-essential

Status: ✓ Complete


3. System Configuration

Set timezone

sudo timedatectl set-timezone Europe/London

Status: ✓ Complete


4. Firewall Configuration

Configure UFW firewall

sudo ufw allow 22/tcp    # SSH
sudo ufw allow 80/tcp    # HTTP
sudo ufw allow 443/tcp   # HTTPS
sudo ufw --force enable

Output:

Rules updated
Rules updated (v6)
Firewall is active and enabled on system startup

Status: ✓ Complete
Note: Will need to be reviewed/tweaked as deployment progresses


5. Docker Installation

Install Docker and Docker Compose

Install Docker GPG key

sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

Add Docker repository

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Install Docker Engine

sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Verify installation

sudo docker --version
# Output: Docker version 29.2.1, build a5c7197

sudo docker compose version
# Output: Docker Compose version v5.0.2

Add user to docker group

sudo usermod -aG docker $USER
newgrp docker

Test Docker

docker run hello-world

Output:

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

Enable Docker on boot

sudo systemctl enable docker

Output:

Synchronizing state of docker.service with SysV service script with /usr/lib/systemd/systemd-sysv-install.
Executing: /usr/lib/systemd/systemd-sysv-install enable docker

Status: ✓ Complete


6. Repository Setup

Generate SSH key

ssh-keygen -t ed25519 -C "your-email@example.com"
# Press Enter to accept default location
# Press Enter twice to skip passphrase (or set one)

Display and add public key to GitHub

cat ~/.ssh/id_ed25519.pub

Steps: 1. Copy the output 2. Go to https://github.com/settings/keys 3. Click "New SSH key" 4. Paste the public key 5. Give it a title: "Uplink Production Server" 6. Click "Add SSH key"

Create application directory and clone repository

sudo mkdir -p /srv/uplink
sudo chown uplink:uplink /srv/uplink
cd /srv/uplink
git clone git@github.com:SensationalSystems/uplink.git app

Output:

Cloning into 'app'...
Enter passphrase for key '/home/uplink/.ssh/id_ed25519': 
remote: Enumerating objects: 9565, done.
remote: Counting objects: 100% (586/586), done.
remote: Compressing objects: 100% (165/165), done.
remote: Total 9565 (delta 459), reused 424 (delta 421), pack-reused 8979 (from 4)
Receiving objects: 100% (9565/9565), 5.17 MiB | 8.30 MiB/s, done.
Resolving deltas: 100% (6677/6677), done.

Verify repository contents

cd /srv/uplink/app
ls

Output:

Pipfile       README.md  devices      logs           media       services   uplink
Pipfile.lock  catalogue  docs         manage.py      orders      stock      yarn.lock
Procfile      contacts   forecasting  march-todo.md  prestashop  templates

Status: ✓ Complete


uplink@Uplink2:~/app$ uplink@Uplink2:/srv/uplink/app$ cd /srv/uplink/app cd ~ uplink@Uplink2:~$ ln -s /srv/uplink/app ~/app uplink@Uplink2:~$ echo "cd ~/app" >> ~/.bashrc uplink@Uplink2:~$ ls -la ~/app lrwxrwxrwx 1 uplink uplink 15 Feb 20 13:02 /home/uplink/app -> /srv/uplink/app uplink@Uplink2:~$ exit logout root@Uplink2:~# sudo -u uplink -i uplink@Uplink2:~/app$ pwd /home/uplink/app uplink@Uplink2:~/app$

8. Environment Configuration (.env file)

Created .env file on Uplink2 server

cd /srv/uplink/app
nano .env

Key Configuration Details

Database Settings: - Using existing DigitalOcean MySQL cluster (NOT Docker MySQL) - DATABASE_HOST=<your-do-mysql-host> - DATABASE_PORT=25060 - Connects to production database with all existing data

Redis Settings: - Using Docker Redis container - REDIS_HOST=redis (Docker service name) - REDIS_URL=redis://redis:6379/0 - HUEY_IMMEDIATE=False (enables background processing)

Security Settings: - SECRET_KEY wrapped in single quotes to prevent Docker variable interpolation (contained $ih_x) - DEBUG=True (temporary - for development until nginx is configured) - ALLOWED_HOSTS=localhost,127.0.0.1,uplink.sensational.systems,uplink.sensational.systems,<server-ip> - Important: No spaces after commas (caused initial "Invalid HTTP_HOST" errors)

Status: ✓ Complete

Issues Resolved: 1. SECRET_KEY variable interpolation - wrapped in quotes 2. ALLOWED_HOSTS space issue - removed space before IP address 3. Database whitelisting - added to DigitalOcean trusted sources

9. Docker Container Architecture

Final Container Setup (4 containers)

┌─────────────────────────────────────────────────────────────┐
│                       NGINX (Port 80/443)                    │
│                    (Not set up yet - future)                 │
└─────────────────┬─────────────────────┬─────────────────────┘
                  │                     │
                  │                     │
    ┌─────────────▼──────────┐    ┌────▼─────────────────┐
    │   web container        │    │  daphne container    │
    │   (runserver/gunicorn) │    │  (ASGI server)       │
    │   Port: 8001→8000      │    │  Port: 9000→9000     │
    │                        │    │                      │
    │   Handles:             │    │  Handles:            │
    │   • Web pages          │    │  • WebSockets        │
    │   • Forms              │    │  • Printer commands  │
    │   • REST API           │    │  • Real-time updates │
    └────┬───────────────────┘    └──────┬───────────────┘
         │                               │
         │    ┌──────────────────────────┘
         │    │
         │    │        ┌──────────────────────┐
         │    │        │  huey container      │
         │    │        │  (Background worker) │
         │    │        │  No exposed ports    │
         │    │        │                      │
         │    │        │  Runs:               │
         │    │        │  • Prestashop sync   │
         │    │        │  • Product updates   │
         │    │        │  • Device cleanup    │
         │    │        └──┬───────────────────┘
         │    │           │
         ├────┴───────────┴──────┐
         │                       │
         │                  ┌─────▼────────────┐
         │                  │  redis container │
         │                  │  (Redis 7)       │
         │                  │  Port: 6381      │
         │                  │                  │
         │                  │  Used by:        │
         │                  │  • Huey queue    │
         │                  │  • Channels      │
         │                  │    (WebSockets)  │
         │                  └──────────────────┘
    ┌────▼─────────────────────────────────┐
    │  DigitalOcean MySQL Cluster          │
    │  (External - Not in Docker)          │
    │  <your-do-mysql-host>                │
    │  Port: 25060                         │
    │                                      │
    │  Contains:                           │
    │  • All production data               │
    │  • Orders, Products, Stock           │
    │  • Shared with old server            │
    └──────────────────────────────────────┘

Key Decisions: - No Docker MySQL container - Using existing DigitalOcean managed database - 4 containers total: web, daphne, huey, redis - Database shared with old server during transition - Redis only used for Huey task queue and Django Channels (WebSockets)

Status: ✓ Complete

10. Checked Out Docker Branch

Switch to Docker feature branch

cd ~/app
git checkout 1130-dockerise-uplink-and-switch-to-using-uv

Branch contains: - Dockerfile - Python 3.9 with uv package manager - docker-compose.yml - Service definitions - docker-compose.prod.yml - Production overrides - .dockerignore - Excludes unnecessary files from build

Status: ✓ Complete


11. Building Docker Containers

Build all container images

cd ~/app
docker compose build
WARN[0000] /home/uplink/app/docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion 
[+] Building 1.4s (23/23) FINISHED                                                                                                          
 => [internal] load local bake definitions                                                                                             0.0s
 => => reading from stdin 1.23kB                                                                                                       0.0s
 => [daphne internal] load build definition from Dockerfile                                                                            0.0s
 => => transferring dockerfile: 1.51kB                                                                                                 0.0s
 => [daphne internal] load metadata for ghcr.io/astral-sh/uv:latest                                                                    0.5s
 => [huey internal] load metadata for docker.io/library/python:3.9-slim                                                                0.5s
 => [huey internal] load .dockerignore                                                                                                 0.0s
 => => transferring context: 766B                                                                                                      0.0s
 => [daphne stage-0  1/10] FROM docker.io/library/python:3.9-slim@sha256:2d97f6910b16bd338d3060f261f53f144965f755599aab1acda1e13cf173  0.0s
 => => resolve docker.io/library/python:3.9-slim@sha256:2d97f6910b16bd338d3060f261f53f144965f755599aab1acda1e13cf1731b1b               0.0s
 => [daphne internal] load build context                                                                                               0.1s
 => => transferring context: 38.53kB                                                                                                   0.1s
 => [web] FROM ghcr.io/astral-sh/uv:latest@sha256:4cac394b6b72846f8a85a7a0e577c6d61d4e17fe2ccee65d9451a8b3c9efb4ac                     0.0s
 => => resolve ghcr.io/astral-sh/uv:latest@sha256:4cac394b6b72846f8a85a7a0e577c6d61d4e17fe2ccee65d9451a8b3c9efb4ac                     0.0s
 => CACHED [huey stage-0  2/10] RUN apt-get update && apt-get install -y     gcc     default-libmysqlclient-dev     pkg-config     cu  0.0s
 => CACHED [huey stage-0  3/10] COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv                                          0.0s
 => CACHED [huey stage-0  4/10] WORKDIR /app                                                                                           0.0s
 => CACHED [huey stage-0  5/10] COPY pyproject.toml ./                                                                                 0.0s
 => CACHED [huey stage-0  6/10] COPY .python-version ./                                                                                0.0s
 => CACHED [huey stage-0  7/10] RUN uv pip install --system -e .                                                                       0.0s
 => CACHED [huey stage-0  8/10] COPY . .                                                                                               0.0s
 => CACHED [huey stage-0  9/10] RUN python manage.py collectstatic --noinput --clear || true                                           0.0s
 => CACHED [huey stage-0 10/10] RUN mkdir -p /app/media                                                                                0.0s
 => [daphne] exporting to image                                                                                                        0.2s
 => => exporting layers                                                                                                                0.0s
 => => exporting manifest sha256:f8c592d32aee52b33df174cabe10f115760d6ab2db5c35bcef951c3fe447f330                                      0.0s
 => => exporting config sha256:77980f6e6573cdf1224eafe4323d2f8c33e8ce9165aa15c99124e9a4eeb3d4c5                                        0.0s
 => => exporting attestation manifest sha256:86d5f4ef39cd16125152630a01748890a1a232fba5d75a10282fd642b9457481                          0.1s
 => => exporting manifest list sha256:f047bfb2b3e5a28b961614e392d1417780e1df8796c55ba9a709001bfc245f45                                 0.0s
 => => naming to docker.io/library/app-daphne:latest                                                                                   0.0s
 => => unpacking to docker.io/library/app-daphne:latest                                                                                0.0s
 => [web] exporting to image                                                                                                           0.2s
 => => exporting layers                                                                                                                0.0s
<<<<<<< HEAD

Build Details: - Used cached layers from previous builds (fast rebuild in 1.4s) - Three images created: app-web, app-daphne, app-huey - All use same base Dockerfile with different CMD instructions

Build Details: - Used cached layers from previous builds (fast rebuild in 1.4s) - Three images created: app-web, app-daphne, app-huey - All use same base Dockerfile with different CMD instructions

Status: ✓ Complete


12. Starting Docker Containers

Initial container startup

docker compose up -d
[+] build 3/3
  Image app-web    Built                                                                                                               1.5s

Initial Issues: - Included Docker MySQL container (later removed) - Database connection failed - needed to whitelist new server IP in DigitalOcean

d42e917 (removing static volumes from the docker container)

Status: ✓ Complete (after fixes)


13. DigitalOcean Database Configuration

Whitelist new server IP

  1. Logged into DigitalOcean dashboard
  2. Navigated to Databases → MySQL cluster
  3. Settings → Trusted Sources
  4. Added <server-ip> to allowed IPs

Remove Docker MySQL container

Docker MySQL was originally included but unnecessary since using DigitalOcean managed database.

Modified docker-compose.yml: - Commented out db service - Removed db dependencies from web/daphne/huey services - Removed database environment variable overrides - Commented out mysql_data volume

Current Status Summary

✅ Completed: - [x] Initial server setup and user configuration - [x] System updates and essential tools - [x] Timezone configuration - [x] Firewall configuration (SSH, HTTP, HTTPS, Docker ports) - [x] Docker and Docker Compose installation - [x] Repository cloned and correct branch checked out - [x] .env file created with production settings - [x] Docker containers built (web, daphne, huey, redis) - [x] Connected to DigitalOcean MySQL database - [x] Debug toolbar disabled in Docker - [x] Database migrations applied - [x] Static files collected - [x] Site functional at http://:8001

⚠️ Current Limitations: - Using DEBUG=True with Django runserver (development mode) - Slower performance than production (acceptable for testing) - Direct port access (8001) instead of standard HTTP/HTTPS - No SSL certificates yet Domain (planned) | uplink.sensational.systems | | IP Address | | | Private IP | | | OS | Ubuntu 24.04 LTS | | User | uplink | | Docker | 29.2.1 | | Docker Compose | v5.0.2 | | Git Branch | 1130-dockerise-uplink-and-switch-to-using-uv | | App Directory | /srv/uplink/app | | Symlink | ~/app → /srv/uplink/app | | Timezone | Europe/London | | Firewall | UFW enabled (22, 80, 443, 8001, 9000) | | Setup Date | February 20, 2026 | | Database | DigitalOcean MySQL (shared with old server) | | Current URL** | http://:8001 |


Container Configuration

Container Image Port Mapping Purpose
uplink_web app-web 8001→8000 Django HTTP (runserver/gunicorn)
uplink_daphne app-daphne 9000→9000 ASGI WebSocket server
uplink_huey app-huey (internal) Background task worker
uplink_redis redis:7-alpine 6381→6379 Task queue & Channels backend

Important Notes

Database Architecture

  • NOT using Docker MySQL - Connected to existing DigitalOcean managed database
  • Database shared with old server during transition period
  • Server IP () whitelisted in DigitalOcean trusted sources
  • Database host: <your-do-mysql-host>:25060

Debug Toolbar

  • Disabled in Docker containers via DOCKER_CONTAINER=1 environment variable
  • Prevents ASGI timeout issues when accessing from external IPs
  • Still enabled for local development (when not in Docker)

Current Mode

  • Running in development mode (DEBUG=True, runserver)
  • Acceptable performance for testing
  • Will switch to production mode (DEBUG=False, gunicorn) after nginx setup

Security

  • SSH key created with passphrase for added security
  • Docker group membership allows non-root user to run Docker commands
  • Repository cloned to /srv/uplink/app following Linux best practices
  • Firewall configured with necessary ports
  • SECRET_KEY in .env wrapped in quotes to prevent variable interpolation

Known Issues & Resolutions

  1. ✅ ALLOWED_HOSTS space issue - fixed by removing spaces in comma-separated list
  2. ✅ Database connection timeout - fixed by whitelisting IP in DigitalOcean
  3. ✅ Debug toolbar timeout - fixed by disabling in Docker
  4. ✅ Missing database column - fixed by running migration
  5. [ ] Configure backup scripts
  6. [ ] Run security audit
  7. [ ] Performance testing and optimization
  8. [ ] Load testing

Phase 3: Cutover Planning

  • [ ] DNS preparation (test with uplink.sensational.systems)
  • [ ] Final data sync verification
  • [ ] Cutover plan documentation
  • [ ] Rollback procedure
  • [ ] DNS cutover to Uplink2
  • [ ] Monitor and verify
  • [ ] Decommission old server

14. Firewall Configuration for Docker Ports

Open ports for direct container access

sudo ufw allow 8001/tcp  # Web container
sudo ufw allow 9000/tcp  # Daphne WebSocket container

Note: These ports are temporary for direct testing. In production, nginx will proxy to these ports and only 80/443 will be exposed externally.

Status: ✓ Complete


15. ALLOWED_HOSTS Configuration Fix

Issue

Site not loading - Django rejecting requests with "Invalid HTTP_HOST header" error.

Investigation

docker compose exec web python manage.py shell -c "from django.conf import settings; print(settings.ALLOWED_HOSTS)"
# Output: ['localhost', '127.0.0.1', 'uplink.sensational.systems', '<server-ip>']
# Note the space before the IP address!

Fix

Edited .env file to remove space in ALLOWED_HOSTS:

# Before:
ALLOWED_HOSTS=localhost,127.0.0.1,uplink.sensational.systems, <server-ip>

# After:
ALLOWED_HOSTS=localhost,127.0.0.1,uplink.sensational.systems,<server-ip>,uplink.sensational.systems

Restart containers

docker compose down
docker compose up -d

Status: ✓ Complete


16. Debug Toolbar Performance Issue

Problem Discovered

Products page timing out completely (60+ seconds with 0 bytes received). Orders page loading fine.

Root Cause Analysis

Old Server Architecture: - nginx proxies all HTTP traffic to Daphne on localhost:8066 - Debug toolbar sees requests from 127.0.0.1 (in INTERNAL_IPS) - Works fine because requests appear to come from localhost

New Docker Architecture: - Accessing directly via <server-ip>:8001 (no nginx yet) - Debug toolbar sees external IP (not in INTERNAL_IPS) - Debug toolbar + ASGI + external IPs = hangs/timeouts

Solution Implemented

Conditionally disable debug toolbar in Docker containers:

Modified uplink/settings.py:

# Removed debug_toolbar from default INSTALLED_APPS

if DEBUG:
    INSTALLED_APPS += ["django_extensions"]
    # Only enable debug toolbar in local dev, not in Docker
    import os
    if not os.environ.get('DOCKER_CONTAINER'):
        INSTALLED_APPS.append("debug_toolbar")

# Removed debug_toolbar.middleware from default MIDDLEWARE

Modified docker-compose.yml: Added DOCKER_CONTAINER=1 environment variable to all app containers (web, daphne, huey).

Deployment

# On local machine
git add uplink/settings.py docker-compose.yml
git commit -m "Disable debug toolbar in Docker to fix ASGI timeout"
git push

# On Uplink2 server
cd ~/app
git pull
docker compose down
docker compose up -d

Status: ✓ Complete


17. Database Migrations

Apply all migrations

docker compose exec web python manage.py migrate

Output:

Operations to perform:
  Apply all migrations: admin, auth, authtoken, catalogue, contacts, contenttypes, 
  devices, orders, prestashop, services, sessions
Running migrations:
  Applying catalogue.0XXX_add_manufacturer_product_name... OK
  [... additional migrations ...]

All migrations applied successfully, including missing manufacturer_product_name column.

Status: ✓ Complete


18. Static Files Collection

Collect static files for serving

docker compose exec web python manage.py collectstatic --noinput

Output:

Copying static files...
X static files copied to '/app/static', Y unmodified.

All static files (CSS, JS, images) copied successfully to /app/static directory.

Status: ✓ Complete


19. Final Verification

Check container status

docker compose ps

All containers healthy: - ✅ uplink_web - Django runserver on port 8001 - ✅ uplink_daphne - ASGI server on port 9000 - ✅ uplink_huey - Background worker - ✅ uplink_redis - Redis cache/queue

Verify site functionality

  • Site accessible at http://:8001
  • Login working correctly
  • Orders page loading (1 second)
  • Products page loading (previously timing out, now working)
  • All production data visible from DigitalOcean database

Status: ✓ Complete

uplink@Uplink2:~/app$

12 Applied Migrations

docker compose exec web python manage.py migrate
All were applied sucesfully.

13 Collected Static Files

docker compose exec web python manage.py collectstatic --noinput
All files were copied sucessfully.

Next Steps

  • [ ] Create .env file with production environment variables
  • [ ] Set up database container
  • [ ] Build and deploy Docker containers
  • [ ] Configure nginx reverse proxy
  • [ ] Set up SSL certificates (Let's Encrypt)
  • [ ] Configure systemd services
  • [ ] Set up log rotation
  • [ ] Configure backup scripts
  • [ ] Run security audit
  • [ ] Performance testing
  • [ ] Data migration from old server
  • [ ] DNS cutover planning

Server Information Summary

Item Value
Server Name Uplink2
IP Address
OS Ubuntu 24.04 LTS
User uplink
Docker 29.2.1
Docker Compose v5.0.2
App Directory /srv/uplink/app
Timezone Europe/London
Firewall UFW enabled (22, 80, 443)
SSL Certificate Let's Encrypt (expires May 21, 2026)
Domain uplink.sensational.systems
Setup Date February 20, 2026

20. Whitenoise Installation for Static File Serving

Problem Identified

Static files (images, CSS, JS) in Docker volume not accessible to nginx on host filesystem. Missing flag images and styling on orders page.

Solution: Whitenoise

Whitenoise allows Django/gunicorn to serve static files directly, eliminating need for nginx to access static file volumes.

Added whitenoise to dependencies

Modified pyproject.toml:

dependencies = [
    ...
    "gunicorn",
    "daphne",
    "whitenoise",
]

Configured Django settings

Modified uplink/settings.py:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",  # Added after SecurityMiddleware
    "django.contrib.sessions.middleware.SessionMiddleware",
    ...
]

# Added at end of static files configuration
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"

Removed Docker volume mounts

Modified docker-compose.yml: - Removed static_volume:/app/static and media_volume:/app/media from web service - Static files now on host filesystem at /srv/uplink/app/static/ - Whitenoise compresses and serves static files with proper cache headers

Benefits: - Static files served efficiently by Django/gunicorn - No need for nginx to access Docker volumes - Compressed static files for faster loading - Proper cache headers (30 days for immutable files) - Completely portable - no host filesystem dependencies

Status: ✓ Complete


21. Dockerized nginx Reverse Proxy

Motivation

Make entire stack portable - nginx as Docker container instead of host service.

Created nginx configuration

Created nginx.conf:

events {
    worker_connections 1024;
}

http {
    upstream django {
        server web:8000;
    }

    upstream daphne {
        server daphne:9000;
    }

    gzip on;
    gzip_vary on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

    server {
        listen 80;
        server_name uplink.sensational.systems;

        location /.well-known/acme-challenge/ {
            root /var/www/certbot;
        }

        location / {
            return 301 https://$host$request_uri;
        }
    }

    server {
        listen 443 ssl http2;
        server_name uplink.sensational.systems;

        ssl_certificate /etc/letsencrypt/live/uplink.sensational.systems/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/uplink.sensational.systems/privkey.pem;
        include /etc/letsencrypt/options-ssl-nginx.conf;
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_prefer_server_ciphers on;

        client_max_body_size 100M;

        # WebSocket connections → Daphne
        location /ws/ {
            proxy_pass http://daphne;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        # Main application → Django (with Whitenoise for static files)
        location / {
            proxy_pass http://django;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

Added nginx and certbot containers

Modified docker-compose.yml:

nginx:
  image: nginx:1.24-alpine
  container_name: uplink_nginx
  ports:
    - "80:80"
    - "443:443"
  volumes:
    - ./nginx.conf:/etc/nginx/nginx.conf:ro
    - ./certbot/conf:/etc/letsencrypt
    - ./certbot/www:/var/www/certbot
  depends_on:
    - web
    - daphne
  networks:
    - uplink_network
  restart: unless-stopped
  command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"

certbot:
  image: certbot/certbot
  container_name: uplink_certbot
  volumes:
    - ./certbot/conf:/etc/letsencrypt
    - ./certbot/www:/var/www/certbot
  entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"

Disabled host nginx

sudo systemctl stop nginx
sudo systemctl disable nginx

Status: ✓ Complete


22. SSL Certificate Setup with Let's Encrypt

DNS Configuration

Created DNS A record: - Hostname: uplink.sensational.systems - Type: A - Value: - TTL: 3600

Verified propagation:

nslookup uplink.sensational.systems 8.8.8.8
# Output: <server-ip>

Created initialization script

Created init-letsencrypt.sh:

#!/bin/bash

domains=(uplink.sensational.systems)
rsa_key_size=4096
data_path="./certbot"
email="your-email@example.com"
staging=0  # Set to 1 for testing

# Download recommended TLS parameters
if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ]; then
  mkdir -p "$data_path/conf"
  curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
  curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
fi

# Create dummy certificate to start nginx
mkdir -p "$data_path/conf/live/$domains"
docker compose run --rm --entrypoint "\
  openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
    -keyout '/etc/letsencrypt/live/$domains/privkey.pem' \
    -out '/etc/letsencrypt/live/$domains/fullchain.pem' \
    -subj '/CN=localhost'" certbot

# Start nginx
docker compose up -d nginx

# Delete dummy certificate
docker compose run --rm --entrypoint "\
  rm -Rf /etc/letsencrypt/live/$domains && \
  rm -Rf /etc/letsencrypt/archive/$domains && \
  rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot

# Request real certificate
docker compose run --rm --entrypoint "\
  certbot certonly --webroot -w /var/www/certbot \
    $staging_arg \
    --email $email \
    -d $domains \
    --rsa-key-size $rsa_key_size \
    --agree-tos \
    --force-renewal" certbot

# Reload nginx
docker compose exec nginx nginx -s reload

Obtained SSL certificate

chmod +x init-letsencrypt.sh
sudo ./init-letsencrypt.sh

Output:

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/uplink.sensational.systems/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/uplink.sensational.systems/privkey.pem
This certificate expires on 2026-05-21.
These files will be updated when the certificate renews.

Started certbot for auto-renewal

docker compose up -d certbot

Result: - Site accessible at https://uplink.sensational.systems - Valid SSL certificate from Let's Encrypt - HTTP automatically redirects to HTTPS - Certificate auto-renews before expiration (checks twice daily) - All static files displaying correctly via whitenoise

Status: ✓ Complete


Current Status Summary

Containers Running

All 6 containers are healthy and operational:

docker ps
# uplink_nginx - nginx:1.24-alpine (ports 80, 443)
# uplink_certbot - certbot/certbot (SSL renewal)
# uplink_web - app-web:latest (port 8001→8000)
# uplink_daphne - app-daphne:latest (port 9000→9000)
# uplink_huey - app-huey:latest (background worker)
# uplink_redis - redis:7-alpine (port 6381→6379)

Application State

  • ✅ All production data accessible via DigitalOcean MySQL
  • ✅ 348 migrations applied successfully
  • ✅ Static files served via whitenoise (compressed, cached)
  • ✅ SSL certificate valid until May 21, 2026
  • ✅ HTTPS enforced with HTTP→HTTPS redirect
  • ✅ WebSocket connections functional (daphne)
  • ✅ Background tasks running (huey)
  • ⚠️ Running in DEBUG mode with runserver (temporary)

Status: ✓ Complete (SSL and Docker stack operational)


23. Production Configuration - Gunicorn & DEBUG=False

Switched to production mode

On February 24, 2026, completed transition to production-ready configuration.

Modified docker-compose.yml

Changed web service command from runserver to gunicorn:

web:
  build: .
  container_name: uplink_web
  command: gunicorn uplink.wsgi:application --bind 0.0.0.0:8000 --workers 4 --timeout 120
  # ... rest of config unchanged

Gunicorn Configuration: - Workers: 4 (suitable for 2 CPU droplet) - Timeout: 120 seconds (handles PrestaShop sync operations) - Bind: 0.0.0.0:8000 (internal Docker network)

Updated .env file

Changed DEBUG setting:

# Before:
DEBUG=True

# After:
DEBUG=False

Deployed changes

cd /srv/uplink/app
git pull origin 1130-dockerise-uplink-and-switch-to-using-uv
docker compose down
docker compose up -d --build

Verified production operation

# Check web container is using gunicorn
docker compose logs web | grep gunicorn
# Output: [INFO] Listening at: http://0.0.0.0:8000 (1)
# Output: [INFO] Using worker: sync
# Output: [INFO] Booting worker with pid: 7-10

# Test HTTPS endpoint
curl -I https://uplink.sensational.systems
# Output: HTTP/2 302 (redirect to login - expected)

# Verify all containers healthy
docker compose ps
# All containers: Up (healthy)

Results: - ✅ Site accessible at https://uplink.sensational.systems - ✅ Gunicorn serving with 4 worker processes - ✅ DEBUG=False (no sensitive debug information exposed) - ✅ Static files served via whitenoise (compressed) - ✅ Performance improved vs runserver - ✅ Production-ready configuration active

Status: ✓ Complete


24. PrestaShop Sync Cron Jobs

Configured production cron jobs

Updated crontab.production to work with Docker environment:

# PrestaShop Product Import (Every 2 minutes)
*/2 * * * * cd /srv/uplink/app && docker compose exec -T web python manage.py prestashop_import_products >> /srv/uplink/logs/cron.log 2>&1

# PrestaShop Category Import (Every 5 minutes)
*/5 * * * * cd /srv/uplink/app && docker compose exec -T web python manage.py prestashop_import_categories >> /srv/uplink/logs/cron.log 2>&1

# PrestaShop Order Sync (Every 15 minutes)
*/15 * * * * cd /srv/uplink/app && docker compose exec -T web python manage.py prestashop_sync_orders >> /srv/uplink/logs/cron.log 2>&1

Installed crontab

crontab crontab.production
crontab -l  # Verify installation

Verified cron execution

tail -f /srv/uplink/logs/cron.log
# Output shows successful PrestaShop imports running every 2 minutes

Status: ✓ Complete


Current Status Summary (February 24, 2026)

Production Stack - All Services

All 6 Docker containers are healthy and serving production traffic:

Container Image Port Purpose Status
uplink_nginx nginx:1.24-alpine 80, 443 Reverse proxy, SSL termination, HTTPS redirect ✅ Required
uplink_certbot certbot/certbot - SSL certificate management, auto-renewal ✅ Required
uplink_web app-web:latest 8000 Django app (gunicorn, 4 workers) ✅ Required
uplink_daphne app-daphne:latest 9000 ASGI WebSocket server (printer commands) ✅ Required
uplink_huey app-huey:latest - Background worker (PrestaShop sync, emails) ✅ Required
uplink_redis redis:7-alpine 6379 Task queue + Channels backend ✅ Required

All services are necessary - each has a distinct critical function in the production architecture.

Application State

  • Production mode: DEBUG=False, gunicorn with 4 workers
  • HTTPS enforced: Valid SSL certificate (expires May 21, 2026)
  • Database: DigitalOcean MySQL cluster (shared with old server)
  • Static files: Whitenoise (compressed, 30-day cache headers)
  • WebSockets: Daphne handling real-time connections
  • Background tasks: Huey processing PrestaShop sync jobs
  • Cron jobs: PrestaShop sync running every 2-15 minutes
  • Performance: Production-ready with proper timeouts (120s)

Deployment Documentation

  • deploy.sh: Three deployment scenarios (quick/full/rebuild)
  • DOCKER_DEPLOYMENT.md: Complete disaster recovery guide
  • Comprehensive monitoring: Health checks, logging, troubleshooting

Next Steps

  1. Monitor production performance for several days
  2. Plan DNS cutover from uplink.sensational.systems to uplink
  3. Merge Docker branch to main (branch is 28 commits ahead, fully tested)
  4. Switch production to main branch (after merge)
  5. Document backup strategy
  6. Decommission old server after validation period

Notes

  • SSH key created with passphrase for added security
  • Docker group membership allows non-root user to run Docker commands
  • Repository cloned to /srv/uplink/app following Linux best practices for application data
  • Firewall configured for SSH (22), HTTP (80), and HTTPS (443)
  • Complete stack now portable - only requires Docker, repo clone, and .env file
  • Whitenoise serves static files with 30-day cache headers and gzip compression
  • SSL certificate auto-renewal configured via certbot container (checks twice daily)
  • Gunicorn configured with 4 workers and 120-second timeout for long-running operations
  • PrestaShop sync jobs running reliably via Docker exec with centralized logging