Installation and deployment behind nginx

From a bare Ubuntu box to a working HTTPS site: building, PostgreSQL, external configuration, systemd, nginx, and the default settings you have to change.

These instructions assume Ubuntu 22.04/24.04 or Debian 12 and a single machine hosting the application, the database and nginx. On a different distribution only the package names change.

Внимание

The application.properties shipped in the repository is a development configuration. It contains a file path from someone else's workstation, a plaintext administrator password and port 80, which collides with nginx. The «What you must change» section is not a set of suggestions — it is a precondition for the thing working at all.

The end state#

server the internet :80 → :443 nginx TLS certificate, gzip, upload size limit, two location blocks proxy_pass / alias /media/ 127.0.0.1:8080 systemd: otryadwebsite.service files served directly Java is not involved PostgreSQL 127.0.0.1:5432 content properties.json in the working directory settings and SEO /var/lib/otryadwebsite/media/ written by the app, read by nginx photos and video
The application listens on localhost only. A single nginx faces the outside world.

1. Dependencies#

sudo apt update && sudo apt install -y openjdk-21-jdk-headless postgresql nginx git

Spring Boot 4 requires Java 17 as a minimum; pom.xml declares <java.version>17</java.version>, but you can build and run on any newer LTS — Java 21 is preferable because of its garbage collector. Verify that the version was actually picked up:

java -version

2. The database#

sudo -u postgres psql -c "CREATE USER otryad WITH PASSWORD 'REPLACE_ME';"
sudo -u postgres psql -c "CREATE DATABASE otryadwebsite OWNER otryad;"

There is no need to create the schema by hand: the project sets spring.jpa.hibernate.ddl-auto=update, and Hibernate builds the tables itself on first startup.

Осторожно

ddl-auto=update can add columns and tables, but it never drops or narrows existing ones. After an upgrade that renames fields, the old columns stay in the database — harmless, but the schema needs manual attention. Run pg_dump before every upgrade.

3. Building#

Note that the Maven project lives in a nested directory, OtryadWebsite/, rather than at the repository root. Build from there.

git clone https://github.com/Electronprod/OtryadWebsite.git /tmp/otryad-src
cd /tmp/otryad-src/OtryadWebsite && ./mvnw -B clean package -DskipTests

The result is target/OtryadWebsite-1.0.jar, an executable fat jar. Lombok and DevTools do not end up inside it: the former is excluded in the spring-boot-maven-plugin configuration, the latter is marked optional and Spring Boot does not package it into the assembled archive.

Put everything in place:

sudo useradd --system --home /var/lib/otryadwebsite --shell /usr/sbin/nologin otryad
sudo mkdir -p /opt/otryadwebsite /var/lib/otryadwebsite/media /etc/otryadwebsite
sudo cp target/OtryadWebsite-1.0.jar /opt/otryadwebsite/app.jar
sudo chown -R otryad:otryad /var/lib/otryadwebsite

4. What you must change#

There is no need to touch application.properties inside the jar — Spring Boot picks up an external file and it overrides the bundled one. Create /etc/otryadwebsite/application.properties:

# --- port: 80 is taken by nginx ---
server.port=8080
server.address=127.0.0.1

# --- behind a reverse proxy: otherwise the host in sitemap and JSON-LD is wrong ---
server.forward-headers-strategy=native

# --- files ---
system.storage.path=/var/lib/otryadwebsite/media/
web.storage.url-prefix=/media/
web.storage.local-serving=false

# --- upload sizes: keep in sync with client_max_body_size in nginx ---
spring.servlet.multipart.max-file-size=2GB
spring.servlet.multipart.max-request-size=2GB

# --- database ---
spring.datasource.url=jdbc:postgresql://127.0.0.1:5432/otryadwebsite
spring.datasource.username=otryad
spring.datasource.password=REPLACE_ME

# --- first administrator: used only when no admin exists in the database yet ---
security.admin.login=REPLACE_ME
security.admin.password=REPLACE_ME8

# --- turn off the development switch ---
dev=false

What matters here and why:

ParameterIn the repositoryWhy it has to change
server.port${SERVER_PORT:80}80 is taken by nginx — the application will not start
system.storage.patha Windows path under a user's Downloads foldera path from the developer's workstation
web.storage.local-servingtrueat true files are served by Spring rather than nginx, and the whole point of the proxy is lost
server.forward-headers-strategycommented outwithout it the application believes it is reachable at http://127.0.0.1:8080, and that address ends up in sitemap.xml, og:url and JSON-LD
security.admin.passworda default value committed to the repositorythe default administrator password is public
spring.servlet.multipart.max-file-size10GBtogether with the nginx limit this sets the real maximum; 10 GB in a single request is almost always a mistake
devtruea development flag — it has no business being on in production

Важно

The administrator password must pass validation on the Person entity: at least 8 characters, at least one Latin letter and at least one digit. Otherwise the application starts but fails to create the first admin and dies with a validation error. The security.admin.* pair is read only when the users table contains no user with the ROLE_ADMIN role — changing it after the first startup is pointless; passwords are changed through the panel.

5. systemd#

A critical detail: site settings are stored in a properties.json file, and the path to it in the code is relative — the file is created in the process's current working directory. That makes WorkingDirectory mandatory: without it the file lands in / or in whatever directory the service happened to be started from, and after a migration all settings, SEO entries and short links appear to "vanish".

/etc/systemd/system/otryadwebsite.service:

[Unit]
Description=OtryadWebsite
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
User=otryad
Group=otryad
WorkingDirectory=/var/lib/otryadwebsite
ExecStart=/usr/bin/java -Xmx512m -jar /opt/otryadwebsite/app.jar \
  --spring.config.additional-location=file:/etc/otryadwebsite/
Restart=on-failure
RestartSec=5
SuccessExitStatus=143

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/otryadwebsite

[Install]
WantedBy=multi-user.target

Permissions on the file holding the passwords, then start it:

sudo chmod 640 /etc/otryadwebsite/application.properties && sudo chown root:otryad /etc/otryadwebsite/application.properties
sudo systemctl daemon-reload && sudo systemctl enable --now otryadwebsite

Check that it came up:

curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/

Logs are at journalctl -u otryadwebsite -f. On the first startup they should contain Administrator profile not found, creating a new one....

6. nginx#

/etc/nginx/sites-available/otryadwebsite:

server {
    listen 80;
    server_name example.org www.example.org;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.org www.example.org;

    ssl_certificate     /etc/letsencrypt/live/example.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.org/privkey.pem;

    # must be no smaller than spring.servlet.multipart.max-file-size
    client_max_body_size 2G;

    # the app compresses only html/xml/json — css, js and svg are left to nginx
    gzip on;
    gzip_types text/css application/javascript image/svg+xml application/json;
    gzip_min_length 1024;

    # media files: served from disk, Java untouched
    location /media/ {
        alias /var/lib/otryadwebsite/media/;
        expires 30d;
        add_header Cache-Control "public, immutable";
        access_log off;
        try_files $uri =404;
    }

    # telemetry does not need to face the internet, even though it requires auth
    location /actuator/ { deny all; }

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        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;
        proxy_set_header X-Forwarded-Host  $host;

        # uploading large videos from the panel
        proxy_request_buffering off;
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }
}

Enable it and verify the configuration:

sudo ln -s /etc/nginx/sites-available/otryadwebsite /etc/nginx/sites-enabled/ && sudo nginx -t && sudo systemctl reload nginx

The certificate:

sudo apt install -y certbot python3-certbot-nginx && sudo certbot --nginx -d example.org -d www.example.org

Why it is set up this way#

alias, not root. Public file addresses are built as web.storage.url-prefix + batchId + file name, that is /media/<uuid>/01.jpg. On disk that is <system.storage.path>/<uuid>/01.jpg. alias discards the /media/ prefix; root would keep it and you would end up with /var/lib/otryadwebsite/media/media/<uuid>/01.jpg. The trailing slashes on location and alias are mandatory — both of them, at the same time.

Uploads still go through Java. nginx serves media for reading only. POST /dashboard/media/upload is handled by Spring, so the size limit is needed both in nginx (client_max_body_size) and in the application (spring.servlet.multipart.*). The smaller of the two is the real limit; nginx returns 413 when it is exceeded.

X-Forwarded-* is useless without forward-headers-strategy. The application builds absolute addresses through ServletUriComponentsBuilder, and those end up in sitemap.xml, in Open Graph and in JSON-LD. Without native they will carry the internal address and the http scheme, and search engines will index links that lead nowhere.

7. After installation#

  1. Go to https://example.org/auth and log in with the security.admin.* pair. Immediately change the password under /dashboard/users and create separate accounts for the moderators.
  2. Turn on protection mode in /dashboard while you are filling the site with content: the public part closes to everyone except staff and holders of the token link.
  3. Replace the domain in robots.txt — it ships with the original deployment's domain hardcoded in the Sitemap: directive. The file lives in src/main/resources/static/, so editing it means editing the sources and rebuilding. The alternative, without a rebuild, is to intercept /robots.txt with its own location block in nginx.
  4. Walk through the titles and descriptions in /dashboard/seo: the shipped defaults describe the original organization.
  5. Check https://example.org/sitemap.xml — the addresses inside must start with your domain and https. If they do not, server.forward-headers-strategy did not take effect.

8. Backups#

The state of the site consists of three independent things, and losing any one of them loses content:

#!/bin/sh
# /usr/local/bin/otryad-backup.sh
set -eu
DST=/var/backups/otryad/$(date +%F)
mkdir -p "$DST"
sudo -u postgres pg_dump otryadwebsite | gzip > "$DST/db.sql.gz"
cp /var/lib/otryadwebsite/properties.json "$DST/"
tar czf "$DST/media.tar.gz" -C /var/lib/otryadwebsite media

properties.json is small, but nothing can reconstruct it: it holds the SEO data for every page, the short links, the contacts, the social links and the access token.

9. Upgrading#

cd /tmp/otryad-src && git pull && cd OtryadWebsite && ./mvnw -B clean package -DskipTests
sudo -u postgres pg_dump otryadwebsite | gzip > /var/backups/otryad-pre-upgrade.sql.gz
sudo systemctl stop otryadwebsite
sudo cp target/OtryadWebsite-1.0.jar /opt/otryadwebsite/app.jar
sudo systemctl start otryadwebsite

A few seconds of downtime are unavoidable: there is a single instance and sessions are held in memory, so anyone logged into the panel is signed out by the restart.

Common problems#

SymptomCause
The service dies on startup with Address already in use in the logsserver.port was not overridden and the application is trying to claim 80
All site settings reset after a restartWorkingDirectory is not set — properties.json was created in a different directory
Album images fail to load with 404system.storage.path and alias disagree, or one of them lost its trailing slash
The album opens but is emptythe batchId directory holds nothing but preview.jpg, which is always excluded from the gallery
sitemap.xml contains addresses like http://127.0.0.1:8080/...server.forward-headers-strategy=native did not take effect
A large video upload fails with 413client_max_body_size is smaller than the file
A large upload fails with 500the nginx limit was raised but spring.servlet.multipart.max-file-size was not
Logging into the panel returns 429 with JSONthe attempt limit kicked in: 5 per minute per address, 5 per minute per login, 30 per day per address
/dashboard returns 403 after logging inthe user's role is neither ROLE_ADMIN nor ROLE_MODERATOR