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
PROD
Related
- Update deployment — pull and rebuild on an existing server
- Security settings — AWS security groups
- Environments — testing vs production hosts
- API installation — local (non-Docker) API setup
- API configuration — Django env vars