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¶
Created non-root user¶
Log in as uplink user¶
Status: ✓ Complete
2. System Update & Essential Tools¶
Update and upgrade system¶
Install essential tools¶
Status: ✓ Complete
3. System Configuration¶
Set timezone¶
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:
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¶
Test Docker¶
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¶
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¶
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¶
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
7. Created a symlink for logging into the Uplink user/app folder¶
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¶
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
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¶
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
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¶
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¶
- Logged into DigitalOcean dashboard
- Navigated to Databases → MySQL cluster
- Settings → Trusted Sources
- 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://
⚠️ 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 |
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=1environment 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/appfollowing Linux best practices - Firewall configured with necessary ports
- SECRET_KEY in .env wrapped in quotes to prevent variable interpolation
Known Issues & Resolutions¶
- ✅ ALLOWED_HOSTS space issue - fixed by removing spaces in comma-separated list
- ✅ Database connection timeout - fixed by whitelisting IP in DigitalOcean
- ✅ Debug toolbar timeout - fixed by disabling in Docker
- ✅ Missing database column - fixed by running migration
- [ ] Configure backup scripts
- [ ] Run security audit
- [ ] Performance testing and optimization
- [ ] 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¶
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¶
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¶
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¶
Output:
All static files (CSS, JS, images) copied successfully to /app/static directory.
Status: ✓ Complete
19. Final Verification¶
Check container status¶
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¶
All were applied sucesfully.13 Collected Static Files¶
All files were copied sucessfully.Next Steps¶
- [ ] Create
.envfile 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:
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¶
Status: ✓ Complete
22. SSL Certificate Setup with Let's Encrypt¶
DNS Configuration¶
Created DNS A record:
- Hostname: uplink.sensational.systems
- Type: A
- Value:
Verified propagation:
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¶
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¶
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:
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¶
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¶
- Monitor production performance for several days
- Plan DNS cutover from uplink.sensational.systems to uplink
- Merge Docker branch to main (branch is 28 commits ahead, fully tested)
- Switch production to main branch (after merge)
- Document backup strategy
- 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/appfollowing 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