A simple architecture can be production-ready#

One or a few applications on a VPS do not always need Kubernetes. Host Nginx, Docker Compose and a controlled release process can provide a clear separation of responsibilities:

text
Internet
   |
Nginx :80/:443
   |
127.0.0.1:8001
   |
Application container :8000

Nginx handles TLS, domain redirects, security headers, limits and access logs. The container contains the application and its dependencies. The application port is published only on 127.0.0.1, so it cannot be accessed directly from the Internet.

Binding to localhost reduces the attack surface#

In Compose:

yaml
services:
  website:
    build: .
    ports:
      - "127.0.0.1:8001:8000"

The difference between 8001:8000 and 127.0.0.1:8001:8000 matters. The first usually publishes the port on every host interface. The second restricts it to loopback. Nginx can still reach the application, but an external client cannot bypass the reverse proxy.

A host firewall is still necessary. Binding to localhost protects that application port; it does not replace a policy for SSH, the Docker daemon, databases and other services.

The reverse proxy must pass the correct context#

A minimal block:

nginx
location / {
    proxy_pass http://127.0.0.1:8001;
    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;
}

The application needs the original host and protocol for URL generation, logging and security rules. At the same time, it should trust forwarded headers only from a controlled proxy. If its port is public, a client can supply a forged X-Forwarded-For itself.

For WebSockets, add Upgrade and Connection. For uploads, choose an intentional client_max_body_size rather than an arbitrarily huge limit for the entire domain.

TLS: a working HTTP challenge before the final virtual host#

The first certificate does not yet exist in /etc/letsencrypt/live/.... If Nginx references missing files immediately, nginx -t will fail. A safe sequence is:

  1. start a temporary HTTP virtual host,
  2. serve /.well-known/acme-challenge/,
  3. run certbot certonly --webroot,
  4. install the final HTTPS virtual host,
  5. run nginx -t,
  6. reload Nginx.

Test automatic renewal:

bash
certbot renew --dry-run

An existing certificate alone does not prove the timer, challenge and reload work. Monitor its expiry from an external location.

Liveness, readiness and smoke tests have different roles#

Liveness should be simple and stable. It answers whether the process can handle a basic request. Docker can use it to mark the container as healthy.

Readiness may check required files, SMTP configuration, a database connection or migrations. Not every readiness failure should trigger a restart. If external SMTP temporarily fails, restarting the application may solve nothing.

A smoke test checks public paths after starting a new version: the home page, subpages, sitemap, API and version number. It is a release check rather than a continuous health check.

Example image health check:

dockerfile
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)" || exit 1

The image should know its version#

Save the version supplied during the build as a label and environment variable:

dockerfile
ARG APP_VERSION=dev
LABEL org.opencontainers.image.version="${APP_VERSION}"
ENV APP_VERSION=${APP_VERSION}

The /version endpoint lets you check what is actually running. Do not override the version in Compose at startup, because rolling back an old image could then report the new, failed version number.

A release should have an artefact, SHA256 checksum and manifest. They establish what code reached the server, how many routes it contained and whether the package was altered.

Update without stopping the running container during the build#

A safe script:

  1. verifies files and version,
  2. tags the current image for rollback,
  3. builds the new image,
  4. starts the new container only after success,
  5. waits for the health check,
  6. runs a smoke test,
  7. removes old images beyond the retention limit.

If the build fails, the running container should remain untouched. Running docker compose down before the build unnecessarily creates an outage even when compilation fails.

Rollback must use the retained image#

Before the change, read the current image ID and give it a unique tag:

bash
STAMP="$(date +%Y%m%d-%H%M%S)"
docker tag current-image "app:rollback-${STAMP}"

After a failed health check or smoke test, restore the latest tag to the previous image and start Compose without building. Then repeat the test: rollback can also fail if the problem concerns the database, host or Nginx.

Retain a bounded number of rollback images. Unlimited retention can fill the disk and cause an outage during the next deployment.

Read-only containers and minimal privileges#

A static application or an API without local writes can run with a read-only filesystem:

yaml
read_only: true
tmpfs:
  - /tmp:size=16m,mode=1777
cap_drop:
  - ALL
security_opt:
  - no-new-privileges:true

The image should run its process as a non-root user. If the application needs uploads or caching, prepare an explicit volume with restricted permissions rather than disabling protection for the entire filesystem.

Headers, caching and CSP#

Nginx can centrally configure:

  • HSTS after HTTPS has been confirmed to work correctly,
  • X-Content-Type-Options: nosniff,
  • Referrer-Policy,
  • Permissions-Policy,
  • Content-Security-Policy,
  • long cache lifetimes for versioned assets,
  • no-store for API, health and version responses.

CSP needs maintenance. Inline JSON-LD or a script needs a hash or nonce. Automatically generating hashes during release reduces the risk of a content update blocking your own code.

Monitoring outside the host#

A systemd timer on the same VPS can detect an application failure, but cannot detect loss of the entire host or network. External monitoring is needed for at least:

  • the home page,
  • /health, /ready and /version,
  • TLS expiry,
  • response time,
  • the form contract without sending messages.

A local monitor remains useful for SMTP probes, logs and fast alerts when part of the system fails.

Deployment checklist#

  • The container port is bound only to localhost.
  • Nginx forwards Host and the correct forwarded headers.
  • Certificate renewal has passed a dry run.
  • The image runs as non-root and has a health check.
  • /ready is checked before the release is considered ready.
  • /version matches the image label version.
  • An update retains the previous image.
  • The smoke test covers public routes.
  • Rollback has been tested rather than merely documented.
  • Monitoring also runs outside the VPS.

This arrangement remains simple without being improvised. Each layer has a clear responsibility, and the deployment process has evidence of success and a prepared way back.