Most data science teams reach for a managed notebook platform first. SageMaker, Vertex AI, and Azure Machine Learning are genuinely convenient, right up until the moment you need to move a workload, audit what actually ran, or explain to your legal team which jurisdiction holds your training data. The exit cost is high, the reproducibility story is fragile, and the sovereignty question has no clean answer. There is a better starting point: a containerised environment on a VPS you control, built so it runs identically from a €20-per-month instance to a bare-metal GPU node, without rewriting a single line.

This guide covers the full setup: Python environment containerisation, a production-grade Jupyter notebook server deployment, and the operational patterns that make a data science VPS setup genuinely portable. The goal is an environment where the machine is replaceable but the experiment is not.

Why containerisation is the foundation

The reproducible ML environment problem is fundamentally a dependency problem. A notebook that works on your laptop fails on the training server because CUDA versions differ, because a transitive pip dependency upgraded, or because the base OS diverged. Containers eliminate that class of failure by making the environment an artefact you can version, test, and ship.

The practical starting point is a Dockerfile that pins every layer: base image, CUDA runtime if you need GPU support, Python version, and package versions locked via pip-compile or poetry lock. Do not use latest anywhere in the image definition. Tag images with the Git commit SHA of the repository that produced them, and push to a private registry you operate. That tag is your audit trail.

A minimal CPU image for exploratory work might look like this:

FROM python:3.11.9-slim
WORKDIR /workspace
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["jupyter", "lab", "--ip=0.0.0.0", "--no-browser", "--allow-root"]

The GPU variant swaps the base image for nvidia/cuda:12.4.1-runtime-ubuntu22.04 and adds the CUDA-aware wheels. The application code and notebooks are identical. Promoting from prototype to production means updating one line in your Compose file or Kubernetes manifest.

Jupyter notebook server deployment: the right way

Running Jupyter directly on a public IP with a token is not a deployment, it is an incident waiting to happen. The correct pattern has three components: a reverse proxy handling TLS termination, an authentication layer in front of the notebook server, and the server itself bound to localhost only.

Nginx or Caddy works well as the reverse proxy. Caddy’s automatic certificate management via Let’s Encrypt reduces operational overhead significantly for small teams. Place it in front of JupyterHub if you have multiple users, or in front of a single Jupyter Lab instance for solo or paired work. Bind the notebook server to 127.0.0.1:8888 and proxy through the reverse proxy on port 443. Never expose the notebook port directly.

For authentication, JupyterHub with PAM or LDAP authentication is the production-grade choice. For smaller setups, an SSH tunnel is both simpler and more secure: forward the notebook port locally, access it on localhost, and keep the server entirely off the public internet.

The environment that runs in production should be the same container image that ran in development. If it is not, you are not doing reproducible science, you are doing reproducible coincidence.

Structuring the project for portability

Reproducibility breaks down at the data layer as often as at the code layer. Treat data references the same way you treat dependency versions: explicit, versioned, and stored outside the container. Object storage with a versioned bucket is the right abstraction. Your notebook reads from a path like s3://project-data/v3/train.parquet; the version is in the path, not implicit in when you ran the cell.

The project layout that survives the prototype-to-production transition cleanly separates concerns:

  • notebooks/ for exploration and reporting, committed to Git with outputs stripped
  • src/ for reusable Python modules extracted from notebooks once they stabilise
  • pipelines/ for scheduled or triggered jobs, defined as plain Python scripts or lightweight DAGs
  • docker/ for Dockerfiles and Compose definitions, one per environment tier
  • data/ as a local mount point only, with the actual data in object storage and a .gitignore covering everything under it

MLflow or DVC handles experiment tracking without requiring a managed service. Both run on your own VPS, store artefacts in your object storage bucket, and expose a UI you access through the same reverse proxy pattern described above. The experiment log stays in your infrastructure, under your keys.

Scaling from a single VPS to bare metal

The same container image that runs on a 4-core VPS for data exploration runs on a bare-metal GPU node for training. The only change is the Compose override or Kubernetes node selector that requests GPU resources. This is where the investment in containerisation pays back: you do not rewrite the environment when you scale the compute.

The practical data science infrastructure best practice for scaling is to keep the notebook server on a persistent, modestly sized VPS and run training jobs as separate containers on larger instances provisioned on demand. The notebook is a long-lived service; the training job is ephemeral. Mixing them on the same instance couples two things with very different lifecycle requirements.

For teams moving from SageMaker or Vertex AI, the migration path is straightforward. Export your notebooks. Rebuild the environment from a Dockerfile that pins the Python and library versions you were using. Point your data references at your own object storage. The managed platform was providing compute, a runtime, and a UI; all three are replaceable with open tooling running on infrastructure you control.

Operational foundations that matter at any scale

A data science VPS setup is still a server, and servers need operational discipline. Three things that teams consistently underinvest in:

  1. Automated backups of the notebook server state. Notebooks, MLflow tracking data, and the registry of container images should all have a documented backup and restore procedure. Test the restore, not just the backup.
  2. Resource limits on containers. An unconstrained training job will saturate memory and make the notebook server unresponsive. Set CPU and memory limits in your Compose or Kubernetes definitions, even on a single-node setup.
  3. Secrets management. Database credentials, object storage keys, and API tokens do not belong in environment files committed to Git. Use Docker secrets, a secrets manager, or at minimum environment-variable injection at runtime from a file that is explicitly excluded from version control.

We run this stack on Tier III infrastructure with ISO 27001 certification and a contractual 99.9% uptime SLA, which means the operational guarantees are written into the agreement, not stated in a marketing page. For teams working with sensitive training data, that distinction matters: sovereignty is a contract-level commitment, not a feature flag.

The lock-in you avoid

Managed notebook platforms bundle convenience with dependency. The convenience is real: provisioning is fast, integrations are pre-built, and the first experiment runs in minutes. The dependency is also real: your environment is defined by the platform’s runtime, your data pipelines integrate with the platform’s storage and IAM, and your team learns the platform’s abstractions rather than transferable ones.

A containerised environment on a VPS you control inverts that trade-off. Setup takes longer the first time. Every subsequent environment is a Dockerfile and a Compose file. The team learns Docker, Python packaging, and standard Linux tooling, skills that transfer to any infrastructure. The experiment artefacts live in storage you own. When you need more compute, you provision a larger instance or a bare-metal node without renegotiating a platform relationship.

That is what a reproducible ML environment actually means in practice: not just that the experiment can be re-run, but that the infrastructure it runs on is yours to move, modify, and reason about without asking permission.