diff --git a/.gitea/workflows/deploy-branch-cleanup.yaml b/.gitea/workflows/deploy-branch-cleanup.yaml
new file mode 100644
index 0000000000..764e04ccf2
--- /dev/null
+++ b/.gitea/workflows/deploy-branch-cleanup.yaml
@@ -0,0 +1,14 @@
+name: Remove branch deployment from branch.dsv.su.se
+on:
+  pull_request:
+    types:
+      - closed
+jobs:
+  cleanup:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: https://gitea.dsv.su.se/ansv7779/action-branch-deploy@v2
+        with:
+          cleanup-ssh-key: ${{ secrets.BRANCH_CLEANUP_KEY }}
+          compose-file: compose-branch-deploy.yaml
+          mode: 'cleanup'
diff --git a/.gitea/workflows/deploy-branch.yaml b/.gitea/workflows/deploy-branch.yaml
new file mode 100644
index 0000000000..73ab386992
--- /dev/null
+++ b/.gitea/workflows/deploy-branch.yaml
@@ -0,0 +1,26 @@
+name: Deploy to branch.dsv.su.se
+on:
+  - pull_request
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    steps:
+      - id: deploy
+        uses: https://gitea.dsv.su.se/ansv7779/action-branch-deploy@v2
+        with:
+          ssh-key: ${{ secrets.BRANCH_DEPLOY_KEY }}
+          compose-file: compose-branch-deploy.yaml
+      - name: Post URL to deployment as comment
+        uses: actions/github-script@v7
+        if: github.event.action == 'opened'
+        env:
+          BRANCH_URL: ${{ steps.deploy.outputs.url }}
+        with:
+          script: |
+            const url = process.env.BRANCH_URL;
+            github.rest.issues.createComment({
+              issue_number: context.issue.number,
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              body: `Deployed to ${url}`
+            })
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000..9212410a58
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,39 @@
+FROM debian:bookworm AS build
+
+RUN apt-get update && apt-get install -y openjdk-17-jdk-headless
+
+WORKDIR /app
+
+COPY pom.xml .
+COPY .mvn/ .mvn/
+COPY mvnw .
+COPY api/pom.xml api/pom.xml
+COPY core/pom.xml core/pom.xml
+COPY view/pom.xml view/pom.xml
+COPY war/pom.xml war/pom.xml
+COPY daisy-integration/pom.xml daisy-integration/pom.xml
+
+# Download dependencies in a separate layer to allow caching for future builds
+RUN ./mvnw dependency:go-offline \
+    --batch-mode \
+    --define includeScope=compile  \
+    --activate-profiles docker-dependencies
+
+COPY api/src/ api/src/
+COPY core/src/ core/src/
+COPY view/src/ view/src/
+COPY war/src/ war/src/
+COPY daisy-integration/src/ daisy-integration/src/
+
+RUN ./mvnw package \
+    --offline \
+    --define skipTests \
+    --activate-profiles branch,DEV \
+    --define skip.npm \
+    --define skip.installnodenpm
+
+FROM tomcat:10 AS run
+
+COPY --from=build /app/war/target/*.war /usr/local/tomcat/webapps/ROOT.war
+
+EXPOSE 8080
diff --git a/README.md b/README.md
index 22533e79fc..de348d2c3b 100644
--- a/README.md
+++ b/README.md
@@ -39,3 +39,13 @@ can be performed.
 Go to `Settings -> Language & Frameworks -> JavaScript -> Prettier` and then check
 `Automatic Prettier Configuration`, set `Run for files` to `**/*.{java}`,
 and finally check `Run on save`.
+
+## Test servers
+All pull requests are automatically deployed to a test server.
+The URL to the test server will be posted as a comment in the pull request once deployed.
+
+Prepare test data in the `DataInitializer` class to help others test your changes.
+Document (in the pull request) which users to log in as and what to do to see the changes.
+
+If you want to reset the data to its original state you can re-run the "deploy-branch.yaml"
+workflow at https://gitea.dsv.su.se/DMC/scipro/actions for the branch you want to reset.
diff --git a/compose-branch-deploy.yaml b/compose-branch-deploy.yaml
new file mode 100644
index 0000000000..aba04bbb51
--- /dev/null
+++ b/compose-branch-deploy.yaml
@@ -0,0 +1,72 @@
+services:
+  scipro:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    restart: unless-stopped
+    depends_on:
+      db:
+        condition: service_healthy
+      oauth2:
+        condition: service_started
+    environment:
+      - JDBC_DATABASE_URL=jdbc:mariadb://db:3306/scipro
+      - JDBC_DATABASE_USERNAME=scipro
+      - JDBC_DATABASE_PASSWORD=scipro
+      - OAUTH2_AUTHORIZATION_URI=https://oauth2-${VHOST}/authorize
+      - OAUTH2_TOKEN_URI=https://oauth2-${VHOST}/exchange
+      - OAUTH2_USER_INFO_URI=https://oauth2-${VHOST}/verify
+      - OAUTH2_CLIENT_ID=scipro_client
+      - OAUTH2_CLIENT_SECRET=scipro_secret
+      - OAUTH2_RESOURCE_SERVER_ID=scipro_api_client
+      - OAUTH2_RESOURCE_SERVER_SECRET=scipro_api_secret
+      - OAUTH2_RESOURCE_SERVER_INTROSPECTION_URI=https://oauth2-${VHOST}/introspect
+    networks:
+      - traefik
+      - internal
+    labels:
+      - "traefik.enable=true"
+      - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${VHOST}`)"
+      - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.tls.certresolver=letsencrypt"
+
+  db:
+    image: mariadb:10.11
+    restart: unless-stopped
+    networks:
+      - internal
+    environment:
+      MARIADB_ROOT_PASSWORD: root
+      MARIADB_DATABASE: scipro
+      MARIADB_USER: scipro
+      MARIADB_PASSWORD: scipro
+    healthcheck:
+      test: ["CMD", "healthcheck.sh", "--connect"]
+      start_period: 10s
+      interval: 10s
+      timeout: 5s
+      retries: 6
+
+  oauth2:
+    build:
+      context: https://github.com/dsv-su/toker.git
+      dockerfile: embedded.Dockerfile
+    restart: unless-stopped
+    environment:
+      - CLIENT_ID=scipro_client
+      - CLIENT_SECRET=scipro_secret
+      - CLIENT_REDIRECT_URI=https://${VHOST}/login/oauth2/code/scipro
+      - RESOURCE_SERVER_ID=scipro_api_client
+      - RESOURCE_SERVER_SECRET=scipro_api_secret
+    networks:
+      - traefik
+    labels:
+      - "traefik.enable=true"
+      - "traefik.http.routers.oauth2-${COMPOSE_PROJECT_NAME}.rule=Host(`oauth2-${VHOST}`)"
+      - "traefik.http.routers.oauth2-${COMPOSE_PROJECT_NAME}.tls.certresolver=letsencrypt"
+
+networks:
+  traefik:
+    name: traefik
+    external: true
+  internal:
+    name: ${COMPOSE_PROJECT_NAME}_internal
diff --git a/war/pom.xml b/war/pom.xml
index 456eeae393..19e7317b23 100644
--- a/war/pom.xml
+++ b/war/pom.xml
@@ -12,6 +12,10 @@
     <artifactId>war</artifactId>
     <packaging>war</packaging>
 
+    <properties>
+        <spring.profile.active>tomcat</spring.profile.active>
+    </properties>
+
     <dependencies>
         <dependency>
             <groupId>org.springframework.boot</groupId>
@@ -88,7 +92,26 @@
     </dependencies>
 
     <build>
+        <resources>
+            <resource>
+                <directory>src/main/resources</directory>
+                <filtering>true</filtering>
+            </resource>
+        </resources>
         <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-resources-plugin</artifactId>
+                <version>3.3.1</version>
+                <configuration>
+                    <propertiesEncoding>${project.build.sourceEncoding}</propertiesEncoding>
+                    <delimiters>
+                        <!-- delimiter for resource filtering is changed since Spring hijacks ${...} -->
+                        <delimiter>@</delimiter>
+                    </delimiters>
+                    <useDefaultDelimiters>false</useDefaultDelimiters>
+                </configuration>
+            </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-compiler-plugin</artifactId>
@@ -110,4 +133,53 @@
         </plugins>
     </build>
 
+    <profiles>
+        <profile>
+            <id>branch</id>
+            <properties>
+                <spring.profile.active>branch</spring.profile.active>
+            </properties>
+        </profile>
+        <profile>
+            <id>docker-dependencies</id>
+            <!--
+                Some dependencies are not discovered by default when running dependency:go-offline.
+                They are added here manually to allow Docker build layers to be cached properly.
+            -->
+            <dependencies>
+                <dependency>
+                    <groupId>org.jboss.logging</groupId>
+                    <artifactId>jboss-logging</artifactId>
+                </dependency>
+                <dependency>
+                    <groupId>com.fasterxml</groupId>
+                    <artifactId>classmate</artifactId>
+                </dependency>
+                <dependency>
+                    <groupId>net.bytebuddy</groupId>
+                    <artifactId>byte-buddy-agent</artifactId>
+                </dependency>
+                <dependency>
+                    <groupId>com.querydsl</groupId>
+                    <artifactId>querydsl-apt</artifactId>
+                    <version>${querydsl.version}</version>
+                    <classifier>jakarta</classifier>
+                </dependency>
+                <dependency>
+                    <groupId>org.assertj</groupId>
+                    <artifactId>assertj-core</artifactId>
+                    <scope>test</scope>
+                </dependency>
+                <dependency>
+                    <groupId>net.minidev</groupId>
+                    <artifactId>json-smart</artifactId>
+                </dependency>
+                <dependency>
+                    <groupId>org.hamcrest</groupId>
+                    <artifactId>hamcrest-core</artifactId>
+                    <scope>test</scope>
+                </dependency>
+            </dependencies>
+        </profile>
+    </profiles>
 </project>
diff --git a/war/src/main/java/se/su/dsv/scipro/war/Main.java b/war/src/main/java/se/su/dsv/scipro/war/Main.java
index 2a14e64915..e47665cc3a 100644
--- a/war/src/main/java/se/su/dsv/scipro/war/Main.java
+++ b/war/src/main/java/se/su/dsv/scipro/war/Main.java
@@ -16,12 +16,15 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
 import org.springframework.boot.autoconfigure.domain.EntityScan;
 import org.springframework.boot.autoconfigure.orm.jpa.EntityManagerFactoryBuilderCustomizer;
 import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
 import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Import;
+import org.springframework.core.Ordered;
 import org.springframework.core.task.SimpleAsyncTaskExecutor;
 import org.springframework.orm.jpa.SharedEntityManagerCreator;
 import org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter;
+import org.springframework.web.filter.ForwardedHeaderFilter;
 import se.su.dsv.scipro.CoreConfig;
 import se.su.dsv.scipro.FileSystemStore;
 import se.su.dsv.scipro.RepositoryConfiguration;
@@ -84,6 +87,22 @@ public class Main extends SpringBootServletInitializer implements ServletContain
         return currentProfile;
     }
 
+    /**
+     * Spring runs on HTTP and is protected by a HTTPS proxy.
+     * This filter takes the `X-Forwarded-*` headers and updates the request to reflect the original HTTP request.
+     * <p>
+     * Note: This is not needed when we're running behind Apache as a proxy since it uses an AJP connector that has a
+     * built-in mechanism for handling this,
+     * see <a href="https://tomcat.apache.org/connectors-doc/common_howto/proxy.html#AJP_as_a_Solution">AJP proxy documentation</a>.
+     * So this is only for the temporary test servers that are running behind Traefik and use regular HTTP.
+     */
+    @Bean
+    public FilterRegistrationBean<ForwardedHeaderFilter> forwardedHeaderFilter() {
+        var filterRegistrationBean = new FilterRegistrationBean<>(new ForwardedHeaderFilter());
+        filterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
+        return filterRegistrationBean;
+    }
+
     @Bean
     public FileStore fileStore() {
         return new FileSystemStore();
diff --git a/war/src/main/resources/application-branch.properties b/war/src/main/resources/application-branch.properties
new file mode 100644
index 0000000000..0d6cb9b213
--- /dev/null
+++ b/war/src/main/resources/application-branch.properties
@@ -0,0 +1,19 @@
+spring.datasource.url=${JDBC_DATABASE_URL}
+spring.datasource.username=${JDBC_DATABASE_USERNAME}
+spring.datasource.password=${JDBC_DATABASE_PASSWORD}
+
+profile=DEV
+
+# No secrets available for branch deployment to branch.dsv.su.se
+# Will have to set up some mock API for this later
+service.grading.url=
+oauth.uri=
+oauth.clientId=
+oauth.clientSecret=
+oauth.redirectUri=
+
+# No secrets available for branch deployment to branch.dsv.su.se
+# Will have to set up some mock API for this later
+daisy.api.url=
+daisy.api.username=
+daisy.api.password=
diff --git a/war/src/main/resources/application-tomcat.properties b/war/src/main/resources/application-tomcat.properties
new file mode 100644
index 0000000000..4e2b4fedce
--- /dev/null
+++ b/war/src/main/resources/application-tomcat.properties
@@ -0,0 +1 @@
+spring.datasource.jndi-name=java:/comp/env/jdbc/sciproDS
diff --git a/war/src/main/resources/application.properties b/war/src/main/resources/application.properties
index f405136272..bb89287006 100644
--- a/war/src/main/resources/application.properties
+++ b/war/src/main/resources/application.properties
@@ -1,4 +1,4 @@
-spring.datasource.jndi-name=java:/comp/env/jdbc/sciproDS
+spring.profiles.active=@spring.profile.active@
 spring.flyway.baseline-version=2
 spring.flyway.baseline-on-migrate=true
 
@@ -16,17 +16,17 @@ springdoc.swagger-ui.path=/swagger
 springdoc.swagger-ui.persist-authorization=true
 
 # These will be overwritten by configuration in the environment of servers it is deployed to
-spring.security.oauth2.resourceserver.opaquetoken.client-id=scipro-api-client
-spring.security.oauth2.resourceserver.opaquetoken.client-secret=scipro-api-secret
-spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=http://localhost:59733/introspect
+spring.security.oauth2.resourceserver.opaquetoken.client-id=${OAUTH2_RESOURCE_SERVER_ID:scipro-api-client}
+spring.security.oauth2.resourceserver.opaquetoken.client-secret=${OAUTH2_RESOURCE_SERVER_SECRET:scipro-api-secret}
+spring.security.oauth2.resourceserver.opaquetoken.introspection-uri=${OAUTH2_RESOURCE_SERVER_INTROSPECTION_URI:http://localhost:59733/introspect}
 
 # Log in via local OAuth 2 authorization server
-spring.security.oauth2.client.provider.docker.user-info-uri=http://localhost:59734/verify
+spring.security.oauth2.client.provider.docker.user-info-uri=${OAUTH2_USER_INFO_URI:http://localhost:59734/verify}
 spring.security.oauth2.client.provider.docker.user-name-attribute=sub
-spring.security.oauth2.client.provider.docker.token-uri=http://localhost:59734/exchange
-spring.security.oauth2.client.provider.docker.authorization-uri=http://localhost:59734/authorize
+spring.security.oauth2.client.provider.docker.token-uri=${OAUTH2_TOKEN_URI:http://localhost:59734/exchange}
+spring.security.oauth2.client.provider.docker.authorization-uri=${OAUTH2_AUTHORIZATION_URI:http://localhost:59734/authorize}
 spring.security.oauth2.client.registration.scipro.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
 spring.security.oauth2.client.registration.scipro.provider=docker
-spring.security.oauth2.client.registration.scipro.client-id=scipro
-spring.security.oauth2.client.registration.scipro.client-secret=s3cr3t
+spring.security.oauth2.client.registration.scipro.client-id=${OAUTH2_CLIENT_ID:scipro}
+spring.security.oauth2.client.registration.scipro.client-secret=${OAUTH2_CLIENT_SECRET:s3cr3t}
 spring.security.oauth2.client.registration.scipro.authorization-grant-type=authorization_code