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:
Internet
|
Nginx :80/:443
|
127.0.0.1:8001
|
Application container :8000Nginx 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:
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:
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:
- start a temporary HTTP virtual host,
- serve
/.well-known/acme-challenge/, - run
certbot certonly --webroot, - install the final HTTPS virtual host,
- run
nginx -t, - reload Nginx.
Test automatic renewal:
certbot renew --dry-runAn 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:
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 1The image should know its version#
Save the version supplied during the build as a label and environment variable:
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:
- verifies files and version,
- tags the current image for rollback,
- builds the new image,
- starts the new container only after success,
- waits for the health check,
- runs a smoke test,
- 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:
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:
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueThe 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-storefor 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,/readyand/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.
/readyis checked before the release is considered ready./versionmatches 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.
REFERENCE MATERIAL
Documents used as references.
This article was updated on . In production projects, always verify behaviour against the specific software version, topology and trace.