Fresh server deployment

RESA currently runs on AWS. This page describes bringing up the stack on a blank Ubuntu server after you connect with SSH credentials or a .pem key.

Before opening the server to the internet, configure inbound rules — see Security settings.

Environment Domain Notes
Testing *.morvanes.info Shared test stack — nginx example below
Production *.morvanes.com Same Compose layout; swap hostnames and certs

1. Connect to the server

From your machine (replace key path, user, and host):

chmod 400 /path/to/your-key.pem
ssh -i /path/to/your-key.pem ubuntu@SERVER_IP

2. Install Docker

sudo apt-get update
sudo apt-get install docker.io docker-compose-v2 -y
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker $USER

Log out and back in (or start a new SSH session) so the docker group applies.

docker --version
docker compose version

3. Create workspace and clone repos

mkdir resa
cd resa
git clone https://github.com/RESAProject/api.git
git clone https://github.com/RESAProject/staff.git
git clone https://github.com/RESAProject/customer.git

4. Server .env and Django secret key

Generate DJANGO_SECRET_KEY

On the server (no Django install required):

python3 -c "import secrets; print(secrets.token_urlsafe(50))"

Or:

openssl rand -base64 48

Copy the output into .env as DJANGO_SECRET_KEY. Do not reuse a key from another environment or commit it to git.

Create .env

nano .env
DJANGO_SECRET_KEY=<GENERATED_SECRET_KEY>

MYSQL_DATABASE=resa_db
MYSQL_USER=resa
MYSQL_PASSWORD=<PASS>
MYSQL_ROOT_PASSWORD=<ROOT_PASS>

DB_NAME=resa_db
DB_USER=root
DB_PASSWORD=<ROOT_PASS>
DB_HOST=db
DB_PORT=3306
DB_SSLMODE=DISABLED

# Testing example — replace IP/hostnames for server.
ALLOWED_HOSTS=localhost,127.0.0.1,nginx,SERVER_IP,resamenu.morvanes.info,resa.morvanes.info

# Local Vite origins for development. On the test/prod server, prefer only the real HTTPS origins instead (or in addition).
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,https://resamenu.morvanes.info,https://resa.morvanes.info
Variable Notes
DJANGO_SECRET_KEY From the generation step above → passed to the API as SECRET_KEY
MYSQL_* Used by the db service to initialize MySQL
DB_* Used by the api service to connect (DB_HOST=db is the Compose service name)
DB_PASSWORD / MYSQL_ROOT_PASSWORD Match when DB_USER=root
ALLOWED_HOSTS Comma-separated; include nginx hostname, server IP, and public hostnames
CORS_ALLOWED_ORIGINS Comma-separated browser origins (Staff/Customer)

For production (.morvanes.com), use production hostnames in ALLOWED_HOSTS and CORS_ALLOWED_ORIGINS, and different DB passwords / secret key than testing.


5. Root docker-compose.yml

nano docker-compose.yml

All sensitive and environment-specific values come from .env (no hardcoded passwords or DB names in Compose):

services:
  db:
    image: mysql:8.4
    container_name: mysql_db
    restart: always
    command: >
      --bind-address=0.0.0.0
      --innodb-buffer-pool-size=128M
      --max-connections=30
      --performance-schema=OFF
    environment:
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql

  api:
    build: ./api
    container_name: api
    command: >
      sh -c "python manage.py collectstatic --noinput &&
      python manage.py migrate &&
      gunicorn --bind 0.0.0.0:8000 config.wsgi:application"
    environment:
      - DB_HOST=db
      - SECRET_KEY=${DJANGO_SECRET_KEY}
      - DB_NAME=${DB_NAME}
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}
      - DB_SSLMODE=${DB_SSLMODE}
      - ALLOWED_HOSTS=${ALLOWED_HOSTS}
      - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS}
    volumes:
      - static_volume:/app/staticfiles
      - media_volume:/app/mediafiles
    depends_on:
      - db

  customer:
    build: ./customer
    container_name: customer
    restart: always

  staff:
    build: ./staff
    container_name: staff
    restart: always

  nginx:
    image: nginx:alpine
    container_name: nginx_proxy
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
      - static_volume:/app/staticfiles
      - media_volume:/app/mediafiles
      - /etc/letsencrypt/live:/etc/letsencrypt/live:ro
      - /etc/letsencrypt/archive:/etc/letsencrypt/archive:ro
    depends_on:
      - api
      - customer
      - staff

volumes:
  mysql_data:
  static_volume:
  media_volume:

6. API Dockerfile

nano api/Dockerfile
FROM python:3.12-slim

RUN apt-get update && apt-get install -y \
    gcc \
    default-libmysqlclient-dev \
    default-mysql-client \
    pkg-config \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

ENV PYTHONUNBUFFERED=1

EXPOSE 8000

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "config.wsgi:application"]

7. Staff Dockerfile

nano staff/Dockerfile
FROM node:24-alpine as build-stage

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

FROM nginx:alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build-stage /app/dist /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

staff/nginx.conf

SPA fallback for the Vite build inside the Staff container (copied by the Dockerfile above):

nano staff/nginx.conf
server {
    listen 80;

    location / {
        root /usr/share/nginx/html;
        index index.html index.htm;
        try_files $uri $uri/ /index.html;
    }
}

8. Customer Dockerfile

nano customer/Dockerfile
FROM node:24-alpine as build-stage

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

FROM nginx:alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build-stage /app/dist /usr/share/nginx/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

customer/nginx.conf

Same SPA pattern for the Customer container:

nano customer/nginx.conf
server {
    listen 80;

    location / {
        root /usr/share/nginx/html;
        index index.html index.htm;
        try_files $uri $uri/ /index.html;
    }
}

9. TLS certificates (Certbot)

Install Certbot

sudo apt-get update
sudo apt-get install certbot -y

Testing (morvanes.info)

sudo certbot certonly --standalone \
  -d resamenu.morvanes.info \
  -d resa.morvanes.info \
  --email <your.email@address.com> \
  --agree-tos \
  --non-interactive

Confirm on the server:

sudo ls /etc/letsencrypt/live/

Compose mounts /etc/letsencrypt/live and /etc/letsencrypt/archive read-only into the nginx service.

Production (morvanes.com)

Same commands with production hostnames, for example:

sudo certbot certonly --standalone \
  -d resamenu.morvanes.com \
  -d resa.morvanes.com \
  --email <your.email@address.com> \
  --agree-tos \
  --non-interactive

10. Reverse-proxy nginx config

mkdir nginx
nano nginx/default.conf

Testing (morvanes.info)

# Auto Redirect to Secure Protocol
server {
    listen 80 default_server;
    server_name _;
    return 301 https://$host$request_uri;
}

# CUSTOMER
server {
    listen 443 ssl;
    server_name resamenu.morvanes.info;

    ssl_certificate /etc/letsencrypt/live/resamenu.morvanes.info/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/resamenu.morvanes.info/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    location /static/ {
        alias /app/staticfiles/;
    }

    location /media/ {
        alias /app/mediafiles/;
    }

    location /api/ {
        proxy_pass http://api:8000;
        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;
    }

    location / {
        proxy_pass http://customer:80;
        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;
    }
}

# STAFF
server {
    listen 443 ssl;
    server_name resa.morvanes.info;

    ssl_certificate /etc/letsencrypt/live/resa.morvanes.info/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/resa.morvanes.info/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    location /static/ {
        alias /app/staticfiles/;
    }

    location /media/ {
        alias /app/mediafiles/;
    }

    location /api/ {
        proxy_pass http://api:8000;
        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;
    }

    location /django-admin/ {
        proxy_pass http://api:8000;
        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;
    }

    location / {
        proxy_pass http://staff:80;
        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;
    }
}
server_name Proxies to Notes
resamenu.morvanes.info customer:80 Guest menu; /api/api:8000
resa.morvanes.info staff:80 POS; also /api/ and /django-admin/ → API

Both vhosts serve /static/ and /media/ from the shared volumes.

Production (morvanes.com)

Same file shape; change hostnames and certificate paths, for example:

Testing Production
resamenu.morvanes.info resamenu.morvanes.com
resa.morvanes.info resa.morvanes.com
resakitchen.morvanes.com (when kitchen is deployed)

Update every server_name, ssl_certificate, and ssl_certificate_key to the matching .com Let’s Encrypt directory. Keep upstreams (api, customer, staff) unchanged unless service names in Compose change.


11. Build and start the stack

From ~/resa (directory with docker-compose.yml and .env):

docker compose up -d --build
docker ps

up -d --build builds images and starts containers in the background. docker ps should list mysql_db, api, customer, staff, and nginx_proxy as running.

If a container exits, inspect logs, for example:

docker compose logs api --tail 100
docker compose logs nginx_proxy --tail 100

Then open the HTTPS hostnames to verify:

Testing
  1. https://resamenu.morvanes.info
  2. https://resa.morvanes.info
PROD
  1. https://resamenu.morvanes.com
  2. https://resa.morvanes.com